Files
ewsdr/ewsdrd.lpr
T
Uladzimir KarpenkaandClaude Opus 4.8 db361a274b Phase 5 (D4 fix): daemon shares GUI config dir (EWSDR app name)
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>
2026-06-09 15:55:34 +03:00

413 lines
16 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
function DaemonAppName: string;
begin
// КРИТИЧНО: тот же каталог конфигурации, что у GUI (~/.config/EWSDR/).
// GetAppConfigDir берёт имя из ApplicationName, иначе бы вышло «ewsdrd» —
// отдельный пустой конфиг (нет device-настроек/FFT-параметров/FFTW-wisdom →
// белый водопад + WDSP открывается ~минуту). GUI задаёт Application.Title:=EWSDR.
Result := 'EWSDR';
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
OnGetApplicationName := @DaemonAppName; // ДО создания контроллера/настроек!
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.