Files
ewsdr/PlutoBackend.pas
T
ew8bakandClaude Opus 4.8 a988849d3e 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>
2026-06-16 18:55:20 +03:00

556 lines
19 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.