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>
The headless daemon never set the web server's FreqMhzDigits, so it
stayed at the constructor default (3 = up to 999 MHz) and VHF/UHF/SHF
frequencies didn't fit the web VFO display. The GUI mirrors this per-
device setting in ApplyFreqMhzDigits; the daemon now does the same on
connect (rfDevice) via PushFreqDigits, reading the value the controller
loaded into FLoadedGlobal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fix: dragging/wheeling the spectrum in the web UI clamped frequency to a
hardcoded HF ceiling (vfoMax=60 MHz), so on Pluto VHF/UHF it snapped down
to 60 MHz and couldn't pan up (only the VFO wheel worked, via a different
limit). Push the backend's tuning range (BackendCaps.MinFreqHz/MaxFreqHz)
to the web alongside the band plan; JS vfoMin/vfoMax use freq_min/freq_max
from state. The transverter branch (XVTR_CUR>=0) is unchanged — it keeps
the wide-open range since the displayed RF freq is translated to IF and
clamped server-side.
Dead-code cleanup (audit of this session's work):
- SampleRateMode/TSampleRateMode: orphaned when the sample-rate
unification replaced the srmContinuous branch in ApplyBackendCapsToUI;
no readers left. Removed (+ now-unused PLUTO_MAX_SR).
- Pre-existing write-only/unused: TBackendCaps.MaxSampleRate,
SampleRateOverlay.SPAN_NAMES, MainForm.BAND_FREQ.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Web band selector was hardcoded to the HF plan, so on Pluto it showed
160m..6m instead of the VHF/UHF plan and highlighted the wrong entry
(band switching itself already worked: web sends idx, controller maps it
via the IsPluto plan). Mirror the sample-rate unification.
Single source of truth = BoardUtils -> controller:
- BoardUtils: TBandInfo/TBandPlanArray + BuildBandPlan(Pluto).
- TRadioController.BandPlan returns BuildBandPlan(IsPluto).
Both UIs derive from it:
- Desktop already plan-aware (RelabelBands + RestoreBand) - unchanged.
- Web: WebServer.SetBands + "bands" in state JSON; hosts push
BandPlan on connect/startup; JS builds the band selector from
state.bands (BAND_N/BAND_F now dynamic).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sample-rate (span) presets were duplicated in three places and none was
authoritative: HPSDR set hardcoded in the desktop overlay (SPAN_RATES),
Pluto set in backend caps, and a separate hardcoded list in the web JS.
The web showed HPSDR rates even on Pluto, and the web adapter silently
dropped any rate outside a hardcoded HPSDR whitelist (so Pluto-only rates
like 960k/2304k never switched).
Single source of truth = backend caps -> controller:
- HPSDRNetwork.Caps now fills RatePresets [48k..1536k] (was nil).
- RadioBackend: named type TBackendRateArray.
- TRadioController.SampleRatePresets returns BackendCaps.RatePresets.
Both UIs derive from it:
- Desktop: ApplyBackendCapsToUI always feeds the overlay from
SampleRatePresets (overlay no longer owns the HPSDR list).
- Web: WebServer.SetRatePresets + rate_presets in state JSON; hosts
(daemon OnState/startup, GUI ApplyBackendCapsToUI/startup) push
SampleRatePresets; JS builds span buttons dynamically from it.
- WebAdapter.SyncSpan validates against SampleRatePresets instead of a
hardcoded whitelist, so any backend rate is accepted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The headless daemon (ewsdrd) only ever built HPSDR devices, so Pluto
could not be used: backend is chosen by Dev.Kind and Pluto opens by URI,
neither of which the daemon propagated. Also local audio played on the
host and web Discover never probed network Plutos.
Shared, backend-agnostic helpers in TRadioController (used by GUI + daemon):
- ResolveDevice(IP): discovered->saved lookup restoring Kind/URI/Serial/
BoardType/MAC. MainForm.ResolveDevice now delegates here.
- AddSavedDevice(name, addr): web dev_add saves Pluto when addr has a URI
scheme (ip:/usb:/local:), else HPSDR. Both web hosts use it.
- SeedPlutoProbeFromSaved: seed network-probe URIs from saved Plutos
(no mDNS -> a network Pluto is only found by direct URI probe).
- LocalAudioEnabled flag (GUI=True): when False the local sound card is
neither opened nor written; RX audio goes only to web (OnAudioConsume).
DeviceStore.AutoStartDevice(out Dev): full autostart record (Kind/URI/
Serial) so a saved Pluto (URI-addressed, empty IPAddress on USB) can
autostart.
Daemon (ewsdrd.lpr): LocalAudioEnabled:=False; SyncConnect via
ResolveDevice; autostart via AutoStartDevice; SyncDiscover seeds probe
URIs then Discover; SyncDevAdd via AddSavedDevice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FLastSMeter is sourced from WDSP (GetSMeterDBm), not the network HP-status,
so only the GUI timer (MainForm) ever updated it. In headless mode nothing
did, leaving PushState to send web clients a frozen -130 dBm. Refresh it in
the daemon Run loop before each PushState, mirroring the GUI timer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three runtime fixes found testing the daemon on real hardware:
- WDSP opened in ~47s (vs <1s GUI): WDSPEngine.Open never imports FFTW wisdom;
the GUI does it separately in EnsureWDSPWisdom before Open. Daemon now calls
WDSPwisdom(GetAppCfgDir) before Open (imports the existing wdspWisdom00 in ms;
builds it once if absent). Verified: open is now instant.
- Web waterfall/spectrum filled only the left ~6%: the analyzer's pixel width
defaults to DISPLAY_BLOCK_SIZE (64). The GUI calls SetSpectrumWidth(panel) every
tick; headless never did, so only 64 of the 1024 web buffer points had data.
Daemon now calls SetSpectrumWidth(1024) after Open (== SPECTRUM_PIXELS / web
buffer). FLastSpectrumW survives channel recreation.
- SIGINT during the (blocking) autostart connect was lost because Run set
FRunning:=True *after* the connect, overwriting RequestStop. Set FRunning:=True
before autostart so a stop during connect exits the loop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GetAppConfigDir derives the dir from ApplicationName, which defaulted to the
binary name 'ewsdrd' -> a separate empty ~/.config/ewsdrd/. With no per-device
settings the daemon never configured WDSP's FFT/spectrum/waterfall display (web
showed a white waterfall and no spectrum), never resolved a saved board type
(Board=0), and -- critically -- found no FFTW wisdom, so WDSP recomputed it from
scratch on Open (~48s vs <1s in the GUI). Set OnGetApplicationName := 'EWSDR'
(matching the GUI's Application.Title) before constructing the controller, so the
daemon reads the same ~/.config/EWSDR/ (settings, saved devices, wdspWisdom00).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ewsdrd.lpr — a console daemon that runs TRadioController + TWebAdapter WITHOUT
TMainForm/LCL, proving the Phase-5 goal: web-only headless operation. THeadlessHost
wires the data-path callbacks to the controller, the web command handlers to the
adapter (lifecycle ones marshalled to the main thread via FCtrl.Invoke =
TThread.Synchronize), opens WDSP synchronously, autostarts the saved device, and
runs a CheckSynchronize + periodic PushState loop. SIGINT/SIGTERM → graceful
StopAndDisconnect (persist + Run=0).
To keep the controller graph truly LCL-free, PlatformUtils now guards its only
Forms dependency (Screen.PixelsPerInch in CurrentScreenDPI) behind {$IFNDEF
HEADLESS}; the GUI build is byte-identical (HEADLESS undefined). build-ewsdrd.sh
compiles the headless graph from source with -Mobjfpc (nested comments) -dHEADLESS
into a separate lib-headless/ output dir, untouching the GUI .ppu.
Verified: builds clean; runs headless, web server up (HTTP 401 = serving), no LCL;
SIGINT shuts down gracefully. Radio-connect + web round-trip need hardware to test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>