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
+56 -13
View File
@@ -7,12 +7,46 @@ unit BoardUtils;
interface
function BoardTypeName(BoardType: Integer): string;
function FreqToBandIdx(Hz: Double): Integer;
// Индекс диапазона по частоте. Pluto=True → VHF/UHF план, иначе HF (openHPSDR).
function FreqToBandIdx(Hz: Double; Pluto: Boolean = False): Integer;
// Короткое имя диапазона для кнопки (зависит от плана).
function BandName(Idx: Integer; Pluto: Boolean = False): string;
// Частота по умолчанию при входе в диапазон (если нет сохранённой/в диапазоне).
function BandDefaultFreq(Idx: Integer; Pluto: Boolean = False): Double;
implementation
uses SysUtils;
const
BAND_COUNT = 11;
// ---- HF/6m план (openHPSDR) ----
HF_NAMES: array[0..BAND_COUNT-1] of string = (
'160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m');
HF_LO: array[0..BAND_COUNT-1] of Double = (
1800000, 3500000, 5330000, 7000000, 10100000,
14000000, 18068000, 21000000, 24890000, 28000000, 50000000);
HF_HI: array[0..BAND_COUNT-1] of Double = (
2000000, 4000000, 5410000, 7300000, 10150000,
14350000, 18168000, 21450000, 24990000, 29700000, 54000000);
HF_DEF: array[0..BAND_COUNT-1] of Double = (
1840000, 3700000, 5357000, 7100000, 10120000,
14200000, 18130000, 21300000, 24940000, 28400000, 50150000);
// ---- VHF/UHF/SHF план (Pluto / AD936x), 11 слотов ----
VU_NAMES: array[0..BAND_COUNT-1] of string = (
'6m','4m','2m','1.25m','70cm','23cm','13cm','9cm','FM','Air','ADS-B');
VU_LO: array[0..BAND_COUNT-1] of Double = (
50000000, 70000000, 144000000, 222000000, 430000000,
1240000000, 2300000000, 3300000000, 88000000, 118000000, 1087000000);
VU_HI: array[0..BAND_COUNT-1] of Double = (
54000000, 70500000, 148000000, 225000000, 440000000,
1300000000, 2450000000, 3500000000, 108000000, 137000000, 1093000000);
VU_DEF: array[0..BAND_COUNT-1] of Double = (
50150000, 70200000, 145000000, 223500000, 433500000,
1296200000, 2320000000, 3400100000, 100000000, 124000000, 1090000000);
function BoardTypeName(BoardType: Integer): string;
begin
case BoardType of
@@ -27,24 +61,33 @@ begin
end;
end;
function FreqToBandIdx(Hz: Double): Integer;
const
BAND_LO: array[0..10] of Double = (
1800000, 3500000, 5330000, 7000000, 10100000,
14000000, 18068000, 21000000, 24890000, 28000000, 50000000);
BAND_HI: array[0..10] of Double = (
2000000, 4000000, 5410000, 7300000, 10150000,
14350000, 18168000, 21450000, 24990000, 29700000, 54000000);
function FreqToBandIdx(Hz: Double; Pluto: Boolean): Integer;
var
i: Integer;
begin
Result := -1;
for i := 0 to 10 do
if (Hz >= BAND_LO[i]) and (Hz <= BAND_HI[i]) then
if Pluto then
begin
Result := i;
Exit;
for i := 0 to BAND_COUNT - 1 do
if (Hz >= VU_LO[i]) and (Hz <= VU_HI[i]) then Exit(i);
end
else
begin
for i := 0 to BAND_COUNT - 1 do
if (Hz >= HF_LO[i]) and (Hz <= HF_HI[i]) then Exit(i);
end;
end;
function BandName(Idx: Integer; Pluto: Boolean): string;
begin
if (Idx < 0) or (Idx >= BAND_COUNT) then Exit('');
if Pluto then Result := VU_NAMES[Idx] else Result := HF_NAMES[Idx];
end;
function BandDefaultFreq(Idx: Integer; Pluto: Boolean): Double;
begin
if (Idx < 0) or (Idx >= BAND_COUNT) then Exit(0);
if Pluto then Result := VU_DEF[Idx] else Result := HF_DEF[Idx];
end;
end.
+14 -1
View File
@@ -7,7 +7,7 @@ interface
uses
Classes, SysUtils, FlatButton, FlatEdit, FlatListBox, AppTheme, Forms, Controls, Graphics, Dialogs,
StdCtrls, ExtCtrls, ComCtrls,
BoardUtils, PlatformUtils, DeviceStore;
BoardUtils, PlatformUtils, DeviceStore, RadioBackend;
type
// Результат диалога
@@ -86,6 +86,9 @@ type
function GetAutoStartBoardType: Integer;
function GetSavedBoardType(Idx: Integer): Integer;
// Текст поля ручного ввода IP/URI (для probe сетевых Pluto при discovery).
function ManualIP: string;
// Получить/сбросить результат
procedure ClearResult;
// Общее хранилище устройств — назначается владельцем (MainForm/контроллер)
@@ -466,6 +469,11 @@ begin
Exit;
end;
D := FStore.Discovered(Idx);
// Pluto сохраняем с Kind/URI/Serial (иначе после перезапуска резолвится как
// HPSDR по голому IP и не подключается).
if D.Kind = bkPluto then
NewIdx := FStore.AddSavedPluto('PlutoSDR', D.URI, D.Serial)
else
NewIdx := FStore.AddSaved(D.DisplayName, D.IPAddress, D.BoardType);
RefreshSavedList;
LstSaved.ItemIndex := NewIdx;
@@ -518,6 +526,11 @@ begin
ModalResult := mrCancel;
end;
function TDeviceDialog.ManualIP: string;
begin
if EdIP <> nil then Result := Trim(EdIP.Text) else Result := '';
end;
procedure TDeviceDialog.ClearResult;
begin
FResult.Accepted := False;
+41 -3
View File
@@ -11,7 +11,7 @@ unit DeviceStore;
interface
uses
Classes, SysUtils, IniFiles, BoardUtils, PlatformUtils;
Classes, SysUtils, IniFiles, BoardUtils, PlatformUtils, RadioBackend;
const
DEVICE_CFG_NAME = 'hpsdr_devices.ini';
@@ -23,6 +23,9 @@ type
IPAddress: string;
BoardType: Integer;
AutoStart: Boolean; // запускать автоматически при старте
Kind: TBackendKind; // bkHPSDR / bkPluto
URI: string; // Pluto: 'ip:..' / 'usb:..'
Serial: string; // Pluto serial
end;
// Найденное устройство (результат discovery)
@@ -31,6 +34,9 @@ type
DisplayName: string;
BoardType: Integer;
MAC: array[0..5] of Byte; // нужен для preload-rate lookup (LoadDevice по MAC)
Kind: TBackendKind; // bkHPSDR / bkPluto
URI: string; // Pluto: context URI
Serial: string; // Pluto serial
end;
{ TDeviceStore }
@@ -50,6 +56,8 @@ type
function Saved(Idx: Integer): TSavedDevice;
// Добавляет запись (Name пустое → 'HPSDR'); возвращает индекс новой записи.
function AddSaved(const AName, IP: string; BoardType: Integer): Integer;
// Добавляет Pluto-устройство (Kind=bkPluto) с URI/Serial.
function AddSavedPluto(const AName, AURI, ASerial: string): Integer;
procedure RemoveSaved(Idx: Integer);
procedure SetAutoStart(Idx: Integer); // эксклюзивно: только одно autostart
function AutoStartIP: string;
@@ -60,7 +68,9 @@ type
// ---- Найденные (discovery) ----
procedure ClearDiscovered;
procedure AddDiscovered(const IP, DisplayName: string; BoardType: Integer;
const MAC: array of Byte);
const MAC: array of Byte;
Kind: TBackendKind = bkHPSDR;
const URI: string = ''; const Serial: string = '');
function DiscoveredCount: Integer;
function Discovered(Idx: Integer): TDiscoveredDevice;
// IP найденного устройства по IP (для preload-rate lookup и т.п.)
@@ -100,6 +110,9 @@ begin
FSaved[I].IPAddress := Ini.ReadString (Section, 'IP', '');
FSaved[I].BoardType := Ini.ReadInteger(Section, 'BoardType', 0);
FSaved[I].AutoStart := Ini.ReadBool (Section, 'AutoStart', False);
FSaved[I].Kind := TBackendKind(Ini.ReadInteger(Section, 'Kind', 0));
FSaved[I].URI := Ini.ReadString (Section, 'URI', '');
FSaved[I].Serial := Ini.ReadString (Section, 'Serial', '');
Inc(FSavedCount);
end;
finally
@@ -123,6 +136,9 @@ begin
Ini.WriteString (Section, 'IP', FSaved[I].IPAddress);
Ini.WriteInteger(Section, 'BoardType', FSaved[I].BoardType);
Ini.WriteBool (Section, 'AutoStart', FSaved[I].AutoStart);
Ini.WriteInteger(Section, 'Kind', Ord(FSaved[I].Kind));
Ini.WriteString (Section, 'URI', FSaved[I].URI);
Ini.WriteString (Section, 'Serial', FSaved[I].Serial);
end;
finally
Ini.Free;
@@ -155,6 +171,25 @@ begin
FSaved[Result].IPAddress := Trim(IP);
FSaved[Result].BoardType := BoardType;
FSaved[Result].AutoStart := False;
FSaved[Result].Kind := bkHPSDR;
FSaved[Result].URI := '';
FSaved[Result].Serial := '';
SaveSaved;
end;
function TDeviceStore.AddSavedPluto(const AName, AURI, ASerial: string): Integer;
begin
Result := FSavedCount;
Inc(FSavedCount);
SetLength(FSaved, FSavedCount);
if Trim(AName) <> '' then FSaved[Result].Name := Trim(AName)
else FSaved[Result].Name := 'PlutoSDR';
FSaved[Result].IPAddress := Trim(AURI); // в списке показываем URI
FSaved[Result].BoardType := 0;
FSaved[Result].AutoStart := False;
FSaved[Result].Kind := bkPluto;
FSaved[Result].URI := Trim(AURI);
FSaved[Result].Serial := Trim(ASerial);
SaveSaved;
end;
@@ -233,7 +268,7 @@ begin
end;
procedure TDeviceStore.AddDiscovered(const IP, DisplayName: string; BoardType: Integer;
const MAC: array of Byte);
const MAC: array of Byte; Kind: TBackendKind; const URI: string; const Serial: string);
var
Idx, J: Integer;
begin
@@ -243,6 +278,9 @@ begin
FDiscovered[Idx].IPAddress := IP;
FDiscovered[Idx].DisplayName := DisplayName;
FDiscovered[Idx].BoardType := BoardType;
FDiscovered[Idx].Kind := Kind;
FDiscovered[Idx].URI := URI;
FDiscovered[Idx].Serial := Serial;
FillChar(FDiscovered[Idx].MAC, SizeOf(FDiscovered[Idx].MAC), 0);
for J := 0 to High(MAC) do
if J <= 5 then FDiscovered[Idx].MAC[J] := MAC[J];
+102 -63
View File
@@ -19,7 +19,7 @@ uses
{$ELSE}
Sockets, BaseUnix,
{$ENDIF}
HPSDRProtocol, SyncObjs, Settings;
HPSDRProtocol, SyncObjs, Settings, RadioBackend;
const
DUC_TX_QUEUE_SIZE = 256; // power of two, bounded latency on TX underrun/overrun
@@ -70,33 +70,19 @@ const
type
THPSDRDevice = record
IPAddress: string;
Port: Word;
MAC: array[0..5] of Byte;
BoardType: Byte;
ProtocolVersion: Byte;
FirmwareVersion: Byte;
NumDDCs: Byte;
FreqOrPhase: Byte;
EndianModes: Byte;
InUse: Boolean;
Valid: Boolean;
end;
PHPSDRDevice = ^THPSDRDevice;
THPSDRDeviceArray = array of THPSDRDevice;
// Запись устройства и типы колбэков теперь общие (RadioBackend) — здесь
// алиасы для обратной совместимости со старым кодом (THPSDRDevice и т.д.).
THPSDRDevice = RadioBackend.TRadioDevice;
PHPSDRDevice = RadioBackend.PRadioDevice;
THPSDRDeviceArray = RadioBackend.TRadioDeviceArray;
TOnDeviceFound = RadioBackend.TOnDeviceFound;
TOnDDCIQPacket = RadioBackend.TOnDDCIQPacket;
TOnMicPacket = RadioBackend.TOnMicPacket;
TOnHPStatus = RadioBackend.TOnHPStatus;
TOnWidebandFrame = RadioBackend.TOnWidebandFrame;
TOnDeviceFound = procedure(const Dev: THPSDRDevice) of object;
TOnDDCIQPacket = procedure(DDCIndex: Integer;
const Data: TDDCIQPacket) of object;
TOnMicPacket = procedure(const Data: TMicDataPacket) of object;
TOnHPStatus = procedure(const Status: THighPriorityStatus) of object;
TOnWidebandFrame = procedure(ADCIndex: Integer;
const Samples: array of SmallInt;
Count: Integer) of object;
{ THPSDRNetwork }
THPSDRNetwork = class
{ THPSDRNetwork — openHPSDR Ethernet protocol бэкенд }
THPSDRNetwork = class(TRadioBackend)
private
FSocket: TSocket;
FLocalPort: Word;
@@ -125,11 +111,8 @@ type
FKeepaliveThread: TThread;
FDUCIQThread: TThread;
FOnDeviceFound: TOnDeviceFound;
FOnDDCIQ: TOnDDCIQPacket;
FOnMic: TOnMicPacket;
FOnHPStatus: TOnHPStatus;
FOnWideband: TOnWidebandFrame;
// Колбэки данных (FOnDeviceFound/FOnDDCIQ/FOnMic/FOnHPStatus/FOnWideband)
// унаследованы из TRadioBackend (protected) — здесь не дублируем.
FPortDDCSpec: Word;
FPortDUCSpec: Word;
@@ -210,59 +193,61 @@ type
function NextSeq(var S: LongWord): LongWord;
procedure PackSeqBytes(var B: array of Byte; Seq: LongWord);
protected
// Полиморфные геттеры состояния (свойства Connected/Running/Device/LastError/
// DirectIP объявлены в TRadioBackend).
function GetConnected: Boolean; override;
function GetRunning: Boolean; override;
function GetDevice: TRadioDevice; override;
function GetLastError: string; override;
function GetDirectIP: string; override;
procedure SetDirectIP(const V: string); override;
public
constructor Create;
destructor Destroy; override;
function Discover(TimeoutMs: Integer = 3000): THPSDRDeviceArray;
function Connect(const Dev: THPSDRDevice): Boolean;
procedure Disconnect;
function Caps: TBackendCaps; override;
procedure SendGeneralPacket(const Pkt: TGeneralPacket);
function Discover(TimeoutMs: Integer = 3000): THPSDRDeviceArray; override;
function Connect(const Dev: THPSDRDevice): Boolean; override;
procedure Disconnect; override;
procedure SendGeneralPacket(const Pkt: TGeneralPacket); override;
procedure ConfigureWideband(ADCIndex: Integer; Enabled: Boolean;
SamplesPerPacket: Integer = 512; SampleBits: Integer = 16;
UpdateRateMS: Integer = 70; PacketsPerFrame: Integer = 32);
procedure SendDDCSpecific(const Pkt: TDDCSpecificPacket);
UpdateRateMS: Integer = 70; PacketsPerFrame: Integer = 32); override;
procedure SendDDCSpecific(const Pkt: TDDCSpecificPacket); override;
procedure ConfigureDDCs(NumDDCs: Byte; SampleRate: Word; ADCSource: Byte = 0;
DitherEnabled: Boolean = True; RandomEnabled: Boolean = True);
procedure SendDUCSpecific(const Pkt: TDUCSpecificPacket);
procedure SendHighPriority(const Pkt: THighPriorityPacket);
DitherEnabled: Boolean = True; RandomEnabled: Boolean = True); override;
procedure SendDUCSpecific(const Pkt: TDUCSpecificPacket); override;
procedure SendHighPriority(const Pkt: THighPriorityPacket); override;
procedure SetRunAndFreq(Run: Boolean; DDC0FreqHz, DUCFreqHz: Double;
DriveLevel: Byte = 100);
DriveLevel: Byte = 100); override;
procedure UpdateState(RXFreqHz, TXFreqHz: Double; DriveLevel: Byte;
Transmitting, PAEnabled, AlexEnabled: Boolean);
procedure SetStepAtten(dB: Byte);
procedure SetAlexConfig(const A: TAlexSettings);
Transmitting, PAEnabled, AlexEnabled: Boolean); override;
procedure SetStepAtten(dB: Byte); override;
procedure SetAlexConfig(const A: TAlexSettings); override;
// Включение/настройка XVTR-режима. Все параметры применяются мгновенно
// (отправляется HP-пакет). Когда AEnable=False, XVTR-bit гасится,
// антенна и Alex routing возвращаются к per-band дефолту.
procedure SetXvtrMode(AEnable, ADisablePA: Boolean; ARxAnt: Byte);
procedure SendFullHP;
procedure SetXvtrMode(AEnable, ADisablePA: Boolean; ARxAnt: Byte); override;
procedure SendFullHP; override;
// Отдельная отправка PTT: переключает реле (bit27) и drive ДО, потом PTT.
// Не меняет FIsTransmitting — вызывать после UpdateState+SendFullHP.
procedure SendPTT(Active: Boolean);
procedure SendDDCAudio(const LeftRight: array of SmallInt);
procedure SendPTT(Active: Boolean); override;
procedure SendDDCAudio(const LeftRight: array of SmallInt); override;
// Принимает RX-аудио (float [-1..1]) из DSP, конвертирует в 16-bit и
// отправляет на радио пакетами по 64 L/R пары. Хвост копится между вызовами.
procedure SendSpeakerAudio(const Left, Right: array of Single; Count: Integer);
procedure SendSpeakerAudio(const Left, Right: array of Single; Count: Integer); override;
// Единый переключатель встроенного динамика: True = слать аудио и снять мьют,
// False = замьютить динамик (byte 1400 bit1) и не накапливать поток.
// Применяется немедленно (шлёт HP-пакет, если подключены).
procedure SetSpeakerAudio(AEnabled: Boolean);
procedure SendDUCIQ(const IData, QData: array of Integer);
procedure ClearDUCIQQueue;
procedure SetSpeakerAudio(AEnabled: Boolean); override;
procedure SendDUCIQ(const IData, QData: array of Integer); override;
procedure ClearDUCIQQueue; override;
property Connected: Boolean read FConnected;
property Running: Boolean read FRunning;
property Device: THPSDRDevice read FDevice;
// LocalPort — HPSDR-специфично (локальный UDP-порт), не часть базы.
property LocalPort: Word read FLocalPort;
property LastError: string read FLastError;
property OnDeviceFound: TOnDeviceFound read FOnDeviceFound write FOnDeviceFound;
property OnDDCIQ: TOnDDCIQPacket read FOnDDCIQ write FOnDDCIQ;
property OnMicPacket: TOnMicPacket read FOnMic write FOnMic;
property OnHPStatus: TOnHPStatus read FOnHPStatus write FOnHPStatus;
property OnWideband: TOnWidebandFrame read FOnWideband write FOnWideband;
property DirectIP: string read FDirectIP write FDirectIP; // unicast discovery
end;
implementation
@@ -599,6 +584,60 @@ end;
// THPSDRNetwork
// ===========================================================================
// ---- Полиморфные геттеры состояния (TRadioBackend) ----
function THPSDRNetwork.GetConnected: Boolean;
begin
Result := FConnected;
end;
function THPSDRNetwork.GetRunning: Boolean;
begin
Result := FRunning;
end;
function THPSDRNetwork.GetDevice: TRadioDevice;
begin
Result := FDevice;
end;
function THPSDRNetwork.GetLastError: string;
begin
Result := FLastError;
end;
function THPSDRNetwork.GetDirectIP: string;
begin
Result := FDirectIP;
end;
procedure THPSDRNetwork.SetDirectIP(const V: string);
begin
FDirectIP := V;
end;
// ---- Возможности HPSDR-бэкенда ----
function THPSDRNetwork.Caps: TBackendCaps;
begin
FillChar(Result, SizeOf(Result), 0);
Result.Kind := bkHPSDR;
Result.HasTX := True;
Result.HasPA := True;
Result.HasAlex := True;
Result.HasWideband := True;
Result.HasDitherRandom := True;
Result.HasHWMic := True;
Result.HasPLLStatus := True;
Result.HasHWGain := False;
Result.HasRFBandwidth := False;
Result.HasFullDuplex := True;
Result.MinSampleRate := 48000;
Result.MaxSampleRate := 1536000;
Result.SampleRateMode := srmDiscrete;
Result.RatePresets := nil; // контроллер использует свой HPSDR-набор
Result.MinFreqHz := 0;
Result.MaxFreqHz := 61440000;
end;
constructor THPSDRNetwork.Create;
begin
inherited;
+199
View File
@@ -0,0 +1,199 @@
unit IIOBindings;
{
Минимальный биндинг к libiio (классический API 0.x; в системе 0.26) с
ДИНАМИЧЕСКОЙ загрузкой. Если libiio нет IIOLoad возвращает False, и
Pluto-бэкенд просто не участвует в discovery (приложение работает с HPSDR).
Загружаем libiio.so / libiio.so.0 (Linux), iio.dll (Windows),
libiio.dylib (macOS). Имена символов одинаковые на всех платформах.
Покрыты функции для: scan (discovery), context create/destroy, поиск
device/channel, чтение/запись атрибутов, буферы RX/TX. Этого набора хватает
на фазы 14 (discovery RX управление TX).
}
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
interface
uses
dynlibs, ctypes, SysUtils;
type
// Непрозрачные указатели libiio
Piio_scan_context = Pointer;
Piio_context_info = Pointer;
PPiio_context_info = ^Piio_context_info; // struct iio_context_info **
Piio_context = Pointer;
Piio_device = Pointer;
Piio_channel = Pointer;
Piio_buffer = Pointer;
var
// ---- Scan / discovery ----
iio_create_scan_context: function(backend: PAnsiChar; flags: cuint): Piio_scan_context; cdecl;
iio_scan_context_destroy: procedure(ctx: Piio_scan_context); cdecl;
iio_scan_context_get_info_list: function(ctx: Piio_scan_context;
var info: PPiio_context_info): ptrint; cdecl;
iio_context_info_list_free: procedure(info: PPiio_context_info); cdecl;
iio_context_info_get_description: function(info: Piio_context_info): PAnsiChar; cdecl;
iio_context_info_get_uri: function(info: Piio_context_info): PAnsiChar; cdecl;
// ---- Context ----
iio_create_context_from_uri: function(uri: PAnsiChar): Piio_context; cdecl;
iio_context_destroy: procedure(ctx: Piio_context); cdecl;
iio_context_get_name: function(ctx: Piio_context): PAnsiChar; cdecl;
iio_context_find_device: function(ctx: Piio_context; name: PAnsiChar): Piio_device; cdecl;
iio_context_get_attr_value: function(ctx: Piio_context; name: PAnsiChar): PAnsiChar; cdecl;
// ---- Device / channel ----
// output — C '_Bool' (1 байт). Передаём явный байт 0/1 (ByteBool/True давал
// некорректное значение → output-каналы не находились).
iio_device_find_channel: function(dev: Piio_device; name: PAnsiChar;
output: cuchar): Piio_channel; cdecl;
iio_device_set_kernel_buffers_count: function(dev: Piio_device; nb: cuint): cint; cdecl;
iio_channel_enable: procedure(chn: Piio_channel); cdecl;
iio_channel_disable: procedure(chn: Piio_channel); cdecl;
// ---- Атрибуты ----
iio_channel_attr_read_longlong: function(chn: Piio_channel; attr: PAnsiChar;
out val: clonglong): cint; cdecl;
iio_channel_attr_write_longlong: function(chn: Piio_channel; attr: PAnsiChar;
val: clonglong): cint; cdecl;
iio_channel_attr_write: function(chn: Piio_channel; attr: PAnsiChar;
src: PAnsiChar): ptrint; cdecl;
iio_channel_attr_write_bool: function(chn: Piio_channel; attr: PAnsiChar;
val: ByteBool): cint; cdecl;
iio_channel_attr_write_double: function(chn: Piio_channel; attr: PAnsiChar;
val: cdouble): cint; cdecl;
// ---- Буферы (RX refill / TX push) ----
iio_device_create_buffer: function(dev: Piio_device; samples_count: csize_t;
cyclic: ByteBool): Piio_buffer; cdecl;
iio_buffer_destroy: procedure(buf: Piio_buffer); cdecl;
iio_buffer_cancel: procedure(buf: Piio_buffer); cdecl; // разблокирует refill/push
iio_buffer_refill: function(buf: Piio_buffer): ptrint; cdecl;
iio_buffer_push: function(buf: Piio_buffer): ptrint; cdecl;
iio_buffer_first: function(buf: Piio_buffer; chn: Piio_channel): Pointer; cdecl;
iio_buffer_step: function(buf: Piio_buffer): ptrint; cdecl;
iio_buffer_end: function(buf: Piio_buffer): Pointer; cdecl;
// ---- Утилиты ----
iio_strerror: procedure(err: cint; dst: PAnsiChar; len: csize_t); cdecl;
// Загружает libiio (один раз). True — готово к использованию. Идемпотентно.
function IIOLoad: Boolean;
// True, если libiio загружена и символы разрешены.
function IIOLoaded: Boolean;
// Расшифровка кода ошибки libiio (err — отрицательный errno).
function IIOStrError(err: cint): string;
implementation
var
GLib: TLibHandle = NilHandle;
GLoaded: Boolean = False;
const
{$IFDEF WINDOWS}
IIO_LIBS: array[0..1] of string = ('libiio.dll', 'iio.dll');
{$ELSE}
{$IFDEF DARWIN}
IIO_LIBS: array[0..1] of string = ('libiio.dylib', 'libiio.1.dylib');
{$ELSE}
IIO_LIBS: array[0..1] of string = ('libiio.so', 'libiio.so.0');
{$ENDIF}
{$ENDIF}
function Sym(const Name: string): Pointer;
begin
Result := GetProcedureAddress(GLib, Name);
if Result = nil then
raise Exception.CreateFmt('libiio: symbol "%s" not found', [Name]);
end;
function IIOLoaded: Boolean;
begin
Result := GLoaded;
end;
function IIOLoad: Boolean;
var
I: Integer;
begin
if GLoaded then Exit(True);
if GLib = NilHandle then
for I := 0 to High(IIO_LIBS) do
begin
GLib := LoadLibrary(IIO_LIBS[I]);
if GLib <> NilHandle then Break;
end;
if GLib = NilHandle then Exit(False);
try
iio_create_scan_context := Sym('iio_create_scan_context');
iio_scan_context_destroy := Sym('iio_scan_context_destroy');
iio_scan_context_get_info_list := Sym('iio_scan_context_get_info_list');
iio_context_info_list_free := Sym('iio_context_info_list_free');
iio_context_info_get_description := Sym('iio_context_info_get_description');
iio_context_info_get_uri := Sym('iio_context_info_get_uri');
iio_create_context_from_uri := Sym('iio_create_context_from_uri');
iio_context_destroy := Sym('iio_context_destroy');
iio_context_get_name := Sym('iio_context_get_name');
iio_context_find_device := Sym('iio_context_find_device');
iio_context_get_attr_value := Sym('iio_context_get_attr_value');
iio_device_find_channel := Sym('iio_device_find_channel');
iio_device_set_kernel_buffers_count := Sym('iio_device_set_kernel_buffers_count');
iio_channel_enable := Sym('iio_channel_enable');
iio_channel_disable := Sym('iio_channel_disable');
iio_channel_attr_read_longlong := Sym('iio_channel_attr_read_longlong');
iio_channel_attr_write_longlong := Sym('iio_channel_attr_write_longlong');
iio_channel_attr_write := Sym('iio_channel_attr_write');
iio_channel_attr_write_bool := Sym('iio_channel_attr_write_bool');
iio_channel_attr_write_double := Sym('iio_channel_attr_write_double');
iio_device_create_buffer := Sym('iio_device_create_buffer');
iio_buffer_destroy := Sym('iio_buffer_destroy');
iio_buffer_cancel := Sym('iio_buffer_cancel');
iio_buffer_refill := Sym('iio_buffer_refill');
iio_buffer_push := Sym('iio_buffer_push');
iio_buffer_first := Sym('iio_buffer_first');
iio_buffer_step := Sym('iio_buffer_step');
iio_buffer_end := Sym('iio_buffer_end');
iio_strerror := Sym('iio_strerror');
GLoaded := True;
except
on E: Exception do
begin
UnloadLibrary(GLib);
GLib := NilHandle;
GLoaded := False;
end;
end;
Result := GLoaded;
end;
function IIOStrError(err: cint): string;
var
Buf: array[0..255] of AnsiChar;
begin
if GLoaded and Assigned(iio_strerror) then
begin
FillChar(Buf, SizeOf(Buf), 0);
iio_strerror(Abs(err), @Buf[0], SizeOf(Buf));
Result := string(PAnsiChar(@Buf[0]));
end
else
Result := Format('iio error %d', [err]);
end;
finalization
if GLib <> NilHandle then
begin
UnloadLibrary(GLib);
GLib := NilHandle;
GLoaded := False;
end;
end.
+128 -8
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;
// 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;
+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.
+227
View File
@@ -0,0 +1,227 @@
unit RadioBackend;
{
Абстракция бэкенда железа. Базовый класс TRadioBackend, который реализуют
конкретные транспорты:
- THPSDRNetwork (openHPSDR Ethernet protocol) HPSDRNetwork.pas
- TPlutoBackend (libiio / AD936x) PlutoBackend.pas (позже)
TRadioController держит FNetwork: TRadioBackend и работает только через этот
интерфейс. Возможности бэкенда описывает TBackendCaps (контроллер гейтит по
ним UI и поведение).
Здесь же общая запись устройства (TRadioDevice) и типы колбэков данных,
чтобы оба бэкенда и контроллер ссылались на один источник правды.
TDDCIQPacket/THighPriorityStatus/TMicDataPacket берём из HPSDRProtocol
это сетевой формат HPSDR, а Pluto-бэкенд синтезирует те же структуры, поэтому
WDSP-вход остаётся неизменным.
}
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
interface
uses
Classes, SysUtils, HPSDRProtocol, Settings;
type
TBackendKind = (bkHPSDR, bkPluto);
TSampleRateMode = (srmDiscrete, srmContinuous);
// Описание возможностей бэкенда. Контроллер гейтит UI/поведение по этим полям.
TBackendCaps = record
Kind: TBackendKind;
HasTX: Boolean; // есть передатчик
HasPA: Boolean; // fwd power / SWR / supply (HP status)
HasAlex: Boolean; // Alex фильтры / антенный коммутатор
HasWideband: Boolean; // wideband ADC дисплей
HasDitherRandom: Boolean; // dither / random на ADC
HasHWMic: Boolean; // mic over network / HW keyer / sidetone
HasPLLStatus: Boolean; // PLL lock в статусе
HasHWGain: Boolean; // manual gain / hw-AGC (Pluto)
HasRFBandwidth: Boolean; // настройка аналоговой RF-полосы (Pluto)
HasFullDuplex: Boolean; // одновременный RX+TX (Pluto / QO-100)
MinSampleRate: Integer;
MaxSampleRate: Integer;
SampleRateMode: TSampleRateMode;
RatePresets: array of Integer; // пресеты для SampleRateOverlay
MinFreqHz: Double;
MaxFreqHz: Double;
end;
// Общая запись найденного/подключённого устройства. Поля HPSDR (MAC/BoardType/
// NumDDCs…) и Pluto (URI/Serial) сосуществуют; актуальность по Kind.
TRadioDevice = record
Kind: TBackendKind;
IPAddress: string;
Port: Word;
MAC: array[0..5] of Byte; // HPSDR; для Pluto — synthetic из Serial
BoardType: Byte;
ProtocolVersion: Byte;
FirmwareVersion: Byte;
NumDDCs: Byte;
FreqOrPhase: Byte;
EndianModes: Byte;
URI: string; // Pluto: 'usb:1.5.5' / 'ip:192.168.2.1'
Serial: string; // Pluto serial (hw_serial)
Model: string; // Pluto hw_model (для отображения)
InUse: Boolean;
Valid: Boolean;
end;
PRadioDevice = ^TRadioDevice;
TRadioDeviceArray = array of TRadioDevice;
// Колбэки данных (вызываются из рабочих потоков бэкенда).
TOnDeviceFound = procedure(const Dev: TRadioDevice) of object;
TOnDDCIQPacket = procedure(DDCIndex: Integer;
const Data: TDDCIQPacket) of object;
TOnMicPacket = procedure(const Data: TMicDataPacket) of object;
TOnHPStatus = procedure(const Status: THighPriorityStatus) of object;
TOnWidebandFrame = procedure(ADCIndex: Integer;
const Samples: array of SmallInt;
Count: Integer) of object;
{ TRadioBackend — абстрактный бэкенд железа }
TRadioBackend = class
protected
// Колбэки — общее хранилище для всех бэкендов (свойства ниже).
FOnDeviceFound: TOnDeviceFound;
FOnDDCIQ: TOnDDCIQPacket;
FOnMic: TOnMicPacket;
FOnHPStatus: TOnHPStatus;
FOnWideband: TOnWidebandFrame;
function GetConnected: Boolean; virtual; abstract;
function GetRunning: Boolean; virtual; abstract;
function GetDevice: TRadioDevice; virtual; abstract;
function GetLastError: string; virtual; abstract;
function GetDirectIP: string; virtual;
procedure SetDirectIP(const V: string); virtual;
public
// ---- Возможности ----
function Caps: TBackendCaps; virtual; abstract;
// ---- Жизненный цикл / discovery ----
function Discover(TimeoutMs: Integer = 3000): TRadioDeviceArray; virtual; abstract;
function Connect(const Dev: TRadioDevice): Boolean; virtual; abstract;
procedure Disconnect; virtual; abstract;
// ---- Конфигурация потока RX ----
procedure ConfigureDDCs(NumDDCs: Byte; SampleRate: Word; ADCSource: Byte = 0;
DitherEnabled: Boolean = True;
RandomEnabled: Boolean = True); virtual; abstract;
// ---- Частота / drive / TX-состояние ----
procedure SetRunAndFreq(Run: Boolean; DDC0FreqHz, DUCFreqHz: Double;
DriveLevel: Byte = 100); virtual; abstract;
procedure UpdateState(RXFreqHz, TXFreqHz: Double; DriveLevel: Byte;
Transmitting, PAEnabled, AlexEnabled: Boolean); virtual; abstract;
procedure SendFullHP; virtual; abstract;
procedure SendPTT(Active: Boolean); virtual; abstract;
// ---- TX IQ ----
procedure SendDUCIQ(const IData, QData: array of Integer); virtual;
procedure ClearDUCIQQueue; virtual;
// ---- Опциональные (HPSDR-специфичные): no-op по умолчанию ----
procedure SendGeneralPacket(const Pkt: TGeneralPacket); virtual;
procedure ConfigureWideband(ADCIndex: Integer; Enabled: Boolean;
SamplesPerPacket: Integer = 512; SampleBits: Integer = 16;
UpdateRateMS: Integer = 70; PacketsPerFrame: Integer = 32); virtual;
procedure SendDDCSpecific(const Pkt: TDDCSpecificPacket); virtual;
procedure SendDUCSpecific(const Pkt: TDUCSpecificPacket); virtual;
procedure SendHighPriority(const Pkt: THighPriorityPacket); virtual;
procedure SetStepAtten(dB: Byte); virtual;
procedure SetAlexConfig(const A: TAlexSettings); virtual;
procedure SetXvtrMode(AEnable, ADisablePA: Boolean; ARxAnt: Byte); virtual;
procedure SendDDCAudio(const LeftRight: array of SmallInt); virtual;
procedure SendSpeakerAudio(const Left, Right: array of Single; Count: Integer); virtual;
procedure SetSpeakerAudio(AEnabled: Boolean); virtual;
// ---- Свойства состояния (полиморфные через виртуальные геттеры) ----
property Connected: Boolean read GetConnected;
property Running: Boolean read GetRunning;
property Device: TRadioDevice read GetDevice;
property LastError: string read GetLastError;
property DirectIP: string read GetDirectIP write SetDirectIP;
// ---- Колбэки данных ----
property OnDeviceFound: TOnDeviceFound read FOnDeviceFound write FOnDeviceFound;
property OnDDCIQ: TOnDDCIQPacket read FOnDDCIQ write FOnDDCIQ;
property OnMicPacket: TOnMicPacket read FOnMic write FOnMic;
property OnHPStatus: TOnHPStatus read FOnHPStatus write FOnHPStatus;
property OnWideband: TOnWidebandFrame read FOnWideband write FOnWideband;
end;
implementation
{ TRadioBackend пустые реализации опциональных методов (no-op).
Конкретные бэкенды переопределяют то, что поддерживают. }
function TRadioBackend.GetDirectIP: string;
begin
Result := '';
end;
procedure TRadioBackend.SetDirectIP(const V: string);
begin
// no-op по умолчанию (unicast discovery — фича HPSDR)
end;
procedure TRadioBackend.SendDUCIQ(const IData, QData: array of Integer);
begin
end;
procedure TRadioBackend.ClearDUCIQQueue;
begin
end;
procedure TRadioBackend.SendGeneralPacket(const Pkt: TGeneralPacket);
begin
end;
procedure TRadioBackend.ConfigureWideband(ADCIndex: Integer; Enabled: Boolean;
SamplesPerPacket: Integer; SampleBits: Integer;
UpdateRateMS: Integer; PacketsPerFrame: Integer);
begin
end;
procedure TRadioBackend.SendDDCSpecific(const Pkt: TDDCSpecificPacket);
begin
end;
procedure TRadioBackend.SendDUCSpecific(const Pkt: TDUCSpecificPacket);
begin
end;
procedure TRadioBackend.SendHighPriority(const Pkt: THighPriorityPacket);
begin
end;
procedure TRadioBackend.SetStepAtten(dB: Byte);
begin
end;
procedure TRadioBackend.SetAlexConfig(const A: TAlexSettings);
begin
end;
procedure TRadioBackend.SetXvtrMode(AEnable, ADisablePA: Boolean; ARxAnt: Byte);
begin
end;
procedure TRadioBackend.SendDDCAudio(const LeftRight: array of SmallInt);
begin
end;
procedure TRadioBackend.SendSpeakerAudio(const Left, Right: array of Single; Count: Integer);
begin
end;
procedure TRadioBackend.SetSpeakerAudio(AEnabled: Boolean);
begin
end;
end.
+142 -19
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,24 +2525,102 @@ 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
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
BoardName := BoardTypeName(Dev.BoardType);
FPendingFoundDev := Dev;
if Dev.Kind = bkPluto then
FPendingFoundEntry := Format('PlutoSDR %s', [Dev.URI])
else
FPendingFoundEntry := Format('%s %s FW:%d DDC:%d',
[Dev.IPAddress, BoardName,
[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;
+48 -19
View File
@@ -22,6 +22,11 @@ type
TSampleRateOverlay = class(TComponent)
private
FCurrentRate: Integer;
// Набор пресетов (зависит от бэкенда): HPSDR 48k..1536k или Pluto >560k.
// До 10 слотов (Pluto может иметь 8: 576k..5760k).
FRates: array[0..9] of Integer;
FNames: array[0..9] of string;
FRateCount: Integer;
FPanelHidden: Boolean;
FOnSpanSelect: TSpanSelectEvent;
FOnHidePanel: THidePanelEvent;
@@ -31,8 +36,8 @@ type
FWidth: Integer;
FHeight: Integer;
FHitRects: array[0..6] of TRect; // [0]=hide button, [1..6]=span buttons
FHitValues: array[0..6] of Integer; // 0=hide, else sample rate
FHitRects: array[0..10] of TRect; // [0]=hide button, [1..]=span buttons
FHitValues: array[0..10] of Integer; // 0=hide, else sample rate
FHitCount: Integer;
FHotIdx: Integer;
@@ -67,6 +72,9 @@ type
function HandleMouseDown(Button: TMouseButton; X, Y: Integer): Boolean;
function HandleMouseLeave: Boolean;
procedure SetCurrentRate(Rate: Integer);
// Задаёт пресеты (Гц, до 6 шт.); имена формируются как '<kHz>k'.
procedure SetRatePresets(const Rates: array of Integer);
procedure SetRatePresetsDefault; // HPSDR 48k..1536k
procedure SetPanelHidden(Hidden: Boolean);
property Left: Integer read FLeft;
property Top: Integer read FTop;
@@ -139,14 +147,15 @@ begin
C.Font.Name := 'Courier New';
C.Font.Size := 7;
C.Font.Style := [];
Result := C.TextWidth(Name) + 10;
if Result < 36 then Result := 36;
Result := C.TextWidth(Name) + 8;
if Result < 30 then Result := 30;
end;
constructor TSampleRateOverlay.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
FCurrentRate := 192000;
SetRatePresetsDefault;
FPanelHidden := False;
FHitCount := 0;
FHotIdx := -1;
@@ -207,10 +216,10 @@ begin
TH := C.TextHeight('A');
ContentW := PAD_X + HIDE_W + BTN_GAP;
for I := 0 to High(SPAN_NAMES) do
for I := 0 to FRateCount-1 do
begin
Inc(ContentW, SpanBtnWidth(SPAN_NAMES[I], C));
if I < High(SPAN_NAMES) then Inc(ContentW, BTN_GAP);
Inc(ContentW, SpanBtnWidth(FNames[I], C));
if I < FRateCount-1 then Inc(ContentW, BTN_GAP);
end;
Inc(ContentW, PAD_X);
if ContentW > W then ContentW := W;
@@ -247,11 +256,11 @@ begin
RegisterHit(HR, 0);
Inc(X, HIDE_W + BTN_GAP);
for I := 0 to High(SPAN_RATES) do
for I := 0 to FRateCount-1 do
begin
BtnW := SpanBtnWidth(SPAN_NAMES[I], C);
BtnW := SpanBtnWidth(FNames[I], C);
HR := Rect(X, OriginY + PAD_Y, X + BtnW, OriginY + PAD_Y + SPAN_H);
IsAct := SPAN_RATES[I] = FCurrentRate;
IsAct := FRates[I] = FCurrentRate;
IsHot := FHotIdx = I + 1;
if IsAct then BlendRect(Target, HR, $20, $5A, $78, 220)
@@ -262,13 +271,13 @@ begin
C.Brush.Style := bsClear;
C.Rectangle(HR);
TW := C.TextWidth(SPAN_NAMES[I]);
TW := C.TextWidth(FNames[I]);
C.Font.Color := CLR_TEXT;
C.Brush.Style := bsClear;
C.TextOut(HR.Left + (BtnW - TW) div 2,
HR.Top + (SPAN_H - TH) div 2, SPAN_NAMES[I]);
HR.Top + (SPAN_H - TH) div 2, FNames[I]);
RegisterHit(HR, SPAN_RATES[I]);
RegisterHit(HR, FRates[I]);
Inc(X, BtnW + BTN_GAP);
end;
end;
@@ -278,10 +287,10 @@ var I, CW: Integer;
begin
C.Font.Name := 'Courier New'; C.Font.Size := 7; C.Font.Style := [];
CW := PAD_X + HIDE_W + BTN_GAP;
for I := 0 to High(SPAN_NAMES) do
for I := 0 to FRateCount-1 do
begin
Inc(CW, SpanBtnWidth(SPAN_NAMES[I], C));
if I < High(SPAN_NAMES) then Inc(CW, BTN_GAP);
Inc(CW, SpanBtnWidth(FNames[I], C));
if I < FRateCount-1 then Inc(CW, BTN_GAP);
end;
Inc(CW, PAD_X);
if CW > AvailW then CW := AvailW;
@@ -444,10 +453,10 @@ begin
Target.Canvas.Font.Size := 7;
Target.Canvas.Font.Style := [];
ContentW := PAD_X + HIDE_W + BTN_GAP;
for I := 0 to High(SPAN_NAMES) do
for I := 0 to FRateCount-1 do
begin
Inc(ContentW, SpanBtnWidth(SPAN_NAMES[I], Target.Canvas));
if I < High(SPAN_NAMES) then Inc(ContentW, BTN_GAP);
Inc(ContentW, SpanBtnWidth(FNames[I], Target.Canvas));
if I < FRateCount-1 then Inc(ContentW, BTN_GAP);
end;
Inc(ContentW, PAD_X);
ContentW := Min(ContentW, W);
@@ -524,6 +533,26 @@ begin
end;
end;
procedure TSampleRateOverlay.SetRatePresets(const Rates: array of Integer);
var i, n: Integer;
begin
n := Length(Rates);
if n > 10 then n := 10;
FRateCount := n;
for i := 0 to n - 1 do
begin
FRates[i] := Rates[i];
FNames[i] := IntToStr(Rates[i] div 1000) + 'k';
end;
FCacheDirty := True;
RequestInvalidate;
end;
procedure TSampleRateOverlay.SetRatePresetsDefault;
begin
SetRatePresets(SPAN_RATES);
end;
procedure TSampleRateOverlay.SetCurrentRate(Rate: Integer);
begin
if FCurrentRate = Rate then Exit;
+320
View File
@@ -0,0 +1,320 @@
# План интеграции ADALM-PLUTO (PlutoSDR) в EWSDR
Ветка: `feature/pluto-sdr-integration` (от `feature/radio-controller-refactor`).
Документ — результат анализа текущей кодовой базы и предлагаемый поэтапный план.
Цель: добавить второй бэкенд железа (Pluto, libiio/AD936x) рядом с openHPSDR так,
чтобы оба находились в одном окне discovery, а различия возможностей корректно
отражались в UI (включая оверлей samplerate с другими значениями для Pluto).
**Ключевые решения по продукту:**
- Pluto — **только VHF/UHF**, HF исключён. Свой band-план: 2 м, 70 см, 23 см,
13 см (и т.д.).
- Отдельный **режим QO-100** (геостационарный транспондер): RX через LNB,
TX напрямую на 2400 МГц, раздельные RX/TX LO, full-duplex.
---
## 1. Анализ текущей архитектуры
### 1.1 Слои и владение
```
TMainForm (GUI) ─┐
ewsdrd.lpr (демон)─┤── владеют ─→ TRadioController (backend-агностичное ядро)
WebServer/Adapter ─┘ │
├─ FNetwork: THPSDRNetwork ← ЕДИНСТВЕННАЯ точка железа
├─ FDSPEngine: TWDSPEngine
├─ FAudioOut / FAudioIn
├─ FSettings: TSettingsManager (per-device по MAC)
└─ FDeviceStore: TDeviceStore (saved + discovered)
```
`TRadioController` уже спроектирован как **backend-агностичное ядро** — в коде есть
прямые комментарии-намёки на Pluto (RadioController.pas:541, :603). Но
`FNetwork` объявлен конкретным типом `THPSDRNetwork`, а не интерфейсом, и
используется в **52 местах** контроллера.
### 1.2 Путь данных (ключевое для бэкенда)
**RX (вход IQ):**
```
THPSDRNetwork (RX-поток, UDP 1035) → OnDDCIQ(DDCIndex, TDDCIQPacket)
→ FDSPEngine.PushDDCPacket(Data.IQData, 0, SamplesPerFrame)
```
IQ — **24-битные** интерливнутые пары I/Q (6 байт/пара), формат зашит в
`PushDDCPacket` (WDSPEngine.pas:1136) и в декодере DSP-потока.
**TX (выход IQ):**
```
WDSP TXA → OnTXIQ(Buf, Count) → накопление 240 пар (24-бит, clamp)
→ FNetwork.SendDUCIQ(I[], Q[])
```
**Телеметрия:** `OnHPStatus(THighPriorityStatus)` → fwd power / SWR / supply V/A /
PLL lock / ADC overload. S-meter считается отдельно в WDSP (`FLastSMeter`).
### 1.3 Discovery
```
FController.Discover → TDiscoverThread → FNetwork.Discover(2000)
(UDP broadcast :1024, CMD_DISCOVERY)
→ per device: OnDeviceFound(THPSDRDevice)
→ FDeviceStore.AddDiscovered(...) → Changed(rfDeviceList)
→ DeviceForm.RefreshFound (один список «DISCOVERED DEVICES»)
```
- `THPSDRDevice`: IP, Port, MAC[6], BoardType, ProtocolVersion, NumDDCs…
- `TDeviceStore`: `TSavedDevice` (Name/IP/BoardType/AutoStart) и
`TDiscoveredDevice` (IP/DisplayName/BoardType/MAC[6]).
- **Per-device настройки** (`Settings.pas`) ключуются по MAC через
`MacToStr(MAC)` → строковая секция JSON. `LoadDevice/LoadTX/LoadAlex/LoadXvtr`
все принимают `MAC: array of Byte`.
### 1.4 Sample rate
- `SampleRateOverlay.pas`**жёстко зашитый** массив
`SPAN_RATES = (48000,96000,192000,384000,768000,1536000)` и `SPAN_NAMES`.
- `SetSampleRate(Hz)` (RadioController.pas:1791): span ≡ sample rate;
`FDSPEngine.ChangeSampleRate(Hz)` + `FNetwork.ConfigureDDCs(1, Hz div 1000, …)`.
- Дискретные значения HPSDR кратны DSP-clock 122.88 МГц.
### 1.5 Частота / диапазоны / XVTR
- `FreqToPhaseWord` (HPSDRProtocol.pas): phase = 2³² · f / 122.88 МГц.
- `FreqToBandIdx` / band-план — **HF/6m** (1.8–54 МГц, 11 диапазонов).
- Есть полноценная **XVTR-подсистема** (8 слотов, LO offset/error, трансляция
видимой частоты в IF, `XvtrTranslate`) — но **один** LO-offset на слот (RX=TX).
Для QO-100 нужны **раздельные RX/TX LO** → отдельная модель (см. §4).
- `SetDuplex` (RadioController.pas:1851) — сейчас заглушка (`{TODO wiring}`).
Для QO-100 full-duplex это нужно довести.
---
## 2. Pluto: что отличается (capability-матрица)
| Возможность | openHPSDR | Pluto (AD936x) |
|--------------------------------|-----------------------|---------------------------------------|
| Транспорт | UDP/Ethernet (свой) | libiio: USB / IP / local |
| Discovery | UDP broadcast :1024 | `iio_scan_context` (USB/network/local)|
| Идентификатор | MAC[6] | serial / context URI |
| Кол-во приёмников (DDC) | до 80, sync | 1 RX (2×2 только с хаком прошивки) |
| Sample rate | дискрет 48k…1536k | **непрерывный, ≥ ~521 ksps**, до 61.44 MSPS (по USB2 практически ~56 MSPS) |
| RF-полоса (аналог. фильтр) | нет (фикс CIC) | отдельная настройка RX/TX LPF |
| Диапазон частот | 061.44 МГц (HF/6m) | 325 МГц–3.8 ГГц (сток), **70 МГц–6 ГГц с хаком**. HF не используем |
| Разрядность IQ | 24-бит | 12-бит ADC, по шине 16-бит |
| Усиление RX | step atten + dither/random | manual gain 073 дБ / AGC (slow/fast/hybrid/manual) |
| TX | DUC + PA + Alex T/R | 1 DAC, TX atten 89…0 дБ, **без PA/SWR** |
| Full-duplex | (зависит) | да (раздельные RX/TX LO) — нужно для QO-100 |
| S-meter | WDSP (есть) | WDSP (есть) + аппаратный RSSI |
| Fwd power / SWR / supply V/A | HP status | **нет** (нет PA) |
| PLL lock | HP status | нет (другой механизм) |
| ADC overload | HP status | косвенно (gain/RSSI) |
| Wideband ADC дисплей | есть | нет |
| Alex / антенный коммутатор | есть | нет |
| Mic over network / HW CW keyer / sidetone / open collector | есть | нет (TX-аудио через звуковую карту/web) |
| 10 МГц reference / PA config | есть | нет; есть XO ppm-калибровка, темп.сенсор |
**Только у Pluto (нужно в UI):** RF bandwidth, manual gain / режим AGC железа,
XO ppm-коррекция, выбор транспорта (USB/IP), buffer size/timeout, температура.
---
## 3. Band-план Pluto (VHF/UHF) и режим QO-100
### 3.1 VHF/UHF band-план
HF исключён. Для Pluto — **отдельная таблица диапазонов** (используется, когда
подключён бэкенд `bkPluto`); `FreqToBandIdx`/band-кнопки переключаются на неё:
| Band | Частоты | Примечание |
|--------|----------------------|---------------------------------------------|
| 2 м | 144146 МГц | требует freq-range хак Pluto (<325 МГц) |
| 70 см | 430440 МГц | требует freq-range хак Pluto |
| 23 см | 12401300 МГц | нативно |
| 13 см | 23002450 МГц | нативно (включает QO-100 uplink 2400) |
| (опц.) FM bcast / ADS-B / прочие — позже |
⚠️ 2 м и 70 см ниже стокового минимума 325 МГц → нужен AD9363→AD9361
frequency-range мод прошивки Pluto. В UI/доках это отметить.
### 3.2 Режим QO-100 (геостационар Es'hail-2 NB-транспондер)
Геометрия сигнала:
- **Downlink (RX):** 10489.5010489.99 МГц (10 ГГц). Принимается через LNB:
LNB LO (обычно 9750 МГц) → Pluto RX ≈ 739.5739.99 МГц. Нативный диапазон Pluto.
- **Uplink (TX):** 2400.052400.54 МГц. Pluto TX напрямую (нативно).
- **Связь частот:** uplink = downlink 8089.5 МГц (offset транспондера).
- **Full-duplex** — стандартный режим работы (слышишь себя через спутник).
Отсюда QO-100 — это **трансвертер с раздельными RX/TX LO**, чего нынешняя
XVTR-модель (один offset) не даёт. Предлагаемая модель — отдельная подсистема/слот:
```pascal
TQO100Settings = record
Enabled: Boolean;
DisplayBase: Double; // отображаем downlink (10489.x ГГц)
LnbLoHz: Double; // LO LNB, по умолч. 9750e6
LnbPpm: Double; // калибровка дрейфа LNB (по маяку)
TransponderOfs: Double; // 8089.5e6 (downlink - uplink)
TxAttenDb: Double; // -89..0
FullDuplex: Boolean; // RX во время TX
end;
```
Трансляция:
- RX: `PlutoRxLO = DisplayFreq LnbLoHz` (с учётом `LnbPpm`).
- TX: `PlutoTxLO = DisplayFreq TransponderOfs`.
- Дисплей/VFO/курсор работают в терминах downlink (10 ГГц).
- Калибровка по маяку: подстройка `LnbPpm`, чтобы наблюдаемый маяк совпал с
опорной частотой.
Требует довести `SetDuplex` (сейчас TODO) до реального full-duplex (одновременно
RX-поток refill и TX-поток push в libiio — Pluto это поддерживает аппаратно).
---
## 4. Предлагаемая архитектура интеграции
### 4.1 Абстракция бэкенда (ядро плана)
Базовый класс/интерфейс **`TRadioBackend`**, который реализуют оба:
`THPSDRNetwork` и новый `TPlutoBackend`. Контроллер держит `FBackend: TRadioBackend`
(сохранить имя поля `FNetwork`, сменив только тип на базовый — минимизирует дифф).
Методы, которых у Pluto нет, — пустые no-op (Alex, wideband, step atten, speaker
audio, DUC specific и т.д.).
Дескриптор возможностей:
```pascal
TBackendKind = (bkHPSDR, bkPluto);
TSampleRateMode = (srmDiscrete, srmContinuous);
TBackendCaps = record
Kind: TBackendKind;
HasTX, HasPA, HasAlex, HasWideband: Boolean;
HasDitherRandom, HasHWMic, HasPLLStatus: Boolean;
HasHWGain: Boolean; // manual gain / hw AGC (Pluto)
HasRFBandwidth: Boolean;
HasFullDuplex: Boolean;
MinSampleRate, MaxSampleRate: Integer;
SampleRateMode: TSampleRateMode;
RatePresets: array of Integer; // для оверлея
MinFreqHz, MaxFreqHz: Double;
end;
```
Контроллер по `FBackend.Caps`: гейтит UI, подставляет пресеты samplerate,
выбирает band-план (VHF/UHF), путь телеметрии.
### 4.2 libiio binding
- FPC-биндинг к `libiio` (и опц. `libad9361`) через **динамическую загрузку**
(`dynlibs`): `libiio.so` / `libiio.dll` / `libiio.dylib`. Приложение
запускается без libiio (Pluto просто не появляется в discovery).
- Новые юниты: `IIOBindings.pas` (типы/прототипы) + `PlutoBackend.pas`
(`TPlutoBackend`: scan/connect, RX/TX потоки, gain/freq/rate/bandwidth).
### 4.3 Discovery в одном окне
- Расширить записи устройств: `Kind: TBackendKind`, `URI: string`
(Pluto: `usb:1.5.5` / `ip:192.168.2.1`), `Serial: string`.
- `TDiscoverThread` запускает **оба** скана параллельно, сливает в один store:
HPSDR (`FNetwork.Discover`) + Pluto (`iio_create_scan_context`).
- `DeviceForm` структурно не меняется: тот же список, лишь метка типа в строке
(`PlutoSDR usb:1.5.5`). `BoardTypeName`/рендер дополнить веткой Pluto.
- `ConnectDevice` диспетчеризует по `Kind`: создаёт нужный бэкенд перед Connect.
### 4.4 Ключ per-device настроек
Минимальное изменение: для Pluto **синтезировать 6-байтовый ключ из serial**
(хэш serial → 6 байт) и подавать в существующие `LoadDevice/LoadTX/...`. Settings.pas
не трогаем. (Альтернатива — обобщить ключ до строки; дороже, отложить.)
### 4.5 Sample rate overlay
- `TSampleRateOverlay` получает список значений/имён от контроллера
(`SetRatePresets`). HPSDR — текущие 48k…1536k. Pluto — пресеты **> 560 кбит**,
напр. `768k / 1536k / 2304k / 3072k / 3840k / 5760k`.
- `SetSampleRate` для Pluto зовёт `FBackend` (set `sampling_frequency` +
`rf_bandwidth`), без `ConfigureDDCs`.
### 4.6 IQ-конвертация (формат WDSP не меняем)
- **RX:** `TPlutoBackend` читает int16 из `iio_buffer`, конвертирует 16→24-бит,
упаковывает в `TDDCIQPacket` (seq синтезируется), вызывает `OnDDCIQ`.
- **TX:** `SendDUCIQ` конвертирует 24→16-бит, пишет в tx-буфер libiio с пейсингом
(модель FIFO-cushion, см. memory `project_txiq_pacing`). ⚠️ TX-rate Pluto
≥ 521 ksps, WDSP TXA отдаёт 192k — нужен ресэмпл 192k→PlutoTX или смена TX-rate.
### 4.7 Усиление / телеметрия
- RX gain: контрол manual gain (073 дБ) / режим hw-AGC при `Caps.HasHWGain`
(можно расширить UI step-atten). WDSP AGC поверх.
- Телеметрия: S-meter (WDSP) — для обоих. fwd/SWR/supply/PLL/overload — скрыть при
`not Caps.HasPA`. Опц.: RSSI/температура Pluto.
---
## 5. Поэтапный план (фазы)
**Фаза 0 — Абстракция (без поведения Pluto)**
- `TRadioBackend` (база) + `TBackendCaps`; `THPSDRNetwork` реализует, заполняет
caps. Сменить тип `FNetwork`/вызовы. Регрессия: HPSDR как раньше. ✅
**Фаза 1 — libiio binding + discovery**
- `IIOBindings.pas`, `PlutoBackend.pas` (skeleton: scan/connect/disconnect/caps).
- `Kind/URI/Serial` в store, объединённый discovery, диспетчер по `Kind`.
- UI: оба типа в одном окне. ✅ (видим Pluto, ещё без RX).
**Фаза 2 — RX-тракт Pluto + VHF/UHF band-план**
- RX-поток libiio → 16→24-бит → `OnDDCIQ` → WDSP. Set freq (Hz в LO), gain,
sample rate, RF bandwidth.
- VHF/UHF band-план (§3.1) при `bkPluto`; samplerate-оверлей с Pluto-пресетами.
- Гейтинг UI по caps. ✅ приём на Pluto (спектр/водопад/аудио на 2м/70см/23см/13см).
**Фаза 3 — Управление/калибровка**
- RX gain / hw-AGC, XO ppm, транспорт (USB/IP), buffer/timeout, температура/RSSI.
- Persist per-device по synthetic-MAC из serial.
**Фаза 4 — TX-тракт Pluto**
- `SendDUCIQ` 24→16 + пейсинг; ресэмпл/смена TX-rate (§4.6); TX atten вместо
drive/PA; гейтинг PA/SWR/Alex/keyer. ✅ передача (SSB/CW/FM).
**Фаза 5 — Режим QO-100**
- `TQO100Settings` + раздельные RX/TX LO (§3.2), дисплей в downlink-терминах,
калибровка LNB по маяку, **full-duplex** (довести `SetDuplex`).
- UI: вход/выход в режим QO-100 (как XVTR-слот), поля LNB LO / ppm / TX atten.
- ✅ полноценная работа через спутник.
**Фаза 6 — Полировка**
- Web/CAT-фронтенды (команды уже агностичны) + гейтинг полей; демон `ewsdrd.lpr`
(discovery/connect Pluto headless); кросс-платформенная сборка с/без libiio; доки.
---
## 6. Риски и открытые вопросы
1. **2 м / 70 см ниже 325 МГц** → требуется freq-range мод прошивки Pluto.
Документировать; возможно гейтить эти кнопки при стоковой прошивке.
2. **TX sample-rate mismatch** (192k WDSP vs ≥521k Pluto) — ресэмпл или смена
TX-rate WDSP.
3. **QO-100 full-duplex** — довести `SetDuplex` (сейчас TODO) до реального
одновременного RX+TX; проверить латентность/буферы libiio.
4. **LNB дрейф** — калибровка `LnbPpm` по маяку (ручная/полуавтомат).
5. **Ключ настроек** — synthetic-MAC из serial (быстро) vs обобщение до строки.
6. **libiio как зависимость** — динамическая загрузка, поведение при отсутствии.
7. **USB-латентность/пропускная** — реальный максимум sample-rate по USB2.
---
## 7. Затрагиваемые файлы (ориентир)
| Файл | Изменение |
|-------------------------|-------------------------------------------------------|
| `RadioController.pas` | тип `FBackend`, диспетчер по Kind, гейтинг по caps, QO-100 трансляция |
| `HPSDRNetwork.pas` | реализовать `TRadioBackend` + заполнить caps |
| `IIOBindings.pas` (нов) | биндинг libiio (dynamic) |
| `PlutoBackend.pas` (нов)| `TPlutoBackend` (scan/connect/RX/TX/gain/rate/bw) |
| `DeviceStore.pas` | `Kind/URI/Serial` в записях |
| `DeviceForm.pas` | метка типа в списке (структурно без изменений) |
| `SampleRateOverlay.pas` | пресеты из контроллера вместо констант |
| `BoardUtils.pas` | имя Pluto, VHF/UHF band-план |
| `Settings.pas` | без изменений (synthetic-MAC) / QO-100 persist |
| `MainForm.pas` / `ewsdrd.lpr` | гейтинг виджетов по caps, wiring, QO-100 UI |
+16 -29
View File
@@ -17,36 +17,23 @@
<UseVersionInfo Value="True"/>
<AutoIncrementBuild Value="True"/>
<MinorVersionNr Value="9"/>
<BuildNr Value="192"/>
<BuildNr Value="202"/>
</VersionInfo>
<MacroValues Count="27">
<Macro1 Name="LCLWidgetType" Value="cocoa"/>
<Macro2 Name="LCLWidgetType" Value="cocoa"/>
<Macro3 Name="LCLWidgetType" Value="cocoa"/>
<Macro4 Name="LCLWidgetType" Value="cocoa"/>
<Macro5 Name="LCLWidgetType" Value="cocoa"/>
<Macro6 Name="LCLWidgetType" Value="cocoa"/>
<Macro7 Name="LCLWidgetType" Value="cocoa"/>
<Macro8 Name="LCLWidgetType" Value="cocoa"/>
<Macro9 Name="LCLWidgetType" Value="cocoa"/>
<Macro10 Name="LCLWidgetType" Value="cocoa"/>
<Macro11 Name="LCLWidgetType" Value="cocoa"/>
<Macro12 Name="LCLWidgetType" Value="cocoa"/>
<Macro13 Name="LCLWidgetType" Value="cocoa"/>
<Macro14 Name="LCLWidgetType" Value="cocoa"/>
<Macro15 Name="LCLWidgetType" Value="cocoa"/>
<Macro16 Name="LCLWidgetType" Value="cocoa"/>
<Macro17 Name="LCLWidgetType" Value="cocoa"/>
<Macro18 Name="LCLWidgetType" Value="cocoa"/>
<Macro19 Name="LCLWidgetType" Value="cocoa"/>
<Macro20 Name="LCLWidgetType" Value="cocoa"/>
<Macro21 Name="LCLWidgetType" Value="cocoa"/>
<Macro22 Name="LCLWidgetType" Value="cocoa"/>
<Macro23 Name="LCLWidgetType" Value="cocoa"/>
<Macro24 Name="LCLWidgetType" Value="cocoa"/>
<Macro25 Name="LCLWidgetType" Value="cocoa"/>
<Macro26 Name="LCLWidgetType" Value="cocoa"/>
<Macro27 Name="LCLWidgetType" Value="cocoa"/>
<MacroValues Count="14">
<Macro1 Name="LCLWidgetType" Value="qt6"/>
<Macro2 Name="LCLWidgetType" Value="qt6"/>
<Macro3 Name="LCLWidgetType" Value="qt6"/>
<Macro4 Name="LCLWidgetType" Value="qt6"/>
<Macro5 Name="LCLWidgetType" Value="qt6"/>
<Macro6 Name="LCLWidgetType" Value="qt6"/>
<Macro7 Name="LCLWidgetType" Value="qt6"/>
<Macro8 Name="LCLWidgetType" Value="qt6"/>
<Macro9 Name="LCLWidgetType" Value="qt6"/>
<Macro10 Name="LCLWidgetType" Value="qt6"/>
<Macro11 Name="LCLWidgetType" Value="qt6"/>
<Macro12 Name="LCLWidgetType" Value="qt6"/>
<Macro13 Name="LCLWidgetType" Value="qt6"/>
<Macro14 Name="LCLWidgetType" Value="qt6"/>
</MacroValues>
<BuildModes>
<Item Name="Debug" Default="True"/>
+83
View File
@@ -0,0 +1,83 @@
program test_pluto;
{$MODE Delphi}
// Ручной тест Pluto/AD936x-бэкенда против реального железа (без UI/DSP).
// Сборка: fpc -Mobjfpc -dHEADLESS -Fu. -FUlib-headless/<cpu-os> test_pluto.lpr
// Запуск: ./test_pluto ip:172.16.2.199
uses
{$IFDEF UNIX}cthreads,{$ENDIF}
SysUtils, Classes, ctypes, Math,
RadioBackend, IIOBindings, HPSDRProtocol, PlutoBackend;
type
// Приёмник OnDDCIQ: считает пакеты/пары и средний уровень I (dBFS).
THelper = class
Pkts: Integer;
Pairs: Int64;
SumSq: Double;
procedure OnIQ(DDCIndex: Integer; const Data: TDDCIQPacket);
end;
procedure THelper.OnIQ(DDCIndex: Integer; const Data: TDDCIQPacket);
const SCALE = 1.0 / 8388608.0;
var spf, i, pos, ir: Integer; fi: Double;
begin
spf := (Integer(Data.SamplesPerFrame[0]) shl 8) or Integer(Data.SamplesPerFrame[1]);
Inc(Pkts);
Pairs := Pairs + spf;
for i := 0 to spf - 1 do
begin
pos := i * 6;
ir := (Integer(Data.IQData[pos]) shl 16) or (Integer(Data.IQData[pos+1]) shl 8)
or Integer(Data.IQData[pos+2]);
if (ir and $800000) <> 0 then ir := ir or Integer($FF000000);
fi := ir * SCALE;
SumSq := SumSq + fi * fi;
end;
end;
var
URI: string;
pl: TPlutoBackend;
dev: TRadioDevice;
h: THelper;
rms: Double;
begin
if ParamCount >= 1 then URI := ParamStr(1) else URI := 'ip:172.16.2.199';
if not IIOLoad then begin writeln('FAIL: libiio not loaded'); Halt(1); end;
writeln('libiio loaded OK');
pl := TPlutoBackend.Create;
h := THelper.Create;
try
if not pl.ProbeURI(URI, dev) then begin writeln('FAIL: probe'); Halt(1); end;
writeln('PROBE OK: ', dev.Model, ' serial=', dev.Serial, ' ip=', dev.IPAddress);
if not pl.Connect(dev) then begin writeln('FAIL connect: ', pl.LastError); Halt(1); end;
writeln('CONNECT OK');
pl.OnDDCIQ := h.OnIQ;
pl.ConfigureDDCs(1, 1536, 0, True, True); // 1.536 MS/s
pl.SetRunAndFreq(True, 100000000, 435000000, 100); // RX 100 MHz (FM band)
writeln('RUN: streaming 800 ms @ RX=100MHz, rate=1.536M ...');
Sleep(800);
pl.SetRunAndFreq(False, 100000000, 435000000, 0);
if h.Pairs > 0 then rms := Sqrt(h.SumSq / h.Pairs) else rms := 0;
writeln(' packets=', h.Pkts, ' pairs=', h.Pairs,
' (~', (h.Pairs / 0.8 / 1e6):0:3, ' MS/s)');
if rms > 0 then
writeln(' I level: rms=', rms:0:5, ' (', (20*Log10(rms)):0:1, ' dBFS)')
else
writeln(' I level: zero (no signal/clip)');
pl.Disconnect;
writeln('DISCONNECT OK');
finally
h.Free;
pl.Free;
end;
writeln('DONE');
end.