From 0fbe5d5edd60fd75ef375b7a6b8fd8dc19a3a253 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 23 Jun 2026 16:53:02 +0300 Subject: [PATCH] feat: QO-100 beacon lock in web UI + headless daemon Mirror the desktop QO-100 beacon lock into the web interface and make it work in the headless daemon. Backend: - WebServer: set_beacon/beacon_seed commands (+OnBeacon/OnBeaconSeed events), beacon state fields, SetBeaconStatus, and beacon_visible/on/ text/ref_hz/track_hz in BuildStateJson. beacon_seed carries 'frac' (0..1 click position); absolute Hz is computed controller-side from live FCenterFreq/FSpanHz (mirrors desktop PixelToFreq). - WebAdapter: OnBeacon->SetBeaconLock, OnBeaconSeed->BeaconSeedAtHz; PushState mirrors compact status (matches MainForm.BeaconStatusText) into the PLL status cell when visible (Pluto + active XVTR). - MainForm/ewsdrd: wire the two callbacks. - ewsdrd: call ServiceBeaconLock in the main loop @~10Hz (FRunning+ FWDSPReady gated). Without this the lock loop never ran headless. Frontend (WebPageHtml): - BCN button (visible only on Pluto+XVTR), highlighted while locked. - Seed-on-mousedown with immediate return (like desktop FormMouseDown): no drag/set_center so the gesture never moves the center / tears IQ. Armed (first click after BCN) or Shift, mirroring desktop arm/Shift. - PLL status cell shows beacon status (field-7 parity with desktop). - Ref (green) + tracked (orange) beacon markers on spectrum/waterfall. Known issue (unresolved): after lock the beacon can slowly drift off and drop to "BCN sync". Suspected residual-sign anti-drift or retune-timing on the web/daemon path; needs on-air diagnostics. See memory project_web_beacon for the investigation state. Co-Authored-By: Claude Opus 4.8 --- MainForm.pas | 2 ++ WebAdapter.pas | 50 ++++++++++++++++++++++++++++++++++++++++ WebPageHtml.pas | 26 +++++++++++++++++---- WebServer.pas | 61 +++++++++++++++++++++++++++++++++++++++++++++++-- ewsdrd.lpr | 12 +++++++++- 5 files changed, 144 insertions(+), 7 deletions(-) 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-статуса), поэтому его никто