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 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 16:53:02 +03:00
co-authored by Claude Opus 4.8
parent 8472c8e7b8
commit 0fbe5d5edd
5 changed files with 144 additions and 7 deletions
+59 -2
View File
@@ -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;