feat: Pluto/AD936x SDR backend (discovery, RX, VHF band plan)

Add ADALM-Pluto / AD9361 support as a second hardware backend alongside
openHPSDR, sharing the WDSP DSP pipeline and the existing controller API
(UI stays decoupled from logic).

- RadioBackend.pas: abstract TRadioBackend + TBackendCaps + TRadioDevice
  (Kind/URI/Serial). THPSDRNetwork now derives from it (state via virtual
  getters); TRadioController.FNetwork is the base type.
- IIOBindings.pas: dynamic libiio loader (runs without libiio present).
- PlutoBackend.pas: scan/probe-by-URI, connect, LO/rate/bandwidth/gain
  control, RX streaming thread (int16->24bit BE -> OnDDCIQ), Q conjugated
  to match WDSP IQ convention. Verified on LibreSDR (AD9361) over network.
- Unified discovery: TDiscoverThread scans both backends; network Plutos
  found via direct ProbeURI (no mDNS needed). ConnectDevice dispatches by
  Dev.Kind (EnsureBackend swaps backend, preserving callbacks).
- DeviceStore/DeviceForm: persist Kind/URI/Serial; save Pluto via
  AddSavedPluto so saved/autostart devices reconnect across restarts.
- VHF/UHF band plan (BoardUtils, kind-aware): 6m..ADS-B for Pluto; fixes
  HF clamps (band detect, mouse-wheel 60 MHz cap, freq-display max).
- SampleRateOverlay: configurable presets (Pluto 576k..5760k, >520 ksps),
  auto-width to fit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 18:55:20 +03:00
co-authored by Claude Opus 4.8
parent 0d82d1d8e6
commit a988849d3e
13 changed files with 1940 additions and 164 deletions
+145 -22
View File
@@ -48,7 +48,7 @@ interface
uses
Classes, SysUtils, Math,
HPSDRProtocol, HPSDRNetwork,
HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
WDSPEngine, AudioOutput, AudioInput,
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore;
@@ -145,7 +145,9 @@ type
FWaterfallBufCount: Integer;
// ---- Движки (владение: см. шапку юнита) ----
FNetwork: THPSDRNetwork;
// FNetwork — абстрактный бэкенд железа (TRadioBackend): конкретный класс
// (THPSDRNetwork / TPlutoBackend позже) выбирается при подключении.
FNetwork: TRadioBackend;
FDSPEngine: TWDSPEngine;
FAudioOut: TAudioOutput;
FAudioIn: TAudioInput;
@@ -272,6 +274,8 @@ type
// через Invoke; Synchronize блокирующий — устройства приходят по одному).
FPendingFoundDev: THPSDRDevice;
FPendingFoundEntry: string;
// URI для probe сетевых Pluto при discovery (см. SetPlutoProbeURIs).
FPlutoProbeURIs: array of string;
constructor Create;
destructor Destroy; override;
@@ -447,6 +451,17 @@ type
function ConnectAndRun(const Dev: THPSDRDevice; SpectrumWidth: Integer): Boolean; // ConnectDevice + band/xvtr/vfo restore + go-live
procedure StopAndDisconnect; // graceful Run=0 + персист настроек + Disconnect (headless)
procedure Discover;
// Гарантирует, что FNetwork — бэкенд нужного типа (HPSDR/Pluto): при
// несовпадении пересоздаёт его, перенося колбэки данных. Вызывается из
// ConnectDevice по Dev.Kind. Discovery использует транзиентные экземпляры.
procedure EnsureBackend(Kind: TBackendKind);
// Возможности текущего бэкенда (для гейтинга UI / пресетов sample rate).
function BackendCaps: TBackendCaps;
// True если активный бэкенд — Pluto (VHF/UHF band-план вместо HF).
function IsPluto: Boolean;
// Список URI для probe сетевых Pluto (mDNS недоступен) — задаёт фронтенд
// перед Discover (сохранённые Pluto + ручной ввод IP).
procedure SetPlutoProbeURIs(const URIs: array of string);
// ---- События наружу ----
property OnInvoke: TUIInvokeEvent read FOnInvoke write FOnInvoke;
@@ -1074,8 +1089,8 @@ begin
// Bandstack: VFO A и B независимы и могут стоять на разных бэндах. В кэш
// ЭТОГО бэнда пишем только ту частоту VFO, что реально в нём — иначе чужая
// частота (напр. VFO A на 40м при сохранении 30м) загрязнит memory бэнда.
if FreqToBandIdx(FVfoA) <> FCurrentBand then B.VfoA := FBandCache[FCurrentBand].VfoA;
if FreqToBandIdx(FVfoB) <> FCurrentBand then B.VfoB := FBandCache[FCurrentBand].VfoB;
if FreqToBandIdx(FVfoA, IsPluto) <> FCurrentBand then B.VfoA := FBandCache[FCurrentBand].VfoA;
if FreqToBandIdx(FVfoB, IsPluto) <> FCurrentBand then B.VfoB := FBandCache[FCurrentBand].VfoB;
FBandCache[FCurrentBand] := B;
FSettings.SaveBand(FDevMAC, FCurrentBand, FBandCache[FCurrentBand]);
end;
@@ -1220,6 +1235,14 @@ begin
B := FBandCache[BandIdx];
FCurrentBand := BandIdx;
// Pluto: кэш диапазона может нести HF-частоту (дефолты Settings). Если VfoA не
// попадает в VHF-слот — берём частоту по умолчанию из плана.
if IsPluto and (FreqToBandIdx(B.VfoA, True) <> BandIdx) then
begin
B.VfoA := BandDefaultFreq(BandIdx, True);
B.VfoB := B.VfoA;
end;
ApplyBandDSP(B);
// --- VFO / drive (только RestoreBand — bandstack-путь VFO не трогает) ---
@@ -1303,7 +1326,7 @@ begin
// FreqToBandIdx вернёт -1 для VHF/UHF).
if FCurrentXvtr < 0 then
begin
BandIdx := FreqToBandIdx(FVfoA);
BandIdx := FreqToBandIdx(FVfoA, IsPluto);
if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
begin
FCurrentBand := BandIdx;
@@ -1333,7 +1356,7 @@ begin
FVfoA := Hz;
if FCurrentXvtr < 0 then
begin
BandIdx := FreqToBandIdx(FVfoA);
BandIdx := FreqToBandIdx(FVfoA, IsPluto);
if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
begin
FCurrentBand := BandIdx;
@@ -1369,7 +1392,7 @@ begin
ApplyTuneCore(FVfoB);
if FCurrentXvtr < 0 then
begin
BandIdx := FreqToBandIdx(FVfoB);
BandIdx := FreqToBandIdx(FVfoB, IsPluto);
if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
begin
FCurrentBand := BandIdx;
@@ -1403,7 +1426,7 @@ begin
// --- Bandstack: активируемый VFO на другом ham-бэнде → восстановить бэнд ---
if FCurrentXvtr < 0 then
begin
NewBand := FreqToBandIdx(ActiveFreq);
NewBand := FreqToBandIdx(ActiveFreq, IsPluto);
if (NewBand >= 0) and (NewBand <> FCurrentBand) then
begin
SaveCurrentBand; // сохранить покидаемый бэнд
@@ -1482,7 +1505,7 @@ begin
// при подсвеченной кнопке нового бэнда (рассинхрон).
if FCurrentXvtr < 0 then
begin
NewBand := FreqToBandIdx(ActiveFreq);
NewBand := FreqToBandIdx(ActiveFreq, IsPluto);
if (NewBand >= 0) and (NewBand <> FCurrentBand) then
begin
SaveCurrentBand;
@@ -2303,6 +2326,8 @@ function TRadioController.ConnectDevice(const Dev: THPSDRDevice): Boolean;
var
i: Integer;
begin
// Подбираем бэкенд под тип устройства (HPSDR/Pluto) до подключения.
EnsureBackend(Dev.Kind);
Result := FNetwork.Connect(Dev);
if not Result then Exit;
@@ -2341,6 +2366,14 @@ begin
FSampleRate := FLoadedGlobal.SampleRate;
FSpanHz := FSampleRate;
end;
// Pluto: HPSDR-наследие rate (48..1536k, часто <520k) невалидно. Клампим к
// дефолту бэкенда, если ниже минимума (overlay покажет валидный набор).
with FNetwork.Caps do
if (Kind = bkPluto) and (FSampleRate < MinSampleRate) then
begin
FSampleRate := 1536000;
FSpanHz := FSampleRate;
end;
FNRMode := EnsureRange(FLoadedGlobal.NRMode, 0, 4);
FNBMode := EnsureRange(FLoadedGlobal.NBMode, 0, 2);
FSNB := FLoadedGlobal.SNBEnabled;
@@ -2420,6 +2453,16 @@ begin
// Перестройка диапазона — команда несёт финальную перестройку по активному VFO.
RestoreBand(FCurrentBand);
// Pluto/VHF: HF-частоты вне диапазона. Если восстановленный VFO ниже минимума
// бэкенда — ставим дефолт VHF (временно 100 МГц, пока нет VHF band-плана).
with FNetwork.Caps do
if (Kind = bkPluto) and (FVfoA < MinFreqHz) then
begin
FVfoB := 100000000;
FCenterFreq := 100000000;
SetVfoA(100000000); // перестроит дисплей + LO
end;
// Восстанавливаем XVTR-режим, если он был активен при выходе. FCurrentBand уже
// загружен (HF-диапазон до XVTR) — ActivateXvtr только выставит FVfoA/FCurrentXvtr.
if (FLoadedGlobal.LastXvtr >= 0) and (FLoadedGlobal.LastXvtr < CFG_XVTR_COUNT)
@@ -2482,25 +2525,103 @@ begin
end;
procedure TDiscoverThread.Execute;
// Сканируем ОБА бэкенда транзиентными экземплярами (независимо от FNetwork —
// он может быть уже подключён/другого типа). Найденные сигналятся колбэком
// OnDeviceFound во время Discover() и копятся в store.
var
Devs: THPSDRDeviceArray;
Found: Integer;
HP: THPSDRNetwork;
Pluto: TPlutoBackend;
PDev: TRadioDevice;
i: Integer;
begin
Devs := FCtrl.FNetwork.Discover(2000);
// Найденные устройства сигналятся колбэком OnDeviceFound во время Discover().
if Length(Devs) = 0 then
Found := 0;
// openHPSDR (UDP broadcast)
HP := THPSDRNetwork.Create;
try
HP.OnDeviceFound := FCtrl.OnDeviceFound;
Found := Found + Length(HP.Discover(2000));
finally
HP.Free;
end;
// Pluto (libiio scan + прямой probe сетевых URI) — если libiio доступна.
if IIOLoad then
begin
Pluto := TPlutoBackend.Create;
try
Pluto.OnDeviceFound := FCtrl.OnDeviceFound;
// USB scan
Found := Found + Length(Pluto.Discover(1500));
// Прямой probe сетевых адресов (mDNS недоступен) — из списка фронтенда.
for i := 0 to High(FCtrl.FPlutoProbeURIs) do
if Pluto.ProbeURI(FCtrl.FPlutoProbeURIs[i], PDev) then
Inc(Found);
finally
Pluto.Free;
end;
end;
if Found = 0 then
FCtrl.Invoke(FCtrl.DiscoverFinishedNone);
end;
procedure TRadioController.OnDeviceFound(const Dev: THPSDRDevice);
// Сетевой поток: строим строку для списка → pending → маршалинг в ApplyDeviceFound.
procedure TRadioController.EnsureBackend(Kind: TBackendKind);
// Пересоздаёт FNetwork нужного класса, перенося колбэки данных. No-op если тип
// уже верный. Старый бэкенд отключается и освобождается.
var
BoardName: string;
NewB: TRadioBackend;
begin
BoardName := BoardTypeName(Dev.BoardType);
FPendingFoundDev := Dev;
FPendingFoundEntry := Format('%s %s FW:%d DDC:%d',
[Dev.IPAddress, BoardName,
Dev.FirmwareVersion, Dev.NumDDCs]);
if (FNetwork <> nil) and (FNetwork.Caps.Kind = Kind) then Exit;
case Kind of
bkPluto: NewB := TPlutoBackend.Create;
else NewB := THPSDRNetwork.Create;
end;
if FNetwork <> nil then
begin
if FNetwork.Connected then FNetwork.Disconnect;
NewB.OnDeviceFound := FNetwork.OnDeviceFound;
NewB.OnDDCIQ := FNetwork.OnDDCIQ;
NewB.OnMicPacket := FNetwork.OnMicPacket;
NewB.OnHPStatus := FNetwork.OnHPStatus;
NewB.OnWideband := FNetwork.OnWideband;
FreeAndNil(FNetwork);
end;
FNetwork := NewB;
end;
function TRadioController.BackendCaps: TBackendCaps;
begin
if FNetwork <> nil then Result := FNetwork.Caps
else FillChar(Result, SizeOf(Result), 0);
end;
function TRadioController.IsPluto: Boolean;
begin
Result := (FNetwork <> nil) and (FNetwork.Caps.Kind = bkPluto);
end;
procedure TRadioController.SetPlutoProbeURIs(const URIs: array of string);
var i: Integer;
begin
SetLength(FPlutoProbeURIs, Length(URIs));
for i := 0 to High(URIs) do FPlutoProbeURIs[i] := URIs[i];
end;
procedure TRadioController.OnDeviceFound(const Dev: THPSDRDevice);
// Сетевой/scan-поток: строим строку для списка → pending → маршалинг в
// ApplyDeviceFound. Строка зависит от типа бэкенда (HPSDR vs Pluto).
begin
FPendingFoundDev := Dev;
if Dev.Kind = bkPluto then
FPendingFoundEntry := Format('PlutoSDR %s', [Dev.URI])
else
FPendingFoundEntry := Format('%s %s FW:%d DDC:%d',
[Dev.IPAddress, BoardTypeName(Dev.BoardType),
Dev.FirmwareVersion, Dev.NumDDCs]);
Invoke(ApplyDeviceFound);
end;
@@ -2509,7 +2630,9 @@ procedure TRadioController.ApplyDeviceFound;
begin
FDiscoverNoneFound := False;
FDeviceStore.AddDiscovered(FPendingFoundDev.IPAddress, FPendingFoundEntry,
FPendingFoundDev.BoardType, FPendingFoundDev.MAC);
FPendingFoundDev.BoardType, FPendingFoundDev.MAC,
FPendingFoundDev.Kind, FPendingFoundDev.URI,
FPendingFoundDev.Serial);
Changed(rfDeviceList);
end;