From de234b56e5791df230747a5598a81b2f4f53e707 Mon Sep 17 00:00:00 2001 From: Uladzimir Karpenka Date: Tue, 9 Jun 2026 15:26:44 +0300 Subject: [PATCH] Phase 5 (D4): headless daemon entry point (ewsdrd) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 + PlatformUtils.pas | 6 +- build-ewsdrd.sh | 22 +++ ewsdrd.lpr | 402 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 432 insertions(+), 1 deletion(-) create mode 100755 build-ewsdrd.sh create mode 100644 ewsdrd.lpr diff --git a/.gitignore b/.gitignore index 355f586..0d7d847 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,9 @@ modules/ backup/ *.bak lib/ +lib-headless/ +ewsdr_test_checklist.html +ewsdr-test-report-*.md # Application bundle for Mac OS *.app/ diff --git a/PlatformUtils.pas b/PlatformUtils.pas index dcfebee..4e0a73e 100644 --- a/PlatformUtils.pas +++ b/PlatformUtils.pas @@ -22,7 +22,7 @@ function GetAppCfgDir: string; implementation uses - SysUtils, Forms; + SysUtils {$IFNDEF HEADLESS}, Forms{$ENDIF}; function QtPlatformAllowsOpenGLControls: Boolean; var @@ -62,8 +62,12 @@ end; function CurrentScreenDPI: Integer; begin +{$IFNDEF HEADLESS} Result := Screen.PixelsPerInch; if Result <= 0 then Result := 96; +{$ELSE} + Result := 96; // headless-демон без LCL: экрана нет, дефолт DPI +{$ENDIF} end; function GetAppCfgDir: string; diff --git a/build-ewsdrd.sh b/build-ewsdrd.sh new file mode 100755 index 0000000..6a21775 --- /dev/null +++ b/build-ewsdrd.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Сборка headless-демона ewsdrd (только Web-управление, без LCL/виджетсета). +# +# Компилирует headless-граф ИЗ ИСХОДНИКОВ с -dHEADLESS в ОТДЕЛЬНЫЙ каталог +# (lib-headless/...), чтобы не трогать .ppu GUI-сборки. Ключевое: +# -Mobjfpc — командный режим по умолчанию: включает nested comments, нужные +# для лидирующих комментариев Settings.pas (юниты дальше сами ставят +# свой {$mode} директивой; программа — {$MODE Delphi}). +# -dHEADLESS — PlatformUtils не тянет Forms (LCL), граф остаётся LCL-free. +# libwdsp.dylib должна лежать в /usr/local/lib. +set -e +ROOT=$(cd "$(dirname "$0")" && pwd) +cd "$ROOT" +CPU=$(fpc -iTP) +OS=$(fpc -iTO) +OUTUNITS="lib-headless/${CPU}-${OS}" +OUT="bin/${CPU}-${OS}" +mkdir -p "$OUTUNITS" "$OUT" +fpc -Mobjfpc -O3 -dHEADLESS \ + -Fu"$ROOT" -FU"$OUTUNITS" -k-L/usr/local/lib \ + -o"$OUT/ewsdrd" ewsdrd.lpr +echo "Built: $OUT/ewsdrd" diff --git a/ewsdrd.lpr b/ewsdrd.lpr new file mode 100644 index 0000000..6d8d4d4 --- /dev/null +++ b/ewsdrd.lpr @@ -0,0 +1,402 @@ +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.