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.