feat: Pluto support in headless daemon (connect/autostart/discover/dev_add) + headless audio

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>
This commit is contained in:
2026-06-23 13:32:05 +03:00
co-authored by Claude Opus 4.8
parent 3115b2b5bf
commit d78d835485
4 changed files with 174 additions and 88 deletions
+128 -27
View File
@@ -129,6 +129,7 @@ type
FOnStateChanged: TRadioStateEvent;
FOnAfterTune: TThreadMethod; // пост-тюн хук: канал-оркестрация после SetVfoA
FOnAudioConsume: TAudioConsumeEvent; // web-перехват RX-аудио (если активен клиент)
FLocalAudio: Boolean; // локальный звук (динамик/мик ПК): GUI=True, демон=False
FOnSpectrumData: TPixelDataEvent; // сырьё спектра наружу (UI рисует FSpecView)
FOnWaterfallData: TPixelDataEvent; // сырьё водопада наружу (UI: децимация+рендер)
procedure Changed(Field: TRadioField);
@@ -528,6 +529,14 @@ type
function ConnectDevice(const Dev: THPSDRDevice): Boolean; // Connect + load-by-MAC; рендер через rfDevice
function ConnectAndRun(const Dev: THPSDRDevice; SpectrumWidth: Integer): Boolean; // ConnectDevice + band/xvtr/vfo restore + go-live
procedure StopAndDisconnect; // graceful Run=0 + персист настроек + Disconnect (headless)
// По IP/URI ищет устройство в store (discovered→saved), восстанавливая
// Kind/URI/Serial/BoardType/MAC. Нужно для Pluto (открывается по URI, выбор
// бэкенда — по Kind). Не найдено → простой HPSDR по IP. Общий путь GUI+демон.
function ResolveDevice(const IP: string): THPSDRDevice;
// Сохраняет устройство по введённому адресу. Схема URI ('ip:'/'usb:'/'local:')
// → Pluto (AddSavedPluto), иначе HPSDR-IP. Общий путь web-dev_add для GUI и
// демона (десктоп-диалог сохраняет Pluto своим путём через AddSavedPluto).
procedure AddSavedDevice(const AName, AAddr: string);
procedure Discover;
// Гарантирует, что FNetwork — бэкенд нужного типа (HPSDR/Pluto): при
// несовпадении пересоздаёт его, перенося колбэки данных. Вызывается из
@@ -547,6 +556,9 @@ type
// Список URI для probe сетевых Pluto (mDNS недоступен) — задаёт фронтенд
// перед Discover (сохранённые Pluto + ручной ввод IP).
procedure SetPlutoProbeURIs(const URIs: array of string);
// Сидирует probe-список из сохранённых Pluto (для бэкенд-агностичного Discover
// без UI-диалога — демон). GUI добавляет ручной IP поверх в BuildProbeURIs.
procedure SeedPlutoProbeFromSaved;
// ---- События наружу ----
property OnInvoke: TUIInvokeEvent read FOnInvoke write FOnInvoke;
@@ -556,6 +568,9 @@ type
// чтобы любой путь тюнинга (desktop/web/CAT/энкодер) был канал-aware headless.
property OnAfterTune: TThreadMethod read FOnAfterTune write FOnAfterTune;
property OnAudioConsume: TAudioConsumeEvent read FOnAudioConsume write FOnAudioConsume;
// Локальный звук ПК (динамик RX / микрофон TX). Демон ставит False: аудио
// только через web (OnAudioConsume), локальная звуковая карта не трогается.
property LocalAudioEnabled: Boolean read FLocalAudio write FLocalAudio;
property OnSpectrumData: TPixelDataEvent read FOnSpectrumData write FOnSpectrumData;
property OnWaterfallData: TPixelDataEvent read FOnWaterfallData write FOnWaterfallData;
end;
@@ -580,6 +595,7 @@ begin
FPlutoTxMaxAttDb := -10.0; // ADI-дефолт: линейный режим выходных каскадов
FWfAGCEnabled := True; FDitherEnabled := True; FRandomEnabled := True;
FFMStepOn := True;
FLocalAudio := True; // GUI: звук на локальную карту; демон выключит явно
FLastSMeter := -130; FLastSWR := 1; FLastSupplyV := -1; FLastSupplyA := -1;
FLastPlutoTempC := TELEMETRY_NONE; FLastPlutoRSSIdB := TELEMETRY_NONE;
FPlutoTeleTick := 0; FPlutoTempBad := 0;
@@ -896,7 +912,10 @@ begin
FNetwork.SendSpeakerAudio(Left, Right, Count);
if Assigned(FOnAudioConsume) and FOnAudioConsume(Left, Right, Count) then
Exit;
FAudioOut.Write(Left, Right, Count);
// Без локального звука (демон) на этом маршрут заканчивается: если web-клиент
// не активен, аудио просто отбрасывается, а не идёт на звуковую карту хоста.
if FLocalAudio then
FAudioOut.Write(Left, Right, Count);
end;
procedure TRadioController.OnSpectrumReady(const Pixels: array of Single;
@@ -2790,6 +2809,58 @@ begin
Changed(rfRunning); // безусловный сброс run-кнопки/MOX.Enabled/sleep (как старый хвост STOP)
end;
function TRadioController.ResolveDevice(const IP: string): THPSDRDevice;
// discovered→saved по IP/URI: восстанавливает Kind/URI/Serial/BoardType/MAC,
// иначе простой HPSDR по IP. См. одноимённую обёртку в GUI (TMainForm).
var
i: Integer;
dd: TDiscoveredDevice;
sd: TSavedDevice;
begin
FillChar(Result, SizeOf(Result), 0);
Result.Kind := bkHPSDR;
Result.IPAddress := IP;
i := FDeviceStore.FindDiscoveredByIP(IP);
if i >= 0 then
begin
dd := FDeviceStore.Discovered(i);
Result.Kind := dd.Kind;
Result.URI := dd.URI;
Result.Serial := dd.Serial;
Result.BoardType := dd.BoardType;
Move(dd.MAC[0], Result.MAC[0], 6);
Exit;
end;
for i := 0 to FDeviceStore.SavedCount - 1 do
begin
sd := FDeviceStore.Saved(i);
if SameText(sd.IPAddress, IP) or SameText(sd.URI, IP) then
begin
Result.Kind := sd.Kind;
Result.URI := sd.URI;
Result.Serial := sd.Serial;
Result.BoardType := sd.BoardType;
Exit;
end;
end;
end;
procedure TRadioController.AddSavedDevice(const AName, AAddr: string);
// web-dev_add: Pluto по схеме URI ('ip:'/'usb:'/'local:'), иначе HPSDR-IP.
// Serial при ручном добавлении неизвестен (допишется при connect/probe).
var
A: string;
begin
A := Trim(AAddr);
if A = '' then Exit;
if (Pos('ip:', A) = 1) or (Pos('usb:', A) = 1) or (Pos('local:', A) = 1) then
FDeviceStore.AddSavedPluto(AName, A, '')
else
FDeviceStore.AddSaved(AName, A, 0);
end;
function TRadioController.ConnectDevice(const Dev: THPSDRDevice): Boolean;
// Подключение + загрузка per-device настроек по MAC в поля контроллера + конфиг
// движка (DSP display, audio). Возвращает False, если Connect не удался (UI
@@ -2884,33 +2955,39 @@ begin
FDSPEngine.SetWaterfallDisplay(FLoadedGlobal.WfDetector, FLoadedGlobal.WfAvgMode,
FLoadedGlobal.WfAvgTimeMS);
end;
// Аудио устройство вывода
FAudioOut.Close;
if FLoadedGlobal.AudioSampleRate > 0 then
FAudioOut.SampleRate := FLoadedGlobal.AudioSampleRate;
if FLoadedGlobal.AudioOutDevice <> '' then
// Локальная звуковая карта (динамик RX / микрофон TX). В демоне FLocalAudio=
// False: карту не открываем (иначе headless-хост хватает ALSA-устройство и
// играет звук). Web-аудио идёт через OnAudioConsume и от FAudioOut не зависит.
if FLocalAudio then
begin
FAudioOut.DeviceIndex := FAudioOut.FindDeviceByName(FLoadedGlobal.AudioOutDevice);
FAudioOutDevName := FLoadedGlobal.AudioOutDevice;
end
else
begin
FAudioOut.DeviceIndex := -1;
FAudioOutDevName := '';
end;
FAudioOut.Open;
// Аудио устройство ввода (TX mic). MicSource определяется в SetMOX по PTT —
// здесь только открываем устройство.
if FLoadedGlobal.AudioInDevice <> '' then
begin
FAudioIn.DeviceIndex := FAudioIn.FindDeviceByName(FLoadedGlobal.AudioInDevice);
FAudioInDevName := FLoadedGlobal.AudioInDevice;
FAudioIn.Open;
end
else
begin
FAudioIn.DeviceIndex := -1;
FAudioInDevName := '';
// Аудио устройство вывода
FAudioOut.Close;
if FLoadedGlobal.AudioSampleRate > 0 then
FAudioOut.SampleRate := FLoadedGlobal.AudioSampleRate;
if FLoadedGlobal.AudioOutDevice <> '' then
begin
FAudioOut.DeviceIndex := FAudioOut.FindDeviceByName(FLoadedGlobal.AudioOutDevice);
FAudioOutDevName := FLoadedGlobal.AudioOutDevice;
end
else
begin
FAudioOut.DeviceIndex := -1;
FAudioOutDevName := '';
end;
FAudioOut.Open;
// Аудио устройство ввода (TX mic). MicSource определяется в SetMOX по PTT —
// здесь только открываем устройство.
if FLoadedGlobal.AudioInDevice <> '' then
begin
FAudioIn.DeviceIndex := FAudioIn.FindDeviceByName(FLoadedGlobal.AudioInDevice);
FAudioInDevName := FLoadedGlobal.AudioInDevice;
FAudioIn.Open;
end
else
begin
FAudioIn.DeviceIndex := -1;
FAudioInDevName := '';
end;
end;
end;
@@ -3153,6 +3230,30 @@ begin
for i := 0 to High(URIs) do FPlutoProbeURIs[i] := URIs[i];
end;
procedure TRadioController.SeedPlutoProbeFromSaved;
// Заполняет probe-список URI всех сохранённых Pluto (mDNS нет → сетевой Pluto
// находится только прямым probe его URI). Демон зовёт это перед Discover; GUI
// добавляет ещё и ручной IP из диалога. Дедупликация по URI.
var
URIs: array of string;
i, k: Integer;
U: string;
begin
SetLength(URIs, 0);
for i := 0 to FDeviceStore.SavedCount - 1 do
if FDeviceStore.Saved(i).Kind = bkPluto then
begin
U := FDeviceStore.Saved(i).URI;
if U = '' then Continue;
for k := 0 to High(URIs) do
if SameText(URIs[k], U) then begin U := ''; Break; end;
if U = '' then Continue;
SetLength(URIs, Length(URIs) + 1);
URIs[High(URIs)] := U;
end;
SetPlutoProbeURIs(URIs);
end;
procedure TRadioController.OnDeviceFound(const Dev: THPSDRDevice);
// Сетевой/scan-поток: строим строку для списка → pending → маршалинг в
// ApplyDeviceFound. Строка зависит от типа бэкенда (HPSDR vs Pluto).