Files
ewsdr/ewsdrd.lpr
T
Uladzimir KarpenkaandClaude Opus 4.8 de234b56e5 Phase 5 (D4): headless daemon entry point (ewsdrd)
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>
2026-06-09 15:26:44 +03:00

403 lines
15 KiB
ObjectPascal

program ewsdrd;
{$IFDEF FPC}{$MODE objfpc}{$H+}{$ENDIF}
{$APPTYPE CONSOLE}
// ===========================================================================
// EWSDR daemon — headless-режим (только Web-управление, без UI/LCL).
//
// Доказывает, что рефакторинг достиг цели Фазы 5: TRadioController + адаптеры
// (Web) работают БЕЗ TMainForm. Граф зависимостей контроллера LCL-free
// (проверено), поэтому здесь нет ни Interfaces, ни Forms, ни виджетсета.
//
// Маршалинг как в GUI: входящие web-команды приходят из потока веб-сервера и
// выполняются в главном потоке через FCtrl.Invoke (=TThread.Synchronize). Чтобы
// очередь Synchronize реально исполнялась без LCL-цикла сообщений, главный поток
// крутит CheckSynchronize() в Run-цикле — там же периодический PushState.
//
// Сборка (использует уже скомпилированные .ppu от GUI-сборки в lib/...):
// fpc -Mdelphi -O3 -Fulib/$(fpc -iTP)-$(fpc -iTO) -k-L/usr/local/lib \
// -obin/ewsdrd ewsdrd.lpr
// (см. build-ewsdrd.sh). libwdsp.dylib должна быть в /usr/local/lib.
//
// Запуск: ./bin/ewsdrd — если есть autostart-устройство (отмеченное в web/GUI),
// подключается сразу; иначе ждёт connect из web-морды (порт из настроек).
// ===========================================================================
uses
{$IFDEF UNIX}cthreads, BaseUnix,{$ENDIF}
Classes, SysUtils,
RadioController, WebServer, WebAdapter,
HPSDRNetwork, Settings;
const
DAEMON_SPEC_WIDTH = 1024; // ширина спектра для StartRunning (web-буфер = 1024)
PUSH_INTERVAL_MS = 50; // период исходящего web-зеркала (как GUI-таймер)
type
{ THeadlessHost — связывает контроллер, веб-сервер и веб-адаптер без формы. }
THeadlessHost = class
private
FCtrl: TRadioController;
FWeb: TWebServer;
FWebAd: TWebAdapter;
FRunning: Boolean;
// Параметры web-команд (заполняются в WS-потоке, читаются в Sync* главного потока).
FsStr1, FsStr2: string;
FsInt: Integer;
FsBool: Boolean;
// Маршалинг метода в главный поток (очередь Synchronize крутит Run-цикл).
procedure DoInvoke(M: TThreadMethod);
// Контроллер → наружу: device-list/xvtr не входят в периодический PushState.
procedure OnState(Sender: TObject; Field: TRadioField);
// WDSP открываем лениво и синхронно (нет UI — блокировать главный поток ок).
function EnsureWDSP: Boolean;
procedure ConnectTo(const Dev: THPSDRDevice);
procedure PushDeviceList;
procedure PushXvtr;
// Входящие web-команды (из WS-потока): запоминаем параметр и маршалим в Sync*.
procedure WebRun(On_: Boolean);
procedure WebConnect(const IP: string);
procedure WebDiscover;
procedure WebDisconnect;
procedure WebDevAdd(const AName, IP: string);
procedure WebDevRemove(Idx: Integer);
procedure WebDevAutoStart(Idx: Integer);
// Sync* — выполняются в главном потоке через DoInvoke.
procedure SyncConnect;
procedure SyncDisconnect;
procedure SyncRun;
procedure SyncDevAdd;
procedure SyncDevRemove;
procedure SyncDevAutoStart;
public
constructor Create;
destructor Destroy; override;
procedure Run;
procedure RequestStop; // из обработчика сигнала (только флаг)
end;
var
GHost: THeadlessHost = nil; // для обработчика сигнала
procedure Log(const S: string);
begin
WriteLn(FormatDateTime('hh:nn:ss', Now), ' ', S);
Flush(Output);
end;
{$IFDEF UNIX}
procedure HandleSignal(sig: cint); cdecl;
begin
if Assigned(GHost) then GHost.RequestStop;
end;
{$ENDIF}
{ THeadlessHost }
constructor THeadlessHost.Create;
var
StartupVfoA, StartupVfoB: Double;
StartupRate: Integer;
WebCfg: TWebSettings;
begin
inherited Create;
FCtrl := TRadioController.Create; // создаёт FSettings + FDeviceStore (LoadSaved)
FCtrl.OnInvoke := @DoInvoke;
FCtrl.OnStateChanged := @OnState;
// Настройки + startup-частота ДО CreateEngines (DSP рождается с верным rate).
FCtrl.FSettings.Load;
if FCtrl.FSettings.LoadStartupPreview(StartupVfoA, StartupVfoB, StartupRate)
and (StartupRate > 0) then
begin
FCtrl.FVfoA := StartupVfoA;
FCtrl.FVfoB := StartupVfoB;
FCtrl.FSampleRate := StartupRate;
FCtrl.FSpanHz := StartupRate;
end;
FCtrl.CreateEngines(FCtrl.FSampleRate);
// Data-path колбэки (сеть/DSP) — все на контроллер (backend-агностично).
FCtrl.FNetwork.OnDeviceFound := @FCtrl.OnDeviceFound;
FCtrl.FNetwork.OnHPStatus := @FCtrl.OnHPStatus;
FCtrl.FNetwork.OnDDCIQ := @FCtrl.OnDDCIQ;
FCtrl.FNetwork.OnMicPacket := @FCtrl.OnMicPacket;
// OnWideband пропущен — это дисплей-панорама, демону не нужна.
FCtrl.FDSPEngine.OnAudio := @FCtrl.OnAudioReady;
FCtrl.FDSPEngine.OnSpectrum := @FCtrl.OnSpectrumReady;
FCtrl.FDSPEngine.OnWaterfall := @FCtrl.OnWaterfallReady;
FCtrl.FDSPEngine.OnTXIQ := @FCtrl.OnTXIQ;
FCtrl.FDSPEngine.OnPullMicSamples := @FCtrl.PullSoundCardMic;
// OnSpectrumData/OnWaterfallData НЕ навешиваем — рендера нет; web читает кадр
// напрямую из FCtrl.FSpectrumBuf в PushState.
// --- Веб-сервер + адаптер ---
FCtrl.FSettings.LoadWebSettings(WebCfg);
FWeb := TWebServer.Create(WebCfg.User, WebCfg.Pass, Word(WebCfg.Port), WebCfg.BindAddr);
FWebAd := TWebAdapter.Create(FCtrl, FWeb, nil);
// RX-аудио веб-клиенту — через route-хук контроллера (D1).
FCtrl.OnAudioConsume := @FWebAd.AudioConsume;
// Чистые контроллер-команды → адаптер (как в GUI).
FWeb.OnMode := @FWebAd.OnMode; FWeb.OnFilter := @FWebAd.OnFilter;
FWeb.OnAGC := @FWebAd.OnAGC; FWeb.OnAGCTop := @FWebAd.OnAGCTop;
FWeb.OnVolume := @FWebAd.OnVolume; FWeb.OnWfAGC := @FWebAd.OnWfAGC;
FWeb.OnWfNF := @FWebAd.OnWfNF; FWeb.OnMute := @FWebAd.OnMute;
FWeb.OnNR := @FWebAd.OnNR; FWeb.OnNB := @FWebAd.OnNB;
FWeb.OnSNB := @FWebAd.OnSNB; FWeb.OnANF := @FWebAd.OnANF;
FWeb.OnFreqB := @FWebAd.OnFreqB; FWeb.OnCenter := @FWebAd.OnCenter;
FWeb.OnAttn := @FWebAd.OnAttn; FWeb.OnFreq := @FWebAd.OnFreq;
FWeb.OnFreqA := @FWebAd.OnFreqA; FWeb.OnBand := @FWebAd.OnBand;
FWeb.OnSpan := @FWebAd.OnSpan; FWeb.OnDrive := @FWebAd.OnDrive;
FWeb.OnCtun := @FWebAd.OnCtun; FWeb.OnActiveVfo := @FWebAd.OnActiveVfo;
FWeb.OnMOX := @FWebAd.OnMOX; FWeb.OnTun := @FWebAd.OnTun;
FWeb.OnFMStep := @FWebAd.OnFMStep; FWeb.OnXvtrBand := @FWebAd.OnXvtrBand;
FWeb.OnWebMic := @FWebAd.OnMic;
FWeb.OnClientActiveChanged := @FWebAd.OnClientActiveChanged;
// Host-coupled (lifecycle) — на демон-хост (маршалит в главный поток).
FWeb.OnRun := @WebRun;
FWeb.OnConnect := @WebConnect;
FWeb.OnDiscoverDev := @WebDiscover;
FWeb.OnDisconnectDev := @WebDisconnect;
FWeb.OnDevAdd := @WebDevAdd;
FWeb.OnDevRemove := @WebDevRemove;
FWeb.OnDevAutoStart := @WebDevAutoStart;
end;
destructor THeadlessHost.Destroy;
begin
if Assigned(FWeb) then FWeb.Stop;
FreeAndNil(FWebAd);
FreeAndNil(FWeb);
FreeAndNil(FCtrl); // Destroy зовёт FreeEngines (Disconnect если Connected)
inherited Destroy;
end;
procedure THeadlessHost.DoInvoke(M: TThreadMethod);
begin
TThread.Synchronize(nil, M);
end;
procedure THeadlessHost.OnState(Sender: TObject; Field: TRadioField);
begin
// Большинство rf* — это UI-рендер (демону не нужен). Сюда попадают только
// выходные web-списки, которых нет в периодическом PushState.
case Field of
rfDeviceList: PushDeviceList; // discovered обновился
rfXvtr, rfDevice: begin PushXvtr; PushDeviceList; end; // connect / xvtr-смена
end;
end;
function THeadlessHost.EnsureWDSP: Boolean;
begin
if FCtrl.FWDSPReady then Exit(True);
Log('Opening WDSP...');
Result := FCtrl.FDSPEngine.Open;
FCtrl.FWDSPReady := Result;
if not Result then
Log('WDSP open FAILED: ' + FCtrl.FDSPEngine.LastError);
end;
procedure THeadlessHost.ConnectTo(const Dev: THPSDRDevice);
begin
if FCtrl.FNetwork.Connected then Exit;
if not EnsureWDSP then Exit;
Log('Connecting to ' + Dev.IPAddress + '...');
if FCtrl.ConnectAndRun(Dev, DAEMON_SPEC_WIDTH) then
Log('Running. Board=' + IntToStr(FCtrl.FNetwork.Device.BoardType))
else
Log('Connect FAILED: ' + FCtrl.FNetwork.LastError);
end;
procedure THeadlessHost.PushDeviceList;
var
Arr: TWebDeviceArray;
i, k: Integer;
begin
SetLength(Arr, FCtrl.FDeviceStore.SavedCount + FCtrl.FDeviceStore.DiscoveredCount);
k := 0;
// saved ПЕРВЫМИ — их позиция == saved-индекс (web шлёт его в dev_remove/autostart).
for i := 0 to FCtrl.FDeviceStore.SavedCount - 1 do
begin
Arr[k].Name := FCtrl.FDeviceStore.Saved(i).Name;
Arr[k].IP := FCtrl.FDeviceStore.Saved(i).IPAddress;
Arr[k].BoardType := FCtrl.FDeviceStore.Saved(i).BoardType;
Arr[k].IsSaved := True;
Arr[k].AutoStart := FCtrl.FDeviceStore.Saved(i).AutoStart;
Inc(k);
end;
for i := 0 to FCtrl.FDeviceStore.DiscoveredCount - 1 do
begin
Arr[k].Name := FCtrl.FDeviceStore.Discovered(i).DisplayName;
Arr[k].IP := FCtrl.FDeviceStore.Discovered(i).IPAddress;
Arr[k].BoardType := FCtrl.FDeviceStore.Discovered(i).BoardType;
Arr[k].IsSaved := False;
Arr[k].AutoStart := False;
Inc(k);
end;
FWeb.SetDeviceList(Arr);
end;
procedure THeadlessHost.PushXvtr;
var
Arr: TWebXvtrArray;
i, Cnt: Integer;
begin
Cnt := 0;
for i := 0 to CFG_XVTR_COUNT - 1 do
if FCtrl.FXvtrSettings.Entries[i].Enabled then Inc(Cnt);
SetLength(Arr, Cnt);
Cnt := 0;
for i := 0 to CFG_XVTR_COUNT - 1 do
if FCtrl.FXvtrSettings.Entries[i].Enabled then
begin
Arr[Cnt].Idx := i;
Arr[Cnt].Name := FCtrl.FXvtrSettings.Entries[i].ButtonText;
Inc(Cnt);
end;
FWeb.SetXvtrBands(Arr, FCtrl.FCurrentXvtr);
end;
// --- Входящие web-команды (WS-поток) → маршалинг в главный поток ---
procedure THeadlessHost.WebRun(On_: Boolean);
begin FsBool := On_; FCtrl.Invoke(@SyncRun); end;
procedure THeadlessHost.WebConnect(const IP: string);
begin FsStr1 := IP; FCtrl.Invoke(@SyncConnect); end;
procedure THeadlessHost.WebDiscover;
begin FCtrl.Invoke(@FCtrl.Discover); end; // Discover уже headless
procedure THeadlessHost.WebDisconnect;
begin FCtrl.Invoke(@SyncDisconnect); end;
procedure THeadlessHost.WebDevAdd(const AName, IP: string);
begin FsStr1 := AName; FsStr2 := IP; FCtrl.Invoke(@SyncDevAdd); end;
procedure THeadlessHost.WebDevRemove(Idx: Integer);
begin FsInt := Idx; FCtrl.Invoke(@SyncDevRemove); end;
procedure THeadlessHost.WebDevAutoStart(Idx: Integer);
begin FsInt := Idx; FCtrl.Invoke(@SyncDevAutoStart); end;
// --- Sync* (главный поток) ---
procedure THeadlessHost.SyncConnect;
var
Dev: THPSDRDevice;
i: Integer;
begin
if FCtrl.FNetwork.Connected then Exit;
FillChar(Dev, SizeOf(Dev), 0);
Dev.IPAddress := FsStr1;
// BoardType не приходит при Connect — берём из discovery, иначе из saved по IP.
i := FCtrl.FDeviceStore.FindDiscoveredByIP(FsStr1);
if i >= 0 then
Dev.BoardType := FCtrl.FDeviceStore.Discovered(i).BoardType
else
for i := 0 to FCtrl.FDeviceStore.SavedCount - 1 do
if FCtrl.FDeviceStore.Saved(i).IPAddress = FsStr1 then
begin
Dev.BoardType := FCtrl.FDeviceStore.Saved(i).BoardType;
Break;
end;
ConnectTo(Dev);
end;
procedure THeadlessHost.SyncDisconnect;
begin
if FCtrl.FNetwork.Connected then
begin
FCtrl.StopAndDisconnect;
Log('Disconnected (web).');
end;
end;
procedure THeadlessHost.SyncRun;
begin
// set_run:true бессмыслен без выбранного устройства (web стартует через connect);
// здесь только STOP-путь, симметрично web-disconnect.
if (not FsBool) and FCtrl.FNetwork.Connected then
begin
FCtrl.StopAndDisconnect;
Log('Stopped (web set_run:false).');
end;
end;
procedure THeadlessHost.SyncDevAdd;
begin FCtrl.FDeviceStore.AddSaved(FsStr1, FsStr2, 0); PushDeviceList; end;
procedure THeadlessHost.SyncDevRemove;
begin FCtrl.FDeviceStore.RemoveSaved(FsInt); PushDeviceList; end;
procedure THeadlessHost.SyncDevAutoStart;
begin FCtrl.FDeviceStore.SetAutoStart(FsInt); PushDeviceList; end;
procedure THeadlessHost.RequestStop;
begin
FRunning := False;
end;
procedure THeadlessHost.Run;
var
WebCfg: TWebSettings;
Dev: THPSDRDevice;
AutoIP: string;
lastPush, nowMs: QWord;
begin
FCtrl.FSettings.LoadWebSettings(WebCfg);
if not FWeb.Start then
begin
Log('Web server FAILED to start on ' + WebCfg.BindAddr + ':' + IntToStr(WebCfg.Port));
Exit;
end;
Log('Web control on http://' + WebCfg.BindAddr + ':' + IntToStr(WebCfg.Port));
PushDeviceList;
// Autostart — подключаемся сразу к отмеченному устройству.
AutoIP := FCtrl.FDeviceStore.AutoStartIP;
if AutoIP <> '' then
begin
FillChar(Dev, SizeOf(Dev), 0);
Dev.IPAddress := AutoIP;
Dev.BoardType := FCtrl.FDeviceStore.AutoStartBoardType;
ConnectTo(Dev);
end
else
Log('No autostart device — waiting for web connect.');
FRunning := True;
lastPush := GetTickCount64;
while FRunning do
begin
CheckSynchronize(PUSH_INTERVAL_MS); // исполняет очередь Invoke + ждёт до 50мс
nowMs := GetTickCount64;
if nowMs - lastPush >= PUSH_INTERVAL_MS then
begin
FWebAd.PushState; // исходящее зеркало состояния/спектра
lastPush := nowMs;
end;
end;
Log('Shutting down...');
if FCtrl.FNetwork.Connected then
FCtrl.StopAndDisconnect; // персист настроек + graceful Run=0
end;
begin
Log('EWSDR daemon starting (headless).');
GHost := THeadlessHost.Create;
{$IFDEF UNIX}
fpSignal(SIGINT, @HandleSignal);
fpSignal(SIGTERM, @HandleSignal);
{$ENDIF}
try
GHost.Run;
finally
FreeAndNil(GHost);
end;
Log('EWSDR daemon stopped.');
end.