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
+22 -4
View File
@@ -203,6 +203,7 @@ begin
'<button id="snbBtn">SNB</button>' +
'<button id="anfBtn">ANF</button>' +
'<button id="nrBtn">NR</button>' +
'<button id="bcnBtn" title="QO-100 beacon lock" style="display:none">BCN</button>' +
'<span class="sl-sep">|</span>' +
'<button id="devBtn" title="Devices">DEV</button>' +
'<button id="startStopBtn" class="on">START</button>' +
@@ -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<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();' +
'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;' +
'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);' +
'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);' +
'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 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;' +
'if(e.button===2){markerOn=Math.abs(markerX-e.offsetX)>=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();' +