web: fix A=B/SWAP VFO ops, wire attenuator, TUN button, FNP filter overlay

- Add freq_a command: sets VFO A regardless of active VFO; fixes A=B and
  SWAP when VFO B is active (prev. sent freq_b which overwrote wrong VFO)
- Add pend("freq") to B=A / A=B / SWAP to guard local display during round-trip
- RX step attenuator: HPSDRNetwork.SetStepAtten → bytes 1442/1443 in HP packet;
  WebServer attn command; MainForm FAtten + SyncWebAttn; attSel.onchange in JS
- TUN mode: set_tun WebSocket command wired to ApplyTUN(); toneBtn toggles
  with on/off highlight and pending protection; tuning field in state JSON
- FNP button opens semi-transparent filter overlay (10 buttons, 5×2 grid)
  with mode-appropriate widths (SSB/CW/AM/FM); active filter highlighted;
  cur.filter_bw updated immediately on click so passband redraws within 50ms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 15:04:23 +03:00
co-authored by Claude Sonnet 4.6
parent 9f208006aa
commit 154b6eccba
4 changed files with 165 additions and 20 deletions
+11 -2
View File
@@ -141,6 +141,7 @@ type
FIsTransmitting: Boolean;
FPAEnabled: Boolean;
FAlexEnabled: Boolean;
FStepAtten: Byte; // ADC0 step attenuator 0-31 dB
FSendLock: TCriticalSection; // защита concurrent UDP sends
@@ -190,6 +191,7 @@ type
DriveLevel: Byte = 100);
procedure UpdateState(RXFreqHz, TXFreqHz: Double; DriveLevel: Byte;
Transmitting, PAEnabled, AlexEnabled: Boolean);
procedure SetStepAtten(dB: Byte);
procedure SendFullHP;
procedure SendDDCAudio(const LeftRight: array of SmallInt);
procedure SendDUCIQ(const IData, QData: array of Integer);
@@ -1208,6 +1210,13 @@ begin
FAlexEnabled := AlexEnabled;
end;
procedure THPSDRNetwork.SetStepAtten(dB: Byte);
begin
if dB > 31 then dB := 31;
FStepAtten := dB;
if FConnected then SendFullHP;
end;
procedure THPSDRNetwork.SendFullHP;
var
Buf: array[0..1443] of Byte;
@@ -1274,8 +1283,8 @@ begin
// Step attenuators ADC0/ADC1 (bytes 1443/1442)
// 0 dB = 0, max 31 dB
Buf[1443] := 0; // ADC0 attenuation
Buf[1442] := 0; // ADC1 attenuation
Buf[1443] := FStepAtten; // ADC0 attenuation
Buf[1442] := FStepAtten; // ADC1 attenuation
DoSendTo(FSocket, Buf, SizeOf(Buf), FDevice.IPAddress, FPortHPFromPC);
end;
+49 -1
View File
@@ -197,6 +197,7 @@ type
FHWPTTActive: Boolean; // True = hardware PTT нажата
FMuted: Boolean;
FVolume: Integer;
FAtten: Integer; // RX step attenuator index: 0=0dB 1=10dB 2=20dB
FLastSMeter: Double;
FSMeterPeak: Double; // верхняя граница светлой зоны
FSMeterMin: Double; // нижняя граница светлой зоны
@@ -489,6 +490,9 @@ type
procedure WebOnCenter(Hz: Double);
procedure WebOnMOX(On_: Boolean);
procedure WebOnDrive(V: Integer);
procedure WebOnFreqA(Hz: Double);
procedure WebOnAttn(Idx: Integer);
procedure WebOnTun(On_: Boolean);
// Synchronize-обёртки (выполняются в UI-потоке)
procedure SyncWebFreq;
procedure SyncWebMode;
@@ -512,6 +516,9 @@ type
procedure SyncWebCenter;
procedure SyncWebMOX;
procedure SyncWebDrive;
procedure SyncWebFreqA;
procedure SyncWebAttn;
procedure SyncWebTun;
// --- CAT callbacks -------------------------------------------------------
function CATGetVfoA: Double;
function CATGetVfoB: Double;
@@ -1091,6 +1098,9 @@ begin
FWebServer.OnCenter := WebOnCenter;
FWebServer.OnMOX := WebOnMOX;
FWebServer.OnDrive := WebOnDrive;
FWebServer.OnFreqA := WebOnFreqA;
FWebServer.OnAttn := WebOnAttn;
FWebServer.OnTun := WebOnTun;
FWebServer.Start;
FillChar(FCATLastGlobal, SizeOf(FCATLastGlobal), 0);
FCATLastGlobal.CATTcpPort := 19090;
@@ -2765,7 +2775,8 @@ begin
BtnNR.Tag, BtnNB.Tag, BtnSNB.Tag <> 0, BtnANF.Tag <> 0,
FCenterFreq, FFilter,
FVfoB, FActiveVfo,
FTransmitting, TrkDrive.Position);
FTransmitting, TrkDrive.Position,
FAtten, FTuning);
end;
// ===========================================================================
@@ -5204,6 +5215,43 @@ begin
TrkDrive.Position := FWebSyncInt;
end;
procedure TMainForm.WebOnFreqA(Hz: Double);
begin
FWebSyncFreq := Hz;
FWebSyncM := SyncWebFreqA;
TThread.Synchronize(nil, FWebSyncM);
end;
procedure TMainForm.SyncWebFreqA;
begin
ApplyVfoA(Round(FWebSyncFreq));
end;
procedure TMainForm.WebOnAttn(Idx: Integer);
begin
FWebSyncInt := Idx;
FWebSyncM := SyncWebAttn;
TThread.Synchronize(nil, FWebSyncM);
end;
procedure TMainForm.SyncWebAttn;
begin
FAtten := Max(0, Min(2, FWebSyncInt));
FNetwork.SetStepAtten(FAtten * 10);
end;
procedure TMainForm.WebOnTun(On_: Boolean);
begin
FWebSyncBool := On_;
FWebSyncM := SyncWebTun;
TThread.Synchronize(nil, FWebSyncM);
end;
procedure TMainForm.SyncWebTun;
begin
ApplyTUN(FWebSyncBool);
end;
procedure TMainForm.TrkDriveChange(Sender: TObject);
begin
FDriveLevel := CalcDriveByte;
+64 -13
View File
@@ -205,12 +205,67 @@ begin
'q("modGrpSel").onchange=function(){const grp=GRP_MAP[modGrp()];const defaultMode=grp[0];cur.mode=defaultMode;updModTglLabel();pend("mode");ws2({cmd:"mode",mode:defaultMode});};' +
'q("modTglBtn").onclick=function(){audioKick();const next=modTglTarget();cur.mode=next;updModTglLabel();pend("mode");ws2({cmd:"mode",mode:next});};' +
'q("agcSel").onchange=function(e){pend("agc");ws2({cmd:"agc",mode:+e.target.value});};' +
'q("attSel").onchange=function(e){pend("attn");ws2({cmd:"attn",idx:+e.target.value});};' +
// FNP — фильтровый оверлей
'var FNP_N={ssb:["5.0k","4.4k","3.8k","3.3k","2.9k","2.7k","2.4k","2.1k","1.8k","1.0k"],' +
'cw:["1.0k","800","750","600","500","400","250","100","50","25"],' +
'am:["16k","12k","10k","8k","6.6k","5.2k","4.0k","3.1k","2.9k","2.4k"],' +
'fm:["20k","15k","12k","10k","8k","6k","5k","4k","3k","2k"]};' +
'var FNP_BW={ssb:[5000,4400,3800,3300,2900,2700,2400,2100,1800,1000],' +
'cw:[1000,800,750,600,500,400,250,100,50,25],' +
'am:[16000,12000,10000,8000,6600,5200,4000,3100,2900,2400],' +
'fm:[20000,15000,12000,10000,8000,6000,5000,4000,3000,2000]};' +
'var fnpOv=null;' +
'function fnpGrp(){var m=cur.mode|0;return(m===3||m===4)?"cw":(m===5)?"fm":(m===2||m===6||m===7)?"am":"ssb";}' +
'function closeFnp(){if(fnpOv){fnpOv.remove();fnpOv=null;}document.removeEventListener("click",fnpOutside);}' +
'function fnpOutside(e){if(fnpOv&&!fnpOv.contains(e.target)&&e.target!==q("fnpBtn"))closeFnp();}' +
'function showFnp(){' +
'if(fnpOv){closeFnp();return;}' +
'var g=fnpGrp(),ns=FNP_N[g],bws=FNP_BW[g],cb=cur.filter_bw||2700;' +
'fnpOv=document.createElement("div");' +
'fnpOv.style.cssText="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);' +
'background:rgba(6,14,6,0.93);border:1px solid rgba(70,120,70,0.65);border-radius:8px;' +
'padding:12px 14px;z-index:2000;backdrop-filter:blur(6px);box-shadow:0 4px 24px rgba(0,0,0,0.7);min-width:260px;";' +
// заголовок
'var hd=document.createElement("div");' +
'hd.style.cssText="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;";' +
'var tl=document.createElement("span");' +
'tl.textContent="Filter — "+g.toUpperCase();' +
'tl.style.cssText="color:#80b880;font-family:monospace;font-size:12px;letter-spacing:1px;";' +
'var xb=document.createElement("button");' +
'xb.textContent="×";' +
'xb.style.cssText="background:none;border:none;color:#6a9a6a;font-size:16px;cursor:pointer;padding:0 2px;line-height:1;";' +
'xb.onclick=closeFnp;' +
'hd.appendChild(tl);hd.appendChild(xb);fnpOv.appendChild(hd);' +
// сетка кнопок
'var gd=document.createElement("div");' +
'gd.style.cssText="display:grid;grid-template-columns:repeat(5,1fr);gap:5px;";' +
'ns.forEach(function(n,i){' +
'var b=document.createElement("button"),act=bws[i]===cb;' +
'b.textContent=n;' +
'b.style.cssText="background:"+(act?"rgba(25,70,25,0.95)":"rgba(12,28,12,0.85)")+";' +
'color:"+(act?"#b0ffb0":"#7aaa7a")+";border:1px solid "+(act?"rgba(120,220,120,0.7)":"rgba(55,95,55,0.45)")+";' +
'border-radius:4px;padding:5px 0;font-family:monospace;font-size:12px;cursor:pointer;' +
'transition:background 0.1s;";' +
'b.onmouseenter=function(){if(!act)this.style.background="rgba(20,50,20,0.95)";}; ' +
'b.onmouseleave=function(){if(!act)this.style.background="rgba(12,28,12,0.85)";}; ' +
'b.onclick=function(){cur.filter_bw=bws[i];pend("filter");ws2({cmd:"filter",bw:bws[i]});closeFnp();};' +
'gd.appendChild(b);' +
'});' +
'fnpOv.appendChild(gd);' +
'document.body.appendChild(fnpOv);' +
'setTimeout(function(){document.addEventListener("click",fnpOutside);},0);' +
'}' +
'q("fnpBtn").onclick=showFnp;' +
'q("volSl").oninput=function(e){ws2({cmd:"volume",v:+e.target.value});var v=q("volVal");if(v)v.textContent=e.target.value;};' +
'q("rfSl").oninput=function(e){ws2({cmd:"agctop",db:+e.target.value});var v=q("rfVal");if(v)v.textContent=e.target.value+"dB";};' +
'q("txSl").oninput=function(e){var v=+e.target.value;var lbl=q("txVal");if(lbl)lbl.textContent=v;pend("drive");ws2({cmd:"drive",v:v});};' +
'function updMoxBtn(tx){var b=q("moxBtn");if(!b)return;b.textContent=tx?"TX":"MOX";b.classList.toggle("mox-on",!!tx);}' +
'q("moxBtn").onclick=function(){audioKick();cur.transmitting=!cur.transmitting;pend("mox");updMoxBtn(cur.transmitting);cmd("set_mox",cur.transmitting);};' +
'function updToneBtn(t){var b=q("toneBtn");if(!b)return;b.classList.toggle("on",!!t);}' +
'q("toneBtn").onclick=function(){audioKick();cur.tuning=!cur.tuning;pend("tun");updToneBtn(cur.tuning);cmd("set_tun",cur.tuning);};' +
// 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});};' +
@@ -274,18 +329,12 @@ begin
'q("vBtnA").onclick=()=>{if(activeVfo!=="A")selVfo("A");};' +
'q("vBtnB").onclick=()=>{if(activeVfo!=="B")selVfo("B");};' +
// B=A: copy A freq into B
'q("btnBA").onclick=()=>{vfoB=vfoA;renderVfos();ws2({cmd:"freq_b",hz:Math.round(vfoB)});};' +
// A=B: copy B freq into A
'q("btnAB").onclick=()=>{vfoA=vfoB;renderVfos();' +
'if(activeVfo==="A")ws2({cmd:"freq",hz:Math.round(vfoA)});' +
'else ws2({cmd:"freq_b",hz:Math.round(vfoA)});' +
// Actually A=B means set vfoA=vfoB, so send freq_a regardless of active
'};' +
// SWAP
'q("btnSwap").onclick=()=>{const t=vfoA;vfoA=vfoB;vfoB=t;renderVfos();' +
'ws2({cmd:"freq",hz:Math.round(activeVfo==="A"?vfoA:vfoB)});' +
'ws2({cmd:"freq_b",hz:Math.round(activeVfo==="A"?vfoB:vfoA)});' +
'};' +
'q("btnBA").onclick=()=>{vfoB=vfoA;renderVfos();pend("freq");ws2({cmd:"freq_b",hz:Math.round(vfoB)});};' +
// A=B: copy B freq into A — always freq_a (sets VFO A regardless of active VFO)
'q("btnAB").onclick=()=>{vfoA=vfoB;renderVfos();pend("freq");ws2({cmd:"freq_a",hz:Math.round(vfoA)});};' +
// SWAP — freq_a always sets VFO A, freq_b always sets VFO B
'q("btnSwap").onclick=()=>{const t=vfoA;vfoA=vfoB;vfoB=t;renderVfos();pend("freq");' +
'ws2({cmd:"freq_a",hz:Math.round(vfoA)});ws2({cmd:"freq_b",hz:Math.round(vfoB)});};' +
// SPAN кнопки: только 48K..1536K
'const SPANS=[48000,96000,192000,384000,768000,1536000];' +
@@ -355,7 +404,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"};' +
'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"};' +
'function applyState(msg){' +
'Object.keys(msg).forEach(function(k){var pk=PEND_MAP[k];if(!pk||!isPending(pk))cur[k]=msg[k];});' +
@@ -381,8 +430,10 @@ begin
'if(!isPending("snb"))updSnbBtn();if(!isPending("anf"))updAnfBtn();' +
'if(!isPending("run"))updRunBtn(!!cur.running);' +
'if(!isPending("mox"))updMoxBtn(!!cur.transmitting);' +
'if(!isPending("tun"))updToneBtn(!!cur.tuning);' +
'if(!isPending("drive")){const ts=q("txSl");if(ts&&!ts.matches(":active")){ts.value=cur.drive||0;var tv=q("txVal");if(tv)tv.textContent=cur.drive||0;}}' +
'if(!isPending("mode")){syncModGrp(cur.mode||0);}' +
'if(!isPending("attn")){const at=q("attSel");if(at)at.value=cur.attn_idx||0;}' +
'updBand(activeVfo==="A"?(cur.vfo_a_hz||0):(vfoB||0));updSpan(cur.span_hz||192000);' +
'paintSM(Number(cur.smeter_dbm||-130));' +
'const mn=cur.mode_name||(MODE_N[cur.mode||0]||"?");' +
+41 -4
View File
@@ -126,6 +126,9 @@ type
TWebCmdCenter = procedure(Hz: Double) of object;
TWebCmdMOX = procedure(On_: Boolean) of object;
TWebCmdDrive = procedure(V: Integer) of object;
TWebCmdFreqA = procedure(Hz: Double) of object;
TWebCmdAttn = procedure(Idx: Integer) of object;
TWebCmdTun = procedure(On_: Boolean) of object;
// ── Главный класс сервера ─────────────────────────────────────────────────
TWebServer = class
@@ -210,6 +213,11 @@ type
FOnDrive: TWebCmdDrive;
FTransmitting: Boolean;
FDriveLevel: Integer;
FOnFreqA: TWebCmdFreqA;
FOnAttn: TWebCmdAttn;
FAttnIdx: Integer;
FOnTun: TWebCmdTun;
FTuning: Boolean;
FWebClientActive: Boolean;
@@ -253,7 +261,8 @@ type
NRMode, NBMode: Integer; SNB, ANF: Boolean;
CenterHz: Double; FilterIdx: Integer;
VfoB: Double; ActiveVfo: Integer;
Transmitting: Boolean; DriveLevel: Integer);
Transmitting: Boolean; DriveLevel: Integer;
AttnIdx: Integer; Tuning: Boolean);
property WebClientActive: Boolean read FWebClientActive;
@@ -279,6 +288,9 @@ type
property OnCenter: TWebCmdCenter read FOnCenter write FOnCenter;
property OnMOX: TWebCmdMOX read FOnMOX write FOnMOX;
property OnDrive: TWebCmdDrive read FOnDrive write FOnDrive;
property OnFreqA: TWebCmdFreqA read FOnFreqA write FOnFreqA;
property OnAttn: TWebCmdAttn read FOnAttn write FOnAttn;
property OnTun: TWebCmdTun read FOnTun write FOnTun;
end;
implementation
@@ -1004,6 +1016,26 @@ begin
if V > 100 then V := 100;
FStateLock.Enter; FDriveLevel := V; FStateLock.Leave;
if Assigned(FOnDrive) then FOnDrive(V);
end
else if Cmd = 'set_tun' then
begin
On_ := JsonGetBool(Json, 'on', FTuning);
FStateLock.Enter; FTuning := On_; FStateLock.Leave;
if Assigned(FOnTun) then FOnTun(On_);
end
else if Cmd = 'freq_a' then
begin
HzF := JsonGetFloat(Json, 'hz', FFreq);
FStateLock.Enter; FFreq := HzF; FStateLock.Leave;
if Assigned(FOnFreqA) then FOnFreqA(HzF);
end
else if Cmd = 'attn' then
begin
Idx := JsonGetInt(Json, 'idx', FAttnIdx);
if Idx < 0 then Idx := 0;
if Idx > 2 then Idx := 2;
FStateLock.Enter; FAttnIdx := Idx; FStateLock.Leave;
if Assigned(FOnAttn) then FOnAttn(Idx);
end;
end;
@@ -1101,7 +1133,7 @@ begin
'"wf_agc":%s,"wf_nf":%s,"band_idx":%d,"smeter_dbm":%.1f,' +
'"running":%s,"mute":%s,"ctun":%s,' +
'"nr_mode":%d,"nr":%s,"nb_mode":%d,"nb":%s,"snb":%s,"anf":%s,"connected":%s,' +
'"transmitting":%s,"drive":%d}',
'"transmitting":%s,"drive":%d,"attn_idx":%d,"tuning":%s}',
[FFreq, FVfoB, FActiveVfo,
FMode, MODE_N[FMode mod 8],
FFilterIdx, FFilterBW,
@@ -1121,7 +1153,9 @@ begin
BoolToStr(FANF, 'true', 'false'),
BoolToStr(FConnected, 'true', 'false'),
BoolToStr(FTransmitting, 'true', 'false'),
FDriveLevel
FDriveLevel,
FAttnIdx,
BoolToStr(FTuning, 'true', 'false')
]);
finally
FStateLock.Leave;
@@ -1187,7 +1221,8 @@ procedure TWebServer.PushSpectrum(
NRMode, NBMode: Integer; SNB, ANF: Boolean;
CenterHz: Double; FilterIdx: Integer;
VfoB: Double; ActiveVfo: Integer;
Transmitting: Boolean; DriveLevel: Integer);
Transmitting: Boolean; DriveLevel: Integer;
AttnIdx: Integer; Tuning: Boolean);
var N, i: Integer;
begin
if not FRunning then Exit;
@@ -1221,6 +1256,8 @@ begin
FActiveVfo := ActiveVfo;
FTransmitting := Transmitting;
FDriveLevel := DriveLevel;
FAttnIdx := AttnIdx;
FTuning := Tuning;
finally
FStateLock.Leave;
end;