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
+2
View File
@@ -872,6 +872,8 @@ begin
FWebServer.OnFreqA := FWebAdapter.OnFreqA; FWebServer.OnFreqA := FWebAdapter.OnFreqA;
FWebServer.OnWebMic := FWebAdapter.OnMic; FWebServer.OnWebMic := FWebAdapter.OnMic;
FWebServer.OnXvtrBand := FWebAdapter.OnXvtrBand; FWebServer.OnXvtrBand := FWebAdapter.OnXvtrBand;
FWebServer.OnBeacon := FWebAdapter.OnBeacon;
FWebServer.OnBeaconSeed := FWebAdapter.OnBeaconSeed;
FWebServer.OnConnect := WebOnConnect; FWebServer.OnConnect := WebOnConnect;
FWebServer.OnDiscoverDev := WebOnDiscover; FWebServer.OnDiscoverDev := WebOnDiscover;
FWebServer.OnDisconnectDev := WebOnDisconnect; FWebServer.OnDisconnectDev := WebOnDisconnect;
+50
View File
@@ -66,6 +66,9 @@ type
procedure SyncMOX; procedure SyncMOX;
procedure SyncTun; procedure SyncTun;
procedure SyncFMStep; procedure SyncFMStep;
procedure SyncBeacon;
procedure SyncBeaconSeed;
function BeaconStatusText: string; // компактный статус (как десктоп поле 7)
public public
constructor Create(AController: TRadioController; AServer: TWebServer; constructor Create(AController: TRadioController; AServer: TWebServer;
AHost: IWebHost); AHost: IWebHost);
@@ -97,6 +100,9 @@ type
procedure OnMOX(On_: Boolean); procedure OnMOX(On_: Boolean);
procedure OnTun(On_: Boolean); procedure OnTun(On_: Boolean);
procedure OnFMStep(Idx: Integer); procedure OnFMStep(Idx: Integer);
// QO-100 beacon: вкл/выкл лок и наведение по клику в спектре (frac 0..1).
procedure OnBeacon(On_: Boolean);
procedure OnBeaconSeed(Frac: Double);
procedure OnClientActiveChanged(Active: Boolean); procedure OnClientActiveChanged(Active: Boolean);
// TX-mic от web-клиента → WDSP. Вызывается из WS-потока; без маршалинга — // TX-mic от web-клиента → WDSP. Вызывается из WS-потока; без маршалинга —
// подача в движок thread-safe (как OnMicPacket/OnDDCIQ контроллера). // подача в движок thread-safe (как OnMicPacket/OnDDCIQ контроллера).
@@ -344,6 +350,29 @@ begin
FController.SaveCurrentBand; FController.SaveCurrentBand;
end; 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); procedure TWebAdapter.OnClientActiveChanged(Active: Boolean);
// Из потока веб-сервера при connect/disconnect клиента. Простая запись флага — // Из потока веб-сервера при connect/disconnect клиента. Простая запись флага —
// ядро (SetMOX, в т.ч. HWPTT-путь) выбирает по нему mic-source. Без маршалинга. // ядро (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; procedure TWebAdapter.PushState;
var var
StatusText, BoardText, IPText, SupplyText: string; StatusText, BoardText, IPText, SupplyText: string;
@@ -456,6 +498,14 @@ begin
else else
SeqText := 'SEQ --'; 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( FServer.PushSpectrum(
FController.FSpectrumBuf, 1024, FController.FSpectrumBuf, 1024,
FController.FWaterfallBuf, FController.FWaterfallBuf,
+22 -4
View File
@@ -203,6 +203,7 @@ begin
'<button id="snbBtn">SNB</button>' + '<button id="snbBtn">SNB</button>' +
'<button id="anfBtn">ANF</button>' + '<button id="anfBtn">ANF</button>' +
'<button id="nrBtn">NR</button>' + '<button id="nrBtn">NR</button>' +
'<button id="bcnBtn" title="QO-100 beacon lock" style="display:none">BCN</button>' +
'<span class="sl-sep">|</span>' + '<span class="sl-sep">|</span>' +
'<button id="devBtn" title="Devices">DEV</button>' + '<button id="devBtn" title="Devices">DEV</button>' +
'<button id="startStopBtn" class="on">START</button>' + '<button id="startStopBtn" class="on">START</button>' +
@@ -264,6 +265,8 @@ begin
'let localCenter=14200000,localSpan=192000;' + // локальные копии — обновляются мгновенно при действии пользователя 'let localCenter=14200000,localSpan=192000;' + // локальные копии — обновляются мгновенно при действии пользователя
'let dragging=false,dragX0=0,dragCen0=0,dragVfo0=0,dragMs=0,wheelActiveTs=0;' + 'let dragging=false,dragX0=0,dragCen0=0,dragVfo0=0,dragMs=0,wheelActiveTs=0;' +
'let hoverX=-1,wTarget="",markerOn=false,markerX=0;' + 'let hoverX=-1,wTarget="",markerOn=false,markerX=0;' +
'let beaconArm=false;' + // одноразовое наведение после включения BCN (как десктоп)
'let specSm=[],wfAvg=[],wfHi=-50,wfLo=-120;' + 'let specSm=[],wfAvg=[],wfHi=-50,wfLo=-120;' +
'let audioCtx=null,audioT=0;' + '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 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 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 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);' + 'updClock();setInterval(updClock,1000);' +
'function ws2(obj){if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}' + 'function ws2(obj){if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}' +
'function cmd(c,v){ws2({cmd:c,on:v});}' + '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 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 updSnbBtn(){sw("snbBtn",!!cur.snb);}' +
'function updAnfBtn(){sw("anfBtn",!!cur.anf);}' + '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 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]};' + 'const GRP_MAP={ssb:[0,1],cw:[3,4],fm:[5,5],am:[6,7]};' +
@@ -441,6 +446,7 @@ begin
// DSP кнопки — cur обновляется локально сразу, не ждём ответа сервера // 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("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("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("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);};' + '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());}' + 'function isPending(field){return !!(pendingCmds[field]&&pendingCmds[field]>Date.now());}' +
// Маппинг: server JSON field → pend key // Маппинг: 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){' + 'function applyState(msg){' +
'Object.keys(msg).forEach(function(k){var pk=PEND_MAP[k];if(!pk||!isPending(pk))cur[k]=msg[k];});' + '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";}' + '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("nr"))updNrBtn();if(!isPending("nb"))updNbBtn();' +
'if(!isPending("snb"))updSnbBtn();if(!isPending("anf"))updAnfBtn();' + 'if(!isPending("snb"))updSnbBtn();if(!isPending("anf"))updAnfBtn();' +
'if(!isPending("beacon"))updBcnBtn();' +
'if(!isPending("run"))updRunBtn(!!cur.running);' + 'if(!isPending("run"))updRunBtn(!!cur.running);' +
'if(!isPending("mox"))updMoxBtn(!!cur.transmitting);' + 'if(!isPending("mox"))updMoxBtn(!!cur.transmitting);' +
'if(!isPending("tun"))updToneBtn(!!cur.tuning);' + '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();' + '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();}' + '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);' + 'function drawRuler(){const w=rulerCv.clientWidth,h=rulerCv.clientHeight;rctx.fillStyle="#0c1210";rctx.fillRect(0,0,w,h);' +
'const st=localCenter-localSpan/2;' + '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.lineTo(w,h);sctx.lineTo(0,h);sctx.closePath();sctx.fillStyle=g;sctx.fill();' +
'sctx.beginPath();for(let x=0;x<w;x++){const i=Math.floor(x*(bins.length-1)/Math.max(1,w-1));const y=cl(((-specSm[i]-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' + 'sctx.beginPath();for(let x=0;x<w;x++){const i=Math.floor(x*(bins.length-1)/Math.max(1,w-1));const y=cl(((-specSm[i]-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' +
'sctx.strokeStyle="#3ddd3d";sctx.lineWidth=1.2;sctx.stroke();' + 'sctx.strokeStyle="#3ddd3d";sctx.lineWidth=1.2;sctx.stroke();' +
'const vx=hzToX(avf,w);sctx.strokeStyle="#60ff80";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(vx,0);sctx.lineTo(vx,h);sctx.stroke();drawMk(sctx,w,h);}' + 'const vx=hzToX(avf,w);sctx.strokeStyle="#60ff80";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(vx,0);sctx.lineTo(vx,h);sctx.stroke();drawBcn(sctx,w,h);drawMk(sctx,w,h);}' +
'function drawWf(bins){const w=wfCv.clientWidth,h=wfCv.clientHeight;' + 'function drawWf(bins){const w=wfCv.clientWidth,h=wfCv.clientHeight;' +
'if(!bins?.length){drawMk(wctx,w,h);return;}' + 'if(!bins?.length){drawMk(wctx,w,h);return;}' +
@@ -795,7 +808,7 @@ begin
'wfBCtx.putImageData(row,0,0);wctx.drawImage(wfBuf,0,0,w,h);' + 'wfBCtx.putImageData(row,0,0);wctx.drawImage(wfBuf,0,0,w,h);' +
'const avf2=activeVfoHz();const[fl2,fh2]=fltLH(),x1w=hzToX(avf2+fl2,w),x2w=hzToX(avf2+fh2,w);' + 'const avf2=activeVfoHz();const[fl2,fh2]=fltLH(),x1w=hzToX(avf2+fl2,w),x2w=hzToX(avf2+fh2,w);' +
'wctx.fillStyle=(cur.transmitting||cur.tuning)?"rgba(255,60,60,.15)":"rgba(220,230,230,.10)";wctx.fillRect(Math.min(x1w,x2w),0,Math.abs(x2w-x1w),h);' + 'wctx.fillStyle=(cur.transmitting||cur.tuning)?"rgba(255,60,60,.15)":"rgba(220,230,230,.10)";wctx.fillRect(Math.min(x1w,x2w),0,Math.abs(x2w-x1w),h);' +
'const vxw=hzToX(avf2,w);wctx.strokeStyle="#60ff80";wctx.lineWidth=1;wctx.beginPath();wctx.moveTo(vxw,0);wctx.lineTo(vxw,h);wctx.stroke();drawMk(wctx,w,h);}' + 'const vxw=hzToX(avf2,w);wctx.strokeStyle="#60ff80";wctx.lineWidth=1;wctx.beginPath();wctx.moveTo(vxw,0);wctx.lineTo(vxw,h);wctx.stroke();drawBcn(wctx,w,h);drawMk(wctx,w,h);}' +
'function rcv(cv){const dpr=Math.max(1,devicePixelRatio||1),w=cv.clientWidth|0,h=cv.clientHeight|0;if(cv.width!==(w*dpr|0)||cv.height!==(h*dpr|0)){cv.width=w*dpr;cv.height=h*dpr;cv.getContext("2d").setTransform(dpr,0,0,dpr,0,0);}}' + 'function rcv(cv){const dpr=Math.max(1,devicePixelRatio||1),w=cv.clientWidth|0,h=cv.clientHeight|0;if(cv.width!==(w*dpr|0)||cv.height!==(h*dpr|0)){cv.width=w*dpr;cv.height=h*dpr;cv.getContext("2d").setTransform(dpr,0,0,dpr,0,0);}}' +
'function resize(){rcv(specCv);rcv(wfCv);rcv(rulerCv);const dpr=Math.max(1,devicePixelRatio||1),sw=smCv.clientWidth|0,sh=smCv.clientHeight|0;' + 'function resize(){rcv(specCv);rcv(wfCv);rcv(rulerCv);const dpr=Math.max(1,devicePixelRatio||1),sw=smCv.clientWidth|0,sh=smCv.clientHeight|0;' +
@@ -832,6 +845,11 @@ begin
'audioKick();wTarget=cv.id;' + 'audioKick();wTarget=cv.id;' +
'if(e.button===2){markerOn=Math.abs(markerX-e.offsetX)>=8||!markerOn;markerX=e.offsetX;return;}' + 'if(e.button===2){markerOn=Math.abs(markerX-e.offsetX)>=8||!markerOn;markerX=e.offsetX;return;}' +
'if(e.button!==0)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;' + 'dragging=true;dragX0=e.offsetX;' +
'dragCen0=localCenter;' + 'dragCen0=localCenter;' +
'dragVfo0=activeVfoHz();' + 'dragVfo0=activeVfoHz();' +
+59 -2
View File
@@ -151,6 +151,9 @@ type
// XVTR-band: web клиент кликнул кнопку трансвертера (Idx 0..CFG_XVTR_COUNT-1). // XVTR-band: web клиент кликнул кнопку трансвертера (Idx 0..CFG_XVTR_COUNT-1).
// Idx=-1 — выход в HF. // Idx=-1 — выход в HF.
TWebCmdXvtrBand = procedure(Idx: Integer) of object; 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-клиента изменилась (подключился/отключился последний клиент). // Активность web-клиента изменилась (подключился/отключился последний клиент).
// Хозяин зеркалит это в ядро (FController.FWebClientActive) для выбора mic-source. // Хозяин зеркалит это в ядро (FController.FWebClientActive) для выбора mic-source.
TWebClientActiveEvent = procedure(Active: Boolean) of object; TWebClientActiveEvent = procedure(Active: Boolean) of object;
@@ -317,6 +320,16 @@ type
FTXText: string; FTXText: string;
FSeqText: 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; FWebClientActive: Boolean;
FOnClientActiveChanged: TWebClientActiveEvent; FOnClientActiveChanged: TWebClientActiveEvent;
@@ -400,6 +413,11 @@ type
procedure SetRatePresets(const ARates: array of Integer); procedure SetRatePresets(const ARates: array of Integer);
procedure SetBands(const ANames: array of string; const AFreqs: array of Double); procedure SetBands(const ANames: array of string; const AFreqs: array of Double);
procedure SetFreqRange(AMin, AMax: 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 OnSpan: TWebCmdSpan read FOnSpan write FOnSpan;
property OnVolume: TWebCmdVolume read FOnVolume write FOnVolume; property OnVolume: TWebCmdVolume read FOnVolume write FOnVolume;
property OnWfAGC: TWebCmdWfAGC read FOnWfAGC write FOnWfAGC; property OnWfAGC: TWebCmdWfAGC read FOnWfAGC write FOnWfAGC;
@@ -542,6 +560,11 @@ begin
FBandIdx := 5; FBandIdx := 5;
FCurrentXvtr := -1; FCurrentXvtr := -1;
FFreqMhzDigits := 3; FFreqMhzDigits := 3;
FBeaconVisible := False;
FBeaconOn := False;
FBeaconText := '';
FBeaconRefHz := 0;
FBeaconTrackHz := 0;
SetLength(FXvtrBands, 0); SetLength(FXvtrBands, 0);
// Bootstrap-набор рейтов (до connect): хост перезапишет под бэкенд (HPSDR/Pluto). // Bootstrap-набор рейтов (до connect): хост перезапишет под бэкенд (HPSDR/Pluto).
SetRatePresets([48000, 96000, 192000, 384000, 768000, 1536000]); SetRatePresets([48000, 96000, 192000, 384000, 768000, 1536000]);
@@ -566,6 +589,21 @@ begin
end; end;
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); procedure TWebServer.SetDeviceList(const ADevices: TWebDeviceArray);
var i: Integer; var i: Integer;
begin begin
@@ -1444,6 +1482,20 @@ begin
FStateLock.Enter; FTuning := On_; FStateLock.Leave; FStateLock.Enter; FTuning := On_; FStateLock.Leave;
if Assigned(FOnTun) then FOnTun(On_); if Assigned(FOnTun) then FOnTun(On_);
end 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 else if Cmd = 'freq_a' then
begin begin
HzF := JsonGetFloat(Json, 'hz', FFreq); HzF := JsonGetFloat(Json, 'hz', FFreq);
@@ -1664,7 +1716,9 @@ begin
'"pll_text":"%s","rx_text":"%s","tx_text":"%s","seq_text":"%s",' + '"pll_text":"%s","rx_text":"%s","tx_text":"%s","seq_text":"%s",' +
'"xvtr_current":%d,"xvtr_bands":%s,"freq_mhz_digits":%d,' + '"xvtr_current":%d,"xvtr_bands":%s,"freq_mhz_digits":%d,' +
'"fmstep_idx":%d,"devices":%s,"rate_presets":%s,"bands":%s,' + '"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, [FFreq, FVfoB, FActiveVfo,
FMode, MODE_N[FMode mod 8], FMode, MODE_N[FMode mod 8],
FFilterIdx, FFilterBW, FFilterIdx, FFilterBW,
@@ -1694,7 +1748,10 @@ begin
JsonEscape(FTXText), JsonEscape(FSeqText), JsonEscape(FTXText), JsonEscape(FSeqText),
FCurrentXvtr, XvtrJson, FFreqMhzDigits, FCurrentXvtr, XvtrJson, FFreqMhzDigits,
FFMStepIdx, DevJson, RateJson, BandJson, FFMStepIdx, DevJson, RateJson, BandJson,
FFreqMin, FFreqMax FFreqMin, FFreqMax,
BoolToStr(FBeaconVisible, 'true', 'false'),
BoolToStr(FBeaconOn, 'true', 'false'),
JsonEscape(FBeaconText), FBeaconRefHz, FBeaconTrackHz
], FS); ], FS);
finally finally
FStateLock.Leave; FStateLock.Leave;
+11 -1
View File
@@ -167,6 +167,7 @@ begin
FWeb.OnCtun := @FWebAd.OnCtun; FWeb.OnActiveVfo := @FWebAd.OnActiveVfo; FWeb.OnCtun := @FWebAd.OnCtun; FWeb.OnActiveVfo := @FWebAd.OnActiveVfo;
FWeb.OnMOX := @FWebAd.OnMOX; FWeb.OnTun := @FWebAd.OnTun; FWeb.OnMOX := @FWebAd.OnMOX; FWeb.OnTun := @FWebAd.OnTun;
FWeb.OnFMStep := @FWebAd.OnFMStep; FWeb.OnXvtrBand := @FWebAd.OnXvtrBand; FWeb.OnFMStep := @FWebAd.OnFMStep; FWeb.OnXvtrBand := @FWebAd.OnXvtrBand;
FWeb.OnBeacon := @FWebAd.OnBeacon; FWeb.OnBeaconSeed := @FWebAd.OnBeaconSeed;
FWeb.OnWebMic := @FWebAd.OnMic; FWeb.OnWebMic := @FWebAd.OnMic;
FWeb.OnClientActiveChanged := @FWebAd.OnClientActiveChanged; FWeb.OnClientActiveChanged := @FWebAd.OnClientActiveChanged;
// Host-coupled (lifecycle) — на демон-хост (маршалит в главный поток). // Host-coupled (lifecycle) — на демон-хост (маршалит в главный поток).
@@ -409,7 +410,7 @@ var
WebCfg: TWebSettings; WebCfg: TWebSettings;
Dev: THPSDRDevice; Dev: THPSDRDevice;
AutoDev: TSavedDevice; AutoDev: TSavedDevice;
lastPush, nowMs: QWord; lastPush, lastBeacon, nowMs: QWord;
begin begin
FCtrl.FSettings.LoadWebSettings(WebCfg); FCtrl.FSettings.LoadWebSettings(WebCfg);
if not FWeb.Start then if not FWeb.Start then
@@ -444,10 +445,19 @@ begin
Log('No autostart device — waiting for web connect.'); Log('No autostart device — waiting for web connect.');
lastPush := GetTickCount64; lastPush := GetTickCount64;
lastBeacon := lastPush;
while FRunning do while FRunning do
begin begin
CheckSynchronize(PUSH_INTERVAL_MS); // исполняет очередь Invoke + ждёт до 50мс CheckSynchronize(PUSH_INTERVAL_MS); // исполняет очередь Invoke + ждёт до 50мс
nowMs := GetTickCount64; 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 if nowMs - lastPush >= PUSH_INTERVAL_MS then
begin begin
// S-метр приходит из WDSP (не из сетевого HP-статуса), поэтому его никто // S-метр приходит из WDSP (не из сетевого HP-статуса), поэтому его никто