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
+555
View File
@@ -0,0 +1,555 @@
unit PlutoBackend;
{
Бэкенд ADALM-PLUTO (libiio / AD936x), реализует TRadioBackend.
Фаза 1 (текущая): discovery через iio_scan_context + Caps + открытие/закрытие
контекста (Connect/Disconnect) + геттеры состояния. RX/TX-потоки и реальная
подача IQ — фазы 2/4 (методы-заглушки сохраняют состояние).
Карта устройств/каналов Pluto (для фаз 24):
ad9361-phy:
RX LO — channel 'altvoltage0' (output), attr 'frequency'
TX LO — channel 'altvoltage1' (output), attr 'frequency'
RX rate/bw— channel 'voltage0' (input), attr 'sampling_frequency','rf_bandwidth'
RX gain — channel 'voltage0' (input), attr 'hardwaregain','gain_control_mode'
TX atten — channel 'voltage0' (output), attr 'hardwaregain' (отриц. дБ)
cf-ad9361-lpc — RX-стрим: 'voltage0'(I),'voltage1'(Q) input
cf-ad9361-dds-core-lpc — TX-стрим: 'voltage0'(I),'voltage1'(Q) output
}
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
interface
uses
Classes, SysUtils, ctypes,
RadioBackend, IIOBindings, HPSDRProtocol, Settings;
type
{ TPlutoBackend }
TPlutoBackend = class(TRadioBackend)
private
FCtx: Piio_context;
FPhy: Piio_device; // ad9361-phy (control)
FRxDev: Piio_device; // cf-ad9361-lpc (RX stream)
FTxDev: Piio_device; // cf-ad9361-dds-core-lpc (TX stream)
FConnected: Boolean;
FRunning: Boolean;
FDevice: TRadioDevice;
FLastError: string;
// Текущее состояние (применяется к железу в фазах 2/4)
FRXFreq: Double;
FTXFreq: Double;
FSampleRate: Integer;
FTransmitting: Boolean;
FGainMode: string; // gain_control_mode (manual/slow_attack/...)
FGainDb: Integer; // hardwaregain при ручном режиме
// RX-стрим (cf-ad9361-lpc): I/Q-каналы, буфер, поток refill.
FRxBuf: Piio_buffer;
FRxChI: Piio_channel;
FRxChQ: Piio_channel;
FRxThread: TThread;
FStreaming: Boolean;
FRxSeq: LongWord;
// Низкоуровневые помощники применения атрибутов к ad9361-phy
procedure ApplyRxLO(Hz: Double);
procedure ApplyTxLO(Hz: Double);
procedure ApplySampleRate(Hz: Integer);
procedure ApplyGain;
procedure StartRX;
procedure StopRX;
protected
function GetConnected: Boolean; override;
function GetRunning: Boolean; override;
function GetDevice: TRadioDevice; override;
function GetLastError: string; override;
public
constructor Create;
destructor Destroy; override;
function Caps: TBackendCaps; override;
function Discover(TimeoutMs: Integer = 3000): TRadioDeviceArray; override;
function Connect(const Dev: TRadioDevice): Boolean; override;
procedure Disconnect; override;
// Прямой probe конкретного URI (например 'ip:172.16.2.199' или 'usb:1.5.5').
// Открывает контекст, проверяет наличие ad9361-phy, читает hw_serial/hw_model,
// заполняет Dev и (если задан) дёргает OnDeviceFound. True — это AD936x-SDR.
// Нужен для сетевых устройств (без mDNS scan их иначе не найти).
function ProbeURI(const AURI: string; out Dev: TRadioDevice): Boolean;
procedure ConfigureDDCs(NumDDCs: Byte; SampleRate: Word; ADCSource: Byte = 0;
DitherEnabled: Boolean = True;
RandomEnabled: Boolean = True); override;
procedure SetRunAndFreq(Run: Boolean; DDC0FreqHz, DUCFreqHz: Double;
DriveLevel: Byte = 100); override;
procedure UpdateState(RXFreqHz, TXFreqHz: Double; DriveLevel: Byte;
Transmitting, PAEnabled, AlexEnabled: Boolean); override;
procedure SendFullHP; override;
procedure SendPTT(Active: Boolean); override;
end;
// Стабильный 6-байтовый ключ (synthetic MAC) из serial/URI — для per-device
// настроек (Settings ключует по MAC). FNV-1a свёртка строки в 6 байт.
function PlutoSyntheticMAC(const S: string): TBytes;
implementation
const
PLUTO_MIN_SR = 521000; // нижний предел AD936x sampling_frequency
PLUTO_MAX_SR = 61440000;
PLUTO_MIN_HZ = 46875000; // AD9361 нативно (LibreSDR/AntSDR); AD9363 — 325 МГц
PLUTO_MAX_HZ = 6000000000.0;
PLUTO_DEF_SR = 1536000; // дефолтный rate если контроллер задал невалидный
RX_BUF_SAMPLES = 8192; // IQ-пар за один refill (~5.3 мс @ 1.536 MS/s)
PAIRS_PER_PKT = 238; // как DDC IQ HPSDR (238*6 = 1428 байт IQData)
type
// RX-поток: блокирующий refill libiio → конверсия int16→24bit BE → OnDDCIQ.
// Работает с private-полями TPlutoBackend (одна единица компиляции).
TPlutoRXThread = class(TThread)
private
FBk: TPlutoBackend;
protected
procedure Execute; override;
public
constructor Create(ABk: TPlutoBackend);
end;
constructor TPlutoRXThread.Create(ABk: TPlutoBackend);
begin
FBk := ABk;
FreeOnTerminate := False;
inherited Create(False);
end;
procedure TPlutoRXThread.Execute;
var
n, step: ptrint;
pFirst, pEnd, p: PByte;
pkt: TDDCIQPacket;
outc: Integer;
vi, vq: LongInt;
u: LongWord;
procedure FlushPkt;
begin
Inc(FBk.FRxSeq);
pkt.Seq[0] := (FBk.FRxSeq shr 24) and $FF;
pkt.Seq[1] := (FBk.FRxSeq shr 16) and $FF;
pkt.Seq[2] := (FBk.FRxSeq shr 8) and $FF;
pkt.Seq[3] := FBk.FRxSeq and $FF;
pkt.SamplesPerFrame[0] := (outc shr 8) and $FF;
pkt.SamplesPerFrame[1] := outc and $FF;
if Assigned(FBk.FOnDDCIQ) then FBk.FOnDDCIQ(0, pkt);
outc := 0;
end;
begin
FillChar(pkt, SizeOf(pkt), 0);
pkt.BitsPerSample[1] := 24;
outc := 0;
while not Terminated do
begin
if FBk.FRxBuf = nil then begin Sleep(5); Continue; end;
n := iio_buffer_refill(FBk.FRxBuf);
if Terminated then Break;
if n <= 0 then begin Sleep(2); Continue; end;
step := iio_buffer_step(FBk.FRxBuf);
pFirst := iio_buffer_first(FBk.FRxBuf, FBk.FRxChI);
pEnd := iio_buffer_end(FBk.FRxBuf);
p := pFirst;
while PtrUInt(p) < PtrUInt(pEnd) do
begin
// I в смещении 0, Q в смещении 2 (два int16-канала, step=4).
// Q инвертируем (сопряжение): IQ-конвенция AD936x зеркальна ожидаемой WDSP,
// иначе спектр перевёрнут (USB↔LSB, перестройка вправо идёт влево).
vi := LongInt(PSmallInt(p)^) * 4096; // 12-bit (±2048) → 24-bit
vq := -LongInt(PSmallInt(p + 2)^) * 4096;
if vi > 8388607 then vi := 8388607 else if vi < -8388608 then vi := -8388608;
if vq > 8388607 then vq := 8388607 else if vq < -8388608 then vq := -8388608;
u := LongWord(vi) and $FFFFFF;
pkt.IQData[outc*6+0] := (u shr 16) and $FF;
pkt.IQData[outc*6+1] := (u shr 8) and $FF;
pkt.IQData[outc*6+2] := u and $FF;
u := LongWord(vq) and $FFFFFF;
pkt.IQData[outc*6+3] := (u shr 16) and $FF;
pkt.IQData[outc*6+4] := (u shr 8) and $FF;
pkt.IQData[outc*6+5] := u and $FF;
Inc(outc);
if outc >= PAIRS_PER_PKT then FlushPkt;
Inc(p, step);
end;
if outc > 0 then FlushPkt; // хвост блока
end;
end;
function PlutoSyntheticMAC(const S: string): TBytes;
var
H: QWord;
I: Integer;
begin
// FNV-1a 64-bit, затем берём младшие 6 байт; первый байт делаем locally-
// administered/unicast (bit1=1, bit0=0), чтобы не коллизировать с реальными MAC.
H := QWord(14695981039346656037);
for I := 1 to Length(S) do
begin
H := H xor QWord(Ord(S[I]));
H := H * QWord(1099511628211);
end;
SetLength(Result, 6);
Result[0] := (Byte(H shr 40) and $FC) or $02;
Result[1] := Byte(H shr 32);
Result[2] := Byte(H shr 24);
Result[3] := Byte(H shr 16);
Result[4] := Byte(H shr 8);
Result[5] := Byte(H);
end;
{ TPlutoBackend }
constructor TPlutoBackend.Create;
begin
inherited Create;
FCtx := nil; FPhy := nil; FRxDev := nil; FTxDev := nil;
FConnected := False;
FRunning := False;
FSampleRate := 1536000;
FGainMode := 'slow_attack'; // аппаратный AGC по умолчанию
FGainDb := 40;
FRxBuf := nil; FRxChI := nil; FRxChQ := nil; FRxThread := nil;
FStreaming := False; FRxSeq := 0;
FillChar(FDevice, SizeOf(FDevice), 0);
end;
destructor TPlutoBackend.Destroy;
begin
Disconnect;
inherited Destroy;
end;
function TPlutoBackend.GetConnected: Boolean; begin Result := FConnected; end;
function TPlutoBackend.GetRunning: Boolean; begin Result := FRunning; end;
function TPlutoBackend.GetDevice: TRadioDevice; begin Result := FDevice; end;
function TPlutoBackend.GetLastError: string; begin Result := FLastError; end;
function TPlutoBackend.Caps: TBackendCaps;
begin
FillChar(Result, SizeOf(Result), 0);
Result.Kind := bkPluto;
Result.HasTX := True;
Result.HasPA := False; // нет PA → нет fwd/SWR/supply
Result.HasAlex := False;
Result.HasWideband := False;
Result.HasDitherRandom := False;
Result.HasHWMic := False;
Result.HasPLLStatus := False;
Result.HasHWGain := True; // manual gain / hw-AGC
Result.HasRFBandwidth := True;
Result.HasFullDuplex := True;
Result.MinSampleRate := PLUTO_MIN_SR;
Result.MaxSampleRate := PLUTO_MAX_SR;
Result.SampleRateMode := srmContinuous;
// Пресеты для SampleRateOverlay (все > 520 ksps, минимум AD936x).
SetLength(Result.RatePresets, 8);
Result.RatePresets[0] := 576000;
Result.RatePresets[1] := 768000;
Result.RatePresets[2] := 960000;
Result.RatePresets[3] := 1536000;
Result.RatePresets[4] := 2304000;
Result.RatePresets[5] := 3072000;
Result.RatePresets[6] := 3840000;
Result.RatePresets[7] := 5760000;
Result.MinFreqHz := PLUTO_MIN_HZ;
Result.MaxFreqHz := PLUTO_MAX_HZ;
end;
function TPlutoBackend.ProbeURI(const AURI: string; out Dev: TRadioDevice): Boolean;
// Открываем контекст по URI, проверяем что это AD936x-SDR (есть ad9361-phy),
// читаем hw_serial/hw_model. Контекст закрываем (это лишь probe, не Connect).
var
Ctx: Piio_context;
Ser, Model, IPStr: string;
PV: PAnsiChar;
MAC: TBytes;
begin
Result := False;
FillChar(Dev, SizeOf(Dev), 0);
if not IIOLoad then Exit;
Ctx := iio_create_context_from_uri(PAnsiChar(AnsiString(AURI)));
if Ctx = nil then Exit;
try
// Признак нашего железа — наличие управляющего устройства ad9361-phy.
if iio_context_find_device(Ctx, 'ad9361-phy') = nil then Exit;
Ser := '';
PV := iio_context_get_attr_value(Ctx, 'hw_serial');
if PV <> nil then Ser := string(PV);
if Ser = '' then Ser := AURI; // fallback: URI как идентификатор
Model := 'AD936x SDR';
PV := iio_context_get_attr_value(Ctx, 'hw_model');
if PV <> nil then Model := string(PV);
IPStr := AURI;
PV := iio_context_get_attr_value(Ctx, 'ip,ip-addr');
if PV <> nil then IPStr := string(PV);
MAC := PlutoSyntheticMAC(Ser);
Dev.Kind := bkPluto;
Dev.URI := AURI;
Dev.Serial := Ser;
Dev.Model := Model;
Dev.IPAddress := IPStr;
Dev.BoardType := 0;
Dev.Valid := True;
Move(MAC[0], Dev.MAC[0], 6);
Result := True;
finally
iio_context_destroy(Ctx);
end;
if Result and Assigned(FOnDeviceFound) then
FOnDeviceFound(Dev);
end;
function TPlutoBackend.Discover(TimeoutMs: Integer): TRadioDeviceArray;
var
Scan: Piio_scan_context;
Info: PPiio_context_info;
Cnt, I: ptrint;
Item: Piio_context_info;
URI: string;
Dev: TRadioDevice;
begin
SetLength(Result, 0);
if not IIOLoad then Exit;
// Сканируем backend 'usb' — надёжно и без зависимости от avahi/dnssd (её тянет
// backend=nil/'ip'). Для каждого найденного контекста делаем ProbeURI: детект по
// наличию ad9361-phy (а не по строке описания — клоны вроде LibreSDR не содержат
// 'pluto'). Сетевые устройства (ip:) находим отдельным ProbeURI по сохранённым/
// введённым адресам (см. контроллер).
Scan := iio_create_scan_context('usb', 0);
if Scan = nil then Exit;
try
Info := nil;
Cnt := iio_scan_context_get_info_list(Scan, Info);
if Cnt <= 0 then Exit;
try
I := 0;
while I < Cnt do
begin
Item := PPiio_context_info(PByte(Info) + I * SizeOf(Pointer))^;
URI := string(iio_context_info_get_uri(Item));
Inc(I);
if ProbeURI(URI, Dev) then
begin
SetLength(Result, Length(Result) + 1);
Result[High(Result)] := Dev;
end;
end;
finally
iio_context_info_list_free(Info);
end;
finally
iio_scan_context_destroy(Scan);
end;
end;
function TPlutoBackend.Connect(const Dev: TRadioDevice): Boolean;
var
M: TBytes;
begin
Result := False;
if not IIOLoad then begin FLastError := 'libiio not available'; Exit; end;
if FConnected then Disconnect;
FDevice := Dev;
// Гарантируем стабильный synthetic-MAC из serial (ключ per-device настроек),
// даже если запись пришла без MAC (сохранённое Pluto-устройство).
if FDevice.Serial <> '' then
begin
M := PlutoSyntheticMAC(FDevice.Serial);
Move(M[0], FDevice.MAC[0], 6);
end;
FCtx := iio_create_context_from_uri(PAnsiChar(AnsiString(Dev.URI)));
if FCtx = nil then
begin
FLastError := 'Cannot open Pluto context: ' + Dev.URI;
Exit;
end;
FPhy := iio_context_find_device(FCtx, 'ad9361-phy');
FRxDev := iio_context_find_device(FCtx, 'cf-ad9361-lpc');
FTxDev := iio_context_find_device(FCtx, 'cf-ad9361-dds-core-lpc');
if FPhy = nil then
begin
FLastError := 'ad9361-phy not found in context';
iio_context_destroy(FCtx); FCtx := nil;
Exit;
end;
FConnected := True;
// Приводим железо в известное состояние: валидный rate + усиление.
if FSampleRate < PLUTO_MIN_SR then FSampleRate := PLUTO_DEF_SR;
ApplySampleRate(FSampleRate);
ApplyGain;
Result := True;
end;
procedure TPlutoBackend.Disconnect;
begin
StopRX;
if FCtx <> nil then
begin
iio_context_destroy(FCtx);
FCtx := nil;
end;
FPhy := nil; FRxDev := nil; FTxDev := nil;
FConnected := False;
FRunning := False;
end;
// ---- Применение атрибутов к ad9361-phy ----
procedure TPlutoBackend.ApplyRxLO(Hz: Double);
var Ch: Piio_channel;
begin
if (FPhy = nil) or (Hz <= 0) then Exit;
Ch := iio_device_find_channel(FPhy, 'altvoltage0', 1);
if Ch <> nil then
iio_channel_attr_write_longlong(Ch, 'frequency', Round(Hz));
end;
procedure TPlutoBackend.ApplyTxLO(Hz: Double);
var Ch: Piio_channel;
begin
if (FPhy = nil) or (Hz <= 0) then Exit;
Ch := iio_device_find_channel(FPhy, 'altvoltage1', 1);
if Ch <> nil then
iio_channel_attr_write_longlong(Ch, 'frequency', Round(Hz));
end;
procedure TPlutoBackend.ApplySampleRate(Hz: Integer);
var Ch: Piio_channel; WasStreaming: Boolean;
begin
if (FPhy = nil) or (Hz < PLUTO_MIN_SR) then Exit;
WasStreaming := FStreaming;
if WasStreaming then StopRX; // менять rate с открытым буфером нельзя
Ch := iio_device_find_channel(FPhy, 'voltage0', 0);
if Ch <> nil then
begin
iio_channel_attr_write_longlong(Ch, 'sampling_frequency', Hz);
iio_channel_attr_write_longlong(Ch, 'rf_bandwidth', Hz);
end;
if WasStreaming then StartRX;
end;
procedure TPlutoBackend.ApplyGain;
var Ch: Piio_channel;
begin
if FPhy = nil then Exit;
Ch := iio_device_find_channel(FPhy, 'voltage0', 0); // RX gain — input voltage0
if Ch = nil then Exit;
iio_channel_attr_write(Ch, 'gain_control_mode', PAnsiChar(AnsiString(FGainMode)));
if FGainMode = 'manual' then
iio_channel_attr_write_longlong(Ch, 'hardwaregain', FGainDb);
end;
procedure TPlutoBackend.StartRX;
begin
if FStreaming or not FConnected or (FRxDev = nil) then Exit;
FRxChI := iio_device_find_channel(FRxDev, 'voltage0', 0);
FRxChQ := iio_device_find_channel(FRxDev, 'voltage1', 0);
if (FRxChI = nil) or (FRxChQ = nil) then
begin FLastError := 'RX I/Q channels not found'; Exit; end;
iio_channel_enable(FRxChI);
iio_channel_enable(FRxChQ);
if Assigned(iio_device_set_kernel_buffers_count) then
iio_device_set_kernel_buffers_count(FRxDev, 4);
FRxBuf := iio_device_create_buffer(FRxDev, RX_BUF_SAMPLES, False);
if FRxBuf = nil then begin FLastError := 'RX create_buffer failed'; Exit; end;
FStreaming := True;
FRxThread := TPlutoRXThread.Create(Self);
end;
procedure TPlutoBackend.StopRX;
begin
if FRxThread <> nil then
begin
FRxThread.Terminate;
if (FRxBuf <> nil) and Assigned(iio_buffer_cancel) then
iio_buffer_cancel(FRxBuf); // разблокировать refill немедленно
FRxThread.WaitFor;
FreeAndNil(FRxThread);
end;
FStreaming := False;
if FRxBuf <> nil then
begin
iio_buffer_destroy(FRxBuf);
FRxBuf := nil;
end;
end;
// ---- Команды контроллера (фаза 1: сохраняем состояние + базовое применение) ----
procedure TPlutoBackend.ConfigureDDCs(NumDDCs: Byte; SampleRate: Word;
ADCSource: Byte; DitherEnabled, RandomEnabled: Boolean);
begin
// SampleRate приходит в ksps (как у HPSDR ConfigureDDCs). Pluto — в Гц.
// Контроллер может прислать HPSDR-наследие (<520k) — клампим к дефолту.
FSampleRate := Integer(SampleRate) * 1000;
if FSampleRate < PLUTO_MIN_SR then FSampleRate := PLUTO_DEF_SR;
if FConnected then ApplySampleRate(FSampleRate); // перезапустит RX при смене
end;
procedure TPlutoBackend.SetRunAndFreq(Run: Boolean; DDC0FreqHz, DUCFreqHz: Double;
DriveLevel: Byte);
begin
FRXFreq := DDC0FreqHz;
FTXFreq := DUCFreqHz;
FRunning := Run;
if FConnected then
begin
ApplyRxLO(FRXFreq);
ApplyTxLO(FTXFreq);
if Run then StartRX else StopRX; // RX-стрим; TX-стрим — фаза 4
end;
end;
procedure TPlutoBackend.UpdateState(RXFreqHz, TXFreqHz: Double; DriveLevel: Byte;
Transmitting, PAEnabled, AlexEnabled: Boolean);
begin
FRXFreq := RXFreqHz;
FTXFreq := TXFreqHz;
FTransmitting := Transmitting;
if FConnected then
begin
ApplyRxLO(FRXFreq);
ApplyTxLO(FTXFreq);
end;
end;
procedure TPlutoBackend.SendFullHP;
begin
// У Pluto нет HP-пакета: частоты/усиление применяются напрямую в UpdateState/
// SetRunAndFreq. Здесь — no-op (состояние уже применено).
end;
procedure TPlutoBackend.SendPTT(Active: Boolean);
begin
FTransmitting := Active;
// Реальный RX↔TX (вкл/выкл стримов) — фаза 4.
end;
end.