diff --git a/MainForm.pas b/MainForm.pas
index 38755ce..b991f53 100644
--- a/MainForm.pas
+++ b/MainForm.pas
@@ -872,6 +872,8 @@ begin
FWebServer.OnFreqA := FWebAdapter.OnFreqA;
FWebServer.OnWebMic := FWebAdapter.OnMic;
FWebServer.OnXvtrBand := FWebAdapter.OnXvtrBand;
+ FWebServer.OnBeacon := FWebAdapter.OnBeacon;
+ FWebServer.OnBeaconSeed := FWebAdapter.OnBeaconSeed;
FWebServer.OnConnect := WebOnConnect;
FWebServer.OnDiscoverDev := WebOnDiscover;
FWebServer.OnDisconnectDev := WebOnDisconnect;
diff --git a/WebAdapter.pas b/WebAdapter.pas
index 1c4494a..37d9057 100644
--- a/WebAdapter.pas
+++ b/WebAdapter.pas
@@ -66,6 +66,9 @@ type
procedure SyncMOX;
procedure SyncTun;
procedure SyncFMStep;
+ procedure SyncBeacon;
+ procedure SyncBeaconSeed;
+ function BeaconStatusText: string; // компактный статус (как десктоп поле 7)
public
constructor Create(AController: TRadioController; AServer: TWebServer;
AHost: IWebHost);
@@ -97,6 +100,9 @@ type
procedure OnMOX(On_: Boolean);
procedure OnTun(On_: Boolean);
procedure OnFMStep(Idx: Integer);
+ // QO-100 beacon: вкл/выкл лок и наведение по клику в спектре (frac 0..1).
+ procedure OnBeacon(On_: Boolean);
+ procedure OnBeaconSeed(Frac: Double);
procedure OnClientActiveChanged(Active: Boolean);
// TX-mic от web-клиента → WDSP. Вызывается из WS-потока; без маршалинга —
// подача в движок thread-safe (как OnMicPacket/OnDDCIQ контроллера).
@@ -344,6 +350,29 @@ begin
FController.SaveCurrentBand;
end;
+procedure TWebAdapter.OnBeacon(On_: Boolean);
+begin FSyncBool := On_; FController.Invoke(@SyncBeacon); end;
+
+procedure TWebAdapter.SyncBeacon;
+begin
+ // Вкл/выкл лок маяка (= запуск декодера + трим LOError). Идемпотентно.
+ FController.SetBeaconLock(FSyncBool);
+end;
+
+procedure TWebAdapter.OnBeaconSeed(Frac: Double);
+begin FSyncFreq := Frac; FController.Invoke(@SyncBeaconSeed); end;
+
+procedure TWebAdapter.SyncBeaconSeed;
+var Hz: Double;
+begin
+ // Frac (доля 0..1 по ширине спектра) → абс. display-Hz от ЖИВЫХ центра/спана
+ // контроллера (зеркалит десктоп PixelToFreq(X,W,FCenterFreq,FSpanHz)). Так
+ // наведение точно, даже когда LO ретюнится локом и клиентский center отстаёт.
+ if (FSyncFreq < 0) or (FSyncFreq > 1) then Exit;
+ Hz := FController.FCenterFreq + (FSyncFreq - 0.5) * FController.FSpanHz;
+ FController.BeaconSeedAtHz(Hz);
+end;
+
procedure TWebAdapter.OnClientActiveChanged(Active: Boolean);
// Из потока веб-сервера при connect/disconnect клиента. Простая запись флага —
// ядро (SetMOX, в т.ч. HWPTT-путь) выбирает по нему mic-source. Без маршалинга.
@@ -387,6 +416,19 @@ end;
// ── Исходящее зеркало состояния ──────────────────────────────────────────────
+function TWebAdapter.BeaconStatusText: string;
+// Зеркалит TMainForm.BeaconStatusText (поле 7 десктопа). Pluto в трансвертере.
+begin
+ if not FController.BeaconLockEnabled then Exit('BCN: off');
+ if FController.BeaconDecodeFreqHz <= 0 then Exit('BCN: click beacon');
+ case FController.BeaconState of
+ bsLock: Result := Format('BCN LOCK %.0fHz /%.0fdB',
+ [FController.BeaconCorrectionHz, FController.BeaconSNRdB]);
+ bsSearch: Result := Format('BCN sync /%.0fdB', [FController.BeaconSNRdB]);
+ else Result := 'BCN --';
+ end;
+end;
+
procedure TWebAdapter.PushState;
var
StatusText, BoardText, IPText, SupplyText: string;
@@ -456,6 +498,14 @@ begin
else
SeqText := 'SEQ --';
+ // QO-100 beacon lock — отдельный канал состояния (видим только на Pluto в XVTR).
+ FServer.SetBeaconStatus(
+ FController.IsPluto and (FController.FCurrentXvtr >= 0),
+ FController.BeaconLockEnabled,
+ BeaconStatusText,
+ FController.BeaconRefHz,
+ FController.BeaconTrackedFreqHz);
+
FServer.PushSpectrum(
FController.FSpectrumBuf, 1024,
FController.FWaterfallBuf,
diff --git a/WebPageHtml.pas b/WebPageHtml.pas
index 2283da0..c1b51e9 100644
--- a/WebPageHtml.pas
+++ b/WebPageHtml.pas
@@ -203,6 +203,7 @@ begin
'' +
'' +
'' +
+ '' +
'|' +
'' +
'' +
@@ -264,6 +265,8 @@ begin
'let localCenter=14200000,localSpan=192000;' + // локальные копии — обновляются мгновенно при действии пользователя
'let dragging=false,dragX0=0,dragCen0=0,dragVfo0=0,dragMs=0,wheelActiveTs=0;' +
'let hoverX=-1,wTarget="",markerOn=false,markerX=0;' +
+ 'let beaconArm=false;' + // одноразовое наведение после включения BCN (как десктоп)
+
'let specSm=[],wfAvg=[],wfHi=-50,wfLo=-120;' +
'let audioCtx=null,audioT=0;' +
@@ -278,7 +281,7 @@ begin
'function sb(id,txt){const e=q(id);if(e){e.textContent=txt||"";e.title=txt||"";}}' +
'function fmtTime(d,utc){const p=n=>String(n).padStart(2,"0");return(utc?p(d.getUTCHours()):p(d.getHours()))+":"+p(utc?d.getUTCMinutes():d.getMinutes())+":"+p(utc?d.getUTCSeconds():d.getSeconds());}' +
'function updClock(){const d=new Date();sb("sbUTC","UTC "+fmtTime(d,true));sb("sbLocal","Local "+fmtTime(d,false));}' +
- 'function updStatusBar(){sb("stEl",cur.status_text||((cur.connected?"OK":"OFF")+" | "+(cur.running?"RUN":"STOP")));sb("sbBoard",cur.board_text||"Board --");sb("sbIP",cur.ip_text||"IP --");sb("sbSupply",cur.supply_text||"Supply --");sb("sbPLL",cur.pll_text||"PLL --");sb("sbRX",cur.rx_text||"RX idle");sb("sbTX",cur.tx_text||"TX idle");sb("sbSEQ",cur.seq_text||"SEQ --");}' +
+ 'function updStatusBar(){sb("stEl",cur.status_text||((cur.connected?"OK":"OFF")+" | "+(cur.running?"RUN":"STOP")));sb("sbBoard",cur.board_text||"Board --");sb("sbIP",cur.ip_text||"IP --");sb("sbSupply",cur.supply_text||"Supply --");sb("sbPLL",cur.beacon_visible?(cur.beacon_text||"BCN --"):(cur.pll_text||"PLL --"));sb("sbRX",cur.rx_text||"RX idle");sb("sbTX",cur.tx_text||"TX idle");sb("sbSEQ",cur.seq_text||"SEQ --");}' +
'updClock();setInterval(updClock,1000);' +
'function ws2(obj){if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}' +
'function cmd(c,v){ws2({cmd:c,on:v});}' +
@@ -286,6 +289,8 @@ begin
'function updNbBtn(){const b=q("nbBtn");if(!b)return;const m=cur.nb_mode|0;b.textContent=(m===2)?"NB2":"NB";sw("nbBtn",m!==0);}' +
'function updSnbBtn(){sw("snbBtn",!!cur.snb);}' +
'function updAnfBtn(){sw("anfBtn",!!cur.anf);}' +
+ 'function updBcnBtn(){const b=q("bcnBtn");if(!b)return;b.style.display=cur.beacon_visible?"":"none";sw("bcnBtn",!!cur.beacon_on);}' +
+
'const MODE_N=["LSB","USB","DSB","CWL","CWU","FM","AM","SAM"];' +
'const GRP_MAP={ssb:[0,1],cw:[3,4],fm:[5,5],am:[6,7]};' +
@@ -441,6 +446,7 @@ begin
// DSP кнопки — cur обновляется локально сразу, не ждём ответа сервера
'q("nrBtn").onclick=function(){cur.nr_mode=((cur.nr_mode|0)+1)%5;cur.nr=cur.nr_mode!==0;updNrBtn();pend("nr");ws2({cmd:"set_nr_mode",mode:cur.nr_mode});};' +
'q("nbBtn").onclick=function(){cur.nb_mode=((cur.nb_mode|0)+1)%3;cur.nb=cur.nb_mode!==0;updNbBtn();pend("nb");ws2({cmd:"set_nb_mode",mode:cur.nb_mode});};' +
+ 'q("bcnBtn").onclick=function(){cur.beacon_on=!cur.beacon_on;beaconArm=cur.beacon_on;updBcnBtn();pend("beacon");cmd("set_beacon",cur.beacon_on);};' +
'q("snbBtn").onclick=function(){cur.snb=!cur.snb;updSnbBtn();pend("snb");cmd("set_snb",cur.snb);};' +
'q("anfBtn").onclick=function(){cur.anf=!cur.anf;updAnfBtn();pend("anf");cmd("set_anf",cur.anf);};' +
@@ -675,7 +681,7 @@ begin
'function isPending(field){return !!(pendingCmds[field]&&pendingCmds[field]>Date.now());}' +
// Маппинг: server JSON field → pend key
- 'var PEND_MAP={vfo_a_hz:"freq",vfo_b_hz:"freq",nr:"nr",nr_mode:"nr",nb:"nb",nb_mode:"nb",snb:"snb",anf:"anf",running:"run",mode:"mode",agc_mode:"agc",transmitting:"mox",drive:"drive",attn_idx:"attn",tuning:"tun",filter_bw:"filter",mute:"mute"};' +
+ 'var PEND_MAP={vfo_a_hz:"freq",vfo_b_hz:"freq",nr:"nr",nr_mode:"nr",nb:"nb",nb_mode:"nb",snb:"snb",anf:"anf",running:"run",mode:"mode",agc_mode:"agc",transmitting:"mox",drive:"drive",attn_idx:"attn",tuning:"tun",filter_bw:"filter",mute:"mute",beacon_on:"beacon"};' +
'function applyState(msg){' +
'Object.keys(msg).forEach(function(k){var pk=PEND_MAP[k];if(!pk||!isPending(pk))cur[k]=msg[k];});' +
@@ -721,6 +727,7 @@ begin
'const rs=q("rfSl");if(rs&&!rs.matches(":active")){rs.value=cur.agc_top||90;var rv=q("rfVal");if(rv)rv.textContent=(cur.agc_top||90)+"dB";}' +
'if(!isPending("nr"))updNrBtn();if(!isPending("nb"))updNbBtn();' +
'if(!isPending("snb"))updSnbBtn();if(!isPending("anf"))updAnfBtn();' +
+ 'if(!isPending("beacon"))updBcnBtn();' +
'if(!isPending("run"))updRunBtn(!!cur.running);' +
'if(!isPending("mox"))updMoxBtn(!!cur.transmitting);' +
'if(!isPending("tun"))updToneBtn(!!cur.tuning);' +
@@ -741,6 +748,12 @@ begin
'function drawMk(ctx,w,h){if(!markerOn)return;ctx.save();ctx.strokeStyle="rgba(255,80,80,.85)";ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(markerX,0);ctx.lineTo(markerX,h);ctx.stroke();' +
'const lbl=((xToHz(markerX,w))/1e6).toFixed(4)+" M";ctx.fillStyle="rgba(35,5,5,.82)";ctx.fillRect(cl(markerX+5,0,w-88),h-16,84,13);ctx.fillStyle="#f88";ctx.font="10px monospace";ctx.fillText(lbl,cl(markerX+8,2,w-86),h-6);ctx.restore();}' +
+ // Маркеры QO-100 маяка: опорная (зелёная) + измеренная/наведение (оранжевая).
+ 'function vbar(ctx,x,w,h,col){if(x<0||x>w)return;ctx.strokeStyle=col;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(x+.5,0);ctx.lineTo(x+.5,h);ctx.stroke();}' +
+ 'function drawBcn(ctx,w,h){if(!cur.beacon_on)return;ctx.save();' +
+ 'if(cur.beacon_ref_hz>0)vbar(ctx,hzToX(cur.beacon_ref_hz,w),w,h,"rgba(80,255,120,.8)");' +
+ 'if(cur.beacon_track_hz>0)vbar(ctx,hzToX(cur.beacon_track_hz,w),w,h,"rgba(255,170,60,.85)");' +
+ 'ctx.restore();}' +
'function drawRuler(){const w=rulerCv.clientWidth,h=rulerCv.clientHeight;rctx.fillStyle="#0c1210";rctx.fillRect(0,0,w,h);' +
'const st=localCenter-localSpan/2;' +
@@ -775,7 +788,7 @@ begin
'sctx.lineTo(w,h);sctx.lineTo(0,h);sctx.closePath();sctx.fillStyle=g;sctx.fill();' +
'sctx.beginPath();for(let x=0;x=8||!markerOn;markerX=e.offsetX;return;}' +
'if(e.button!==0)return;' +
+ // Наведение маяка — на mousedown с немедленным return (как десктоп FormMouseDown):
+ // НЕ стартуем drag, чтобы не уехал set_center/VFO и не порвался IQ под локом.
+ // Только когда «вооружено» (1-й клик после BCN) или с Shift (перенаведение).
+ 'if(cur.beacon_on&&(beaconArm||e.shiftKey)){beaconArm=false;' +
+ 'ws2({cmd:"beacon_seed",frac:cl(e.offsetX/Math.max(1,cv.clientWidth),0,1)});return;}' +
'dragging=true;dragX0=e.offsetX;' +
'dragCen0=localCenter;' +
'dragVfo0=activeVfoHz();' +
diff --git a/WebServer.pas b/WebServer.pas
index b466911..366b5f9 100644
--- a/WebServer.pas
+++ b/WebServer.pas
@@ -151,6 +151,9 @@ type
// XVTR-band: web клиент кликнул кнопку трансвертера (Idx 0..CFG_XVTR_COUNT-1).
// Idx=-1 — выход в HF.
TWebCmdXvtrBand = procedure(Idx: Integer) of object;
+ // QO-100 beacon lock: вкл/выкл лок и наведение (клик по маяку, доля 0..1 по ширине).
+ TWebCmdBeacon = procedure(On_: Boolean) of object;
+ TWebCmdBeaconSeed = procedure(Frac: Double) of object;
// Активность web-клиента изменилась (подключился/отключился последний клиент).
// Хозяин зеркалит это в ядро (FController.FWebClientActive) для выбора mic-source.
TWebClientActiveEvent = procedure(Active: Boolean) of object;
@@ -317,6 +320,16 @@ type
FTXText: string;
FSeqText: string;
+ // QO-100 beacon lock (Pluto в трансвертере). Зеркалится в state как
+ // beacon_visible/on/text/ref_hz/track_hz; команды set_beacon/beacon_seed.
+ FBeaconVisible: Boolean; // кнопка/статус активны (Pluto + XVTR)
+ FBeaconOn: Boolean; // лок включён (подсветка кнопки)
+ FBeaconText: string; // компактный статус (ячейка PLL, как десктоп)
+ FBeaconRefHz: Double; // опорная частота маяка (маркер)
+ FBeaconTrackHz: Double; // измеренная частота маяка (маркер)
+ FOnBeacon: TWebCmdBeacon;
+ FOnBeaconSeed: TWebCmdBeaconSeed;
+
FWebClientActive: Boolean;
FOnClientActiveChanged: TWebClientActiveEvent;
@@ -400,6 +413,11 @@ type
procedure SetRatePresets(const ARates: array of Integer);
procedure SetBands(const ANames: array of string; const AFreqs: array of Double);
procedure SetFreqRange(AMin, AMax: Double);
+ // QO-100 beacon lock: зеркало статуса в state (зовётся из PushState хозяина).
+ procedure SetBeaconStatus(AVisible, AOn: Boolean; const AText: string;
+ ARefHz, ATrackHz: Double);
+ property OnBeacon: TWebCmdBeacon read FOnBeacon write FOnBeacon;
+ property OnBeaconSeed: TWebCmdBeaconSeed read FOnBeaconSeed write FOnBeaconSeed;
property OnSpan: TWebCmdSpan read FOnSpan write FOnSpan;
property OnVolume: TWebCmdVolume read FOnVolume write FOnVolume;
property OnWfAGC: TWebCmdWfAGC read FOnWfAGC write FOnWfAGC;
@@ -542,6 +560,11 @@ begin
FBandIdx := 5;
FCurrentXvtr := -1;
FFreqMhzDigits := 3;
+ FBeaconVisible := False;
+ FBeaconOn := False;
+ FBeaconText := '';
+ FBeaconRefHz := 0;
+ FBeaconTrackHz := 0;
SetLength(FXvtrBands, 0);
// Bootstrap-набор рейтов (до connect): хост перезапишет под бэкенд (HPSDR/Pluto).
SetRatePresets([48000, 96000, 192000, 384000, 768000, 1536000]);
@@ -566,6 +589,21 @@ begin
end;
end;
+procedure TWebServer.SetBeaconStatus(AVisible, AOn: Boolean; const AText: string;
+ ARefHz, ATrackHz: Double);
+begin
+ FStateLock.Enter;
+ try
+ FBeaconVisible := AVisible;
+ FBeaconOn := AOn;
+ FBeaconText := AText;
+ FBeaconRefHz := ARefHz;
+ FBeaconTrackHz := ATrackHz;
+ finally
+ FStateLock.Leave;
+ end;
+end;
+
procedure TWebServer.SetDeviceList(const ADevices: TWebDeviceArray);
var i: Integer;
begin
@@ -1444,6 +1482,20 @@ begin
FStateLock.Enter; FTuning := On_; FStateLock.Leave;
if Assigned(FOnTun) then FOnTun(On_);
end
+ else if Cmd = 'set_beacon' then
+ begin
+ On_ := JsonGetBool(Json, 'on', FBeaconOn);
+ FStateLock.Enter; FBeaconOn := On_; FStateLock.Leave;
+ if Assigned(FOnBeacon) then FOnBeacon(On_);
+ end
+ else if Cmd = 'beacon_seed' then
+ begin
+ // frac = доля позиции клика по ширине спектра (0..1). Абсолютную display-частоту
+ // считает контроллер из ЖИВЫХ FCenterFreq/FSpanHz (как десктоп PixelToFreq) —
+ // клиентский localCenter может отставать при ретюне LO во время лока.
+ HzF := JsonGetFloat(Json, 'frac', -1);
+ if (HzF >= 0) and Assigned(FOnBeaconSeed) then FOnBeaconSeed(HzF);
+ end
else if Cmd = 'freq_a' then
begin
HzF := JsonGetFloat(Json, 'hz', FFreq);
@@ -1664,7 +1716,9 @@ begin
'"pll_text":"%s","rx_text":"%s","tx_text":"%s","seq_text":"%s",' +
'"xvtr_current":%d,"xvtr_bands":%s,"freq_mhz_digits":%d,' +
'"fmstep_idx":%d,"devices":%s,"rate_presets":%s,"bands":%s,' +
- '"freq_min":%.0f,"freq_max":%.0f}',
+ '"freq_min":%.0f,"freq_max":%.0f,' +
+ '"beacon_visible":%s,"beacon_on":%s,"beacon_text":"%s",' +
+ '"beacon_ref_hz":%.0f,"beacon_track_hz":%.0f}',
[FFreq, FVfoB, FActiveVfo,
FMode, MODE_N[FMode mod 8],
FFilterIdx, FFilterBW,
@@ -1694,7 +1748,10 @@ begin
JsonEscape(FTXText), JsonEscape(FSeqText),
FCurrentXvtr, XvtrJson, FFreqMhzDigits,
FFMStepIdx, DevJson, RateJson, BandJson,
- FFreqMin, FFreqMax
+ FFreqMin, FFreqMax,
+ BoolToStr(FBeaconVisible, 'true', 'false'),
+ BoolToStr(FBeaconOn, 'true', 'false'),
+ JsonEscape(FBeaconText), FBeaconRefHz, FBeaconTrackHz
], FS);
finally
FStateLock.Leave;
diff --git a/ewsdrd.lpr b/ewsdrd.lpr
index bc9616b..75f5384 100644
--- a/ewsdrd.lpr
+++ b/ewsdrd.lpr
@@ -167,6 +167,7 @@ begin
FWeb.OnCtun := @FWebAd.OnCtun; FWeb.OnActiveVfo := @FWebAd.OnActiveVfo;
FWeb.OnMOX := @FWebAd.OnMOX; FWeb.OnTun := @FWebAd.OnTun;
FWeb.OnFMStep := @FWebAd.OnFMStep; FWeb.OnXvtrBand := @FWebAd.OnXvtrBand;
+ FWeb.OnBeacon := @FWebAd.OnBeacon; FWeb.OnBeaconSeed := @FWebAd.OnBeaconSeed;
FWeb.OnWebMic := @FWebAd.OnMic;
FWeb.OnClientActiveChanged := @FWebAd.OnClientActiveChanged;
// Host-coupled (lifecycle) — на демон-хост (маршалит в главный поток).
@@ -409,7 +410,7 @@ var
WebCfg: TWebSettings;
Dev: THPSDRDevice;
AutoDev: TSavedDevice;
- lastPush, nowMs: QWord;
+ lastPush, lastBeacon, nowMs: QWord;
begin
FCtrl.FSettings.LoadWebSettings(WebCfg);
if not FWeb.Start then
@@ -444,10 +445,19 @@ begin
Log('No autostart device — waiting for web connect.');
lastPush := GetTickCount64;
+ lastBeacon := lastPush;
while FRunning do
begin
CheckSynchronize(PUSH_INTERVAL_MS); // исполняет очередь Invoke + ждёт до 50мс
nowMs := GetTickCount64;
+ // QO-100 beacon lock — одна итерация контура @~10 Гц (как GUI MeterTimer). No-op
+ // если лок выключен. Без этого лок маяка не работал бы в headless-демоне.
+ if nowMs - lastBeacon >= 100 then
+ begin
+ if FCtrl.FRunning and FCtrl.FWDSPReady then
+ FCtrl.ServiceBeaconLock;
+ lastBeacon := nowMs;
+ end;
if nowMs - lastPush >= PUSH_INTERVAL_MS then
begin
// S-метр приходит из WDSP (не из сетевого HP-статуса), поэтому его никто