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
+130 -10
View File
@@ -42,6 +42,7 @@ uses
ChannelStore, ChannelsForm,
PowerInhibit,
DeviceStore,
RadioBackend, PlutoBackend,
RadioController, ChannelController;
const
@@ -324,6 +325,9 @@ type
procedure OnWidebandCB(ADCIndex: Integer; const Samples: array of SmallInt;
Count: Integer);
procedure OnWDSPOpenDone(Success: Boolean);
function ResolveDevice(const IP: string): THPSDRDevice;
procedure RelabelBands; // подписи band-кнопок под план бэкенда
procedure ApplyBackendCapsToUI; // sample-rate пресеты + гейтинг полей по Caps
procedure DoConnectDevice(const Dev: THPSDRDevice);
// Старт с уже выбранным устройством (preload rate + WDSP open + connect).
// Переиспользуется десктоп-диалогом и web-оверлеем — без модального диалога.
@@ -1313,6 +1317,8 @@ begin
// возвращаем живое значение в persist-буфер, чтобы blob нёс актуальную тему.
FController.FLoadedGlobal.LightTheme := FLightTheme;
FCATAdapter.ApplySettings(FController.FLoadedGlobal);
RelabelBands; // HF ↔ VHF/UHF подписи под тип бэкенда
ApplyBackendCapsToUI; // пресеты sample rate + гейтинг полей
RebuildXvtrButtons;
PushXvtrToWeb;
TrkDrive.Position := FController.FDrivePercent; // слайдер ← drive%
@@ -2664,10 +2670,12 @@ const
MARGIN_Y = 4;
begin
if (FSampleRateOverlay = nil) or (PbSpectrum = nil) then Exit;
// Ширина = доступная ширина спектра справа от MARGIN_X (контент сам клампится
// к этому пределу). Раньше фиксировалось 320px → 8 Pluto-кнопок не влезали.
FSampleRateOverlay.SetBounds(
MARGIN_X,
MARGIN_Y,
FSampleRateOverlay.Width,
Max(320, PbSpectrum.Width - MARGIN_X * 2),
FSampleRateOverlay.Height);
end;
@@ -3296,7 +3304,40 @@ end;
procedure TMainForm.BtnDiscoverFromDialog(Sender: TObject);
// Запускается когда пользователь нажимает DISCOVER внутри диалога.
// Контроллер чистит список и стартует поток; UI слушает rfDeviceList.
// Сетевые Pluto (без mDNS) не находятся scan'ом — собираем список URI для
// прямого probe: сохранённые Pluto + ручной ввод IP в поле диалога.
var
URIs: array of string;
i: Integer;
ip: string;
procedure AddURI(const U: string);
var k: Integer;
begin
if U = '' then Exit;
for k := 0 to High(URIs) do if SameText(URIs[k], U) then Exit;
SetLength(URIs, Length(URIs) + 1);
URIs[High(URIs)] := U;
end;
begin
SetLength(URIs, 0);
for i := 0 to FController.FDeviceStore.SavedCount - 1 do
if FController.FDeviceStore.Saved(i).Kind = bkPluto then
AddURI(FController.FDeviceStore.Saved(i).URI);
if Assigned(FDeviceDialog) then
begin
ip := FDeviceDialog.ManualIP;
if ip <> '' then
begin
if (Pos('ip:', ip) = 1) or (Pos('usb:', ip) = 1) or (Pos('local:', ip) = 1) then
AddURI(ip)
else
AddURI('ip:' + ip);
end;
end;
FController.SetPlutoProbeURIs(URIs);
SetStatusText(2, 'Discovering...');
FController.Discover;
end;
@@ -3352,13 +3393,52 @@ begin
end;
end;
// Создаём запись устройства из IP + BoardType (из диалога/autostart) и стартуем.
FillChar(Dev, SizeOf(Dev), 0);
Dev.IPAddress := FPendingIP;
Dev.BoardType := FPendingBoardType;
// Создаём запись устройства. Если это известный (discovered/saved) Pluto —
// восстанавливаем Kind/URI/Serial/MAC из store по IP; иначе HPSDR по IP.
Dev := ResolveDevice(FPendingIP);
if Dev.Kind = bkHPSDR then Dev.BoardType := FPendingBoardType;
StartWithDevice(Dev);
end;
function TMainForm.ResolveDevice(const IP: string): THPSDRDevice;
// По IP/URI ищет устройство среди discovered, затем saved, чтобы восстановить
// Kind/URI/Serial/MAC (нужно для Pluto). Если не найдено — простой HPSDR по IP.
var
i: Integer;
dd: TDiscoveredDevice;
sd: TSavedDevice;
begin
// Result — managed-поля (строки) при входе nil, FillChar безопасен.
FillChar(Result, SizeOf(Result), 0);
Result.Kind := bkHPSDR;
Result.IPAddress := IP;
i := FController.FDeviceStore.FindDiscoveredByIP(IP);
if i >= 0 then
begin
dd := FController.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 FController.FDeviceStore.SavedCount - 1 do
begin
sd := FController.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 TMainForm.DoStopAndDisconnect;
// STOP-teardown, общий для десктоп-STOP и web-disconnect. Движок (graceful Run=0,
// сброс TX/телеметрии) + персист настроек + Disconnect — в контроллере
@@ -3435,6 +3515,33 @@ begin
DoConnectDevice(FPendingDev);
end;
procedure TMainForm.RelabelBands;
// Переписывает подписи band-кнопок под план активного бэкенда (HF ↔ VHF/UHF).
var i: Integer; pl: Boolean;
begin
pl := FController.IsPluto;
for i := 0 to BAND_COUNT - 1 do
if BtnBand[i] <> nil then
BtnBand[i].Caption := BandName(i, pl);
end;
procedure TMainForm.ApplyBackendCapsToUI;
// Пресеты sample-rate оверлея из Caps (Pluto >560k) + (TODO) гейтинг полей,
// которых нет у бэкенда (PA/SWR/Alex/Wideband).
var
Caps: TBackendCaps;
begin
Caps := FController.BackendCaps;
if Assigned(FSampleRateOverlay) then
begin
if (Caps.SampleRateMode = srmContinuous) and (Length(Caps.RatePresets) > 0) then
FSampleRateOverlay.SetRatePresets(Caps.RatePresets)
else
FSampleRateOverlay.SetRatePresetsDefault; // HPSDR 48k..1536k
FSampleRateOverlay.SetCurrentRate(FController.FSampleRate);
end;
end;
procedure TMainForm.DoConnectDevice(const Dev: THPSDRDevice);
begin
BtnStartStop.Enabled := True;
@@ -3798,7 +3905,7 @@ begin
if FController.FCurrentXvtr < 0 then
begin
BandIdx := FreqToBandIdx(ClickFreq);
BandIdx := FreqToBandIdx(ClickFreq, FController.IsPluto);
if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
begin
if FController.FDevConnected then
@@ -3834,7 +3941,7 @@ begin
end;
if FController.FCurrentXvtr < 0 then
begin
BandIdx := FreqToBandIdx(FController.FVfoB);
BandIdx := FreqToBandIdx(FController.FVfoB, FController.IsPluto);
if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
begin
if FController.FCurrentBand >= 0 then
@@ -5114,10 +5221,14 @@ begin
NewFreq := Base; // снэп к нижней границе
end;
// В XVTR-режиме HF-клэмп не применяем: ApplyVfoA сам ограничит по FreqBegin/FreqEnd
// Клэмп диапазона перестройки. XVTR — ApplyVfoA сам ограничит по FreqBegin/End.
// Pluto — по диапазону бэкенда (Caps). Иначе HF (30 кГц..60 МГц).
if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and
FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then
NewFreq := Max(0, NewFreq)
else if FController.IsPluto then
NewFreq := Max(Round(FController.BackendCaps.MinFreqHz),
Min(Round(FController.BackendCaps.MaxFreqHz), NewFreq))
else
NewFreq := Max(30000, Min(60000000, NewFreq));
if FController.FActiveVfo = 0 then
@@ -5329,8 +5440,17 @@ begin
FController.FLoadedGlobal.FreqMhzDigits := Digits; // зеркало в persist-буфер контроллера
FreqDispA.MinMhzDigits := Digits;
FreqDispB.MinMhzDigits := Digits;
FreqDispA.MaxFreq := MaxFreqs[Digits];
FreqDispB.MaxFreq := MaxFreqs[Digits];
// Pluto/VHF/UHF: верхняя граница из Caps (до 6 ГГц), иначе HF по числу цифр.
if FController.IsPluto then
begin
FreqDispA.MaxFreq := Round(FController.BackendCaps.MaxFreqHz);
FreqDispB.MaxFreq := Round(FController.BackendCaps.MaxFreqHz);
end
else
begin
FreqDispA.MaxFreq := MaxFreqs[Digits];
FreqDispB.MaxFreq := MaxFreqs[Digits];
end;
if Assigned(FWebServer) then
FWebServer.FreqMhzDigits := Digits;
LayoutTopVfoBlock;