From a988849d3effd418d11badcc78a185a43acf21d2 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 16 Jun 2026 18:55:20 +0300 Subject: [PATCH] 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 --- BoardUtils.pas | 73 ++++- DeviceForm.pas | 17 +- DeviceStore.pas | 44 ++- HPSDRNetwork.pas | 167 ++++++---- IIOBindings.pas | 199 ++++++++++++ MainForm.pas | 140 ++++++++- PlutoBackend.pas | 555 ++++++++++++++++++++++++++++++++++ RadioBackend.pas | 227 ++++++++++++++ RadioController.pas | 167 ++++++++-- SampleRateOverlay.pas | 67 ++-- doc/PLUTO_INTEGRATION_PLAN.md | 320 ++++++++++++++++++++ ewsdr.lpi | 45 +-- test_pluto.lpr | 83 +++++ 13 files changed, 1940 insertions(+), 164 deletions(-) create mode 100644 IIOBindings.pas create mode 100644 PlutoBackend.pas create mode 100644 RadioBackend.pas create mode 100644 doc/PLUTO_INTEGRATION_PLAN.md create mode 100644 test_pluto.lpr diff --git a/BoardUtils.pas b/BoardUtils.pas index 5945dde..4a8c1a3 100644 --- a/BoardUtils.pas +++ b/BoardUtils.pas @@ -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 - begin - Result := i; - Exit; - end; + if Pluto then + begin + 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. diff --git a/DeviceForm.pas b/DeviceForm.pas index 54f3f2b..fac0013 100644 --- a/DeviceForm.pas +++ b/DeviceForm.pas @@ -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,7 +469,12 @@ begin Exit; end; D := FStore.Discovered(Idx); - NewIdx := FStore.AddSaved(D.DisplayName, D.IPAddress, D.BoardType); + // 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; end; @@ -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; diff --git a/DeviceStore.pas b/DeviceStore.pas index bf77747..1bc892a 100644 --- a/DeviceStore.pas +++ b/DeviceStore.pas @@ -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]; diff --git a/HPSDRNetwork.pas b/HPSDRNetwork.pas index c4f5e9c..62e2bf8 100644 --- a/HPSDRNetwork.pas +++ b/HPSDRNetwork.pas @@ -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; - 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 + // LocalPort — HPSDR-специфично (локальный UDP-порт), не часть базы. + property LocalPort: Word read FLocalPort; 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; diff --git a/IIOBindings.pas b/IIOBindings.pas new file mode 100644 index 0000000..b05ce4c --- /dev/null +++ b/IIOBindings.pas @@ -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. Этого набора хватает + на фазы 1–4 (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. diff --git a/MainForm.pas b/MainForm.pas index f8130f5..63b0dfc 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -42,6 +42,7 @@ uses ChannelStore, ChannelsForm, PowerInhibit, DeviceStore, + RadioBackend, PlutoBackend, RadioController, ChannelController; const @@ -324,6 +325,9 @@ type procedure OnWidebandCB(ADCIndex: Integer; const Samples: array of SmallInt; Count: Integer); procedure OnWDSPOpenDone(Success: Boolean); + function ResolveDevice(const IP: string): THPSDRDevice; + procedure RelabelBands; // подписи band-кнопок под план бэкенда + procedure ApplyBackendCapsToUI; // sample-rate пресеты + гейтинг полей по Caps procedure DoConnectDevice(const Dev: THPSDRDevice); // Старт с уже выбранным устройством (preload rate + WDSP open + connect). // Переиспользуется десктоп-диалогом и web-оверлеем — без модального диалога. @@ -1313,6 +1317,8 @@ begin // возвращаем живое значение в persist-буфер, чтобы blob нёс актуальную тему. FController.FLoadedGlobal.LightTheme := FLightTheme; FCATAdapter.ApplySettings(FController.FLoadedGlobal); + RelabelBands; // HF ↔ VHF/UHF подписи под тип бэкенда + ApplyBackendCapsToUI; // пресеты sample rate + гейтинг полей RebuildXvtrButtons; PushXvtrToWeb; TrkDrive.Position := FController.FDrivePercent; // слайдер ← drive% @@ -2664,10 +2670,12 @@ const MARGIN_Y = 4; begin if (FSampleRateOverlay = nil) or (PbSpectrum = nil) then Exit; + // Ширина = доступная ширина спектра справа от MARGIN_X (контент сам клампится + // к этому пределу). Раньше фиксировалось 320px → 8 Pluto-кнопок не влезали. FSampleRateOverlay.SetBounds( MARGIN_X, MARGIN_Y, - FSampleRateOverlay.Width, + Max(320, PbSpectrum.Width - MARGIN_X * 2), FSampleRateOverlay.Height); end; @@ -3296,7 +3304,40 @@ end; procedure TMainForm.BtnDiscoverFromDialog(Sender: TObject); // Запускается когда пользователь нажимает DISCOVER внутри диалога. // Контроллер чистит список и стартует поток; UI слушает rfDeviceList. +// Сетевые Pluto (без mDNS) не находятся scan'ом — собираем список URI для +// прямого probe: сохранённые Pluto + ручной ввод IP в поле диалога. +var + URIs: array of string; + i: Integer; + ip: string; + + procedure AddURI(const U: string); + var k: Integer; + begin + if U = '' then Exit; + for k := 0 to High(URIs) do if SameText(URIs[k], U) then Exit; + SetLength(URIs, Length(URIs) + 1); + URIs[High(URIs)] := U; + end; + begin + SetLength(URIs, 0); + for i := 0 to FController.FDeviceStore.SavedCount - 1 do + if FController.FDeviceStore.Saved(i).Kind = bkPluto then + AddURI(FController.FDeviceStore.Saved(i).URI); + if Assigned(FDeviceDialog) then + begin + ip := FDeviceDialog.ManualIP; + if ip <> '' then + begin + if (Pos('ip:', ip) = 1) or (Pos('usb:', ip) = 1) or (Pos('local:', ip) = 1) then + AddURI(ip) + else + AddURI('ip:' + ip); + end; + end; + FController.SetPlutoProbeURIs(URIs); + SetStatusText(2, 'Discovering...'); FController.Discover; end; @@ -3352,13 +3393,52 @@ begin end; end; - // Создаём запись устройства из IP + BoardType (из диалога/autostart) и стартуем. - FillChar(Dev, SizeOf(Dev), 0); - Dev.IPAddress := FPendingIP; - Dev.BoardType := FPendingBoardType; + // Создаём запись устройства. Если это известный (discovered/saved) Pluto — + // восстанавливаем Kind/URI/Serial/MAC из store по IP; иначе HPSDR по IP. + Dev := ResolveDevice(FPendingIP); + if Dev.Kind = bkHPSDR then Dev.BoardType := FPendingBoardType; StartWithDevice(Dev); end; +function TMainForm.ResolveDevice(const IP: string): THPSDRDevice; +// По IP/URI ищет устройство среди discovered, затем saved, чтобы восстановить +// Kind/URI/Serial/MAC (нужно для Pluto). Если не найдено — простой HPSDR по IP. +var + i: Integer; + dd: TDiscoveredDevice; + sd: TSavedDevice; +begin + // Result — managed-поля (строки) при входе nil, FillChar безопасен. + FillChar(Result, SizeOf(Result), 0); + Result.Kind := bkHPSDR; + Result.IPAddress := IP; + + i := FController.FDeviceStore.FindDiscoveredByIP(IP); + if i >= 0 then + begin + dd := FController.FDeviceStore.Discovered(i); + Result.Kind := dd.Kind; + Result.URI := dd.URI; + Result.Serial := dd.Serial; + Result.BoardType := dd.BoardType; + Move(dd.MAC[0], Result.MAC[0], 6); + Exit; + end; + + for i := 0 to FController.FDeviceStore.SavedCount - 1 do + begin + sd := FController.FDeviceStore.Saved(i); + if SameText(sd.IPAddress, IP) or SameText(sd.URI, IP) then + begin + Result.Kind := sd.Kind; + Result.URI := sd.URI; + Result.Serial := sd.Serial; + Result.BoardType := sd.BoardType; + Exit; + end; + end; +end; + procedure TMainForm.DoStopAndDisconnect; // STOP-teardown, общий для десктоп-STOP и web-disconnect. Движок (graceful Run=0, // сброс TX/телеметрии) + персист настроек + Disconnect — в контроллере @@ -3435,6 +3515,33 @@ begin DoConnectDevice(FPendingDev); end; +procedure TMainForm.RelabelBands; +// Переписывает подписи band-кнопок под план активного бэкенда (HF ↔ VHF/UHF). +var i: Integer; pl: Boolean; +begin + pl := FController.IsPluto; + for i := 0 to BAND_COUNT - 1 do + if BtnBand[i] <> nil then + BtnBand[i].Caption := BandName(i, pl); +end; + +procedure TMainForm.ApplyBackendCapsToUI; +// Пресеты sample-rate оверлея из Caps (Pluto >560k) + (TODO) гейтинг полей, +// которых нет у бэкенда (PA/SWR/Alex/Wideband). +var + Caps: TBackendCaps; +begin + Caps := FController.BackendCaps; + if Assigned(FSampleRateOverlay) then + begin + if (Caps.SampleRateMode = srmContinuous) and (Length(Caps.RatePresets) > 0) then + FSampleRateOverlay.SetRatePresets(Caps.RatePresets) + else + FSampleRateOverlay.SetRatePresetsDefault; // HPSDR 48k..1536k + FSampleRateOverlay.SetCurrentRate(FController.FSampleRate); + end; +end; + procedure TMainForm.DoConnectDevice(const Dev: THPSDRDevice); begin BtnStartStop.Enabled := True; @@ -3798,7 +3905,7 @@ begin if FController.FCurrentXvtr < 0 then begin - BandIdx := FreqToBandIdx(ClickFreq); + BandIdx := FreqToBandIdx(ClickFreq, FController.IsPluto); if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then begin if FController.FDevConnected then @@ -3834,7 +3941,7 @@ begin end; if FController.FCurrentXvtr < 0 then begin - BandIdx := FreqToBandIdx(FController.FVfoB); + BandIdx := FreqToBandIdx(FController.FVfoB, FController.IsPluto); if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then begin if FController.FCurrentBand >= 0 then @@ -5114,10 +5221,14 @@ begin NewFreq := Base; // снэп к нижней границе end; - // В XVTR-режиме HF-клэмп не применяем: ApplyVfoA сам ограничит по FreqBegin/FreqEnd + // Клэмп диапазона перестройки. XVTR — ApplyVfoA сам ограничит по FreqBegin/End. + // Pluto — по диапазону бэкенда (Caps). Иначе HF (30 кГц..60 МГц). if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then NewFreq := Max(0, NewFreq) + else if FController.IsPluto then + NewFreq := Max(Round(FController.BackendCaps.MinFreqHz), + Min(Round(FController.BackendCaps.MaxFreqHz), NewFreq)) else NewFreq := Max(30000, Min(60000000, NewFreq)); if FController.FActiveVfo = 0 then @@ -5329,8 +5440,17 @@ begin FController.FLoadedGlobal.FreqMhzDigits := Digits; // зеркало в persist-буфер контроллера FreqDispA.MinMhzDigits := Digits; FreqDispB.MinMhzDigits := Digits; - FreqDispA.MaxFreq := MaxFreqs[Digits]; - FreqDispB.MaxFreq := MaxFreqs[Digits]; + // Pluto/VHF/UHF: верхняя граница из Caps (до 6 ГГц), иначе HF по числу цифр. + if FController.IsPluto then + begin + FreqDispA.MaxFreq := Round(FController.BackendCaps.MaxFreqHz); + FreqDispB.MaxFreq := Round(FController.BackendCaps.MaxFreqHz); + end + else + begin + FreqDispA.MaxFreq := MaxFreqs[Digits]; + FreqDispB.MaxFreq := MaxFreqs[Digits]; + end; if Assigned(FWebServer) then FWebServer.FreqMhzDigits := Digits; LayoutTopVfoBlock; diff --git a/PlutoBackend.pas b/PlutoBackend.pas new file mode 100644 index 0000000..0443614 --- /dev/null +++ b/PlutoBackend.pas @@ -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 (для фаз 2–4): + 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. diff --git a/RadioBackend.pas b/RadioBackend.pas new file mode 100644 index 0000000..5a05fad --- /dev/null +++ b/RadioBackend.pas @@ -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. diff --git a/RadioController.pas b/RadioController.pas index e9079d9..be720e4 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -48,7 +48,7 @@ interface uses Classes, SysUtils, Math, - HPSDRProtocol, HPSDRNetwork, + HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, WDSPEngine, AudioOutput, AudioInput, Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore; @@ -145,7 +145,9 @@ type FWaterfallBufCount: Integer; // ---- Движки (владение: см. шапку юнита) ---- - FNetwork: THPSDRNetwork; + // FNetwork — абстрактный бэкенд железа (TRadioBackend): конкретный класс + // (THPSDRNetwork / TPlutoBackend позже) выбирается при подключении. + FNetwork: TRadioBackend; FDSPEngine: TWDSPEngine; FAudioOut: TAudioOutput; FAudioIn: TAudioInput; @@ -272,6 +274,8 @@ type // через Invoke; Synchronize блокирующий — устройства приходят по одному). FPendingFoundDev: THPSDRDevice; FPendingFoundEntry: string; + // URI для probe сетевых Pluto при discovery (см. SetPlutoProbeURIs). + FPlutoProbeURIs: array of string; constructor Create; destructor Destroy; override; @@ -447,6 +451,17 @@ type function ConnectAndRun(const Dev: THPSDRDevice; SpectrumWidth: Integer): Boolean; // ConnectDevice + band/xvtr/vfo restore + go-live procedure StopAndDisconnect; // graceful Run=0 + персист настроек + Disconnect (headless) procedure Discover; + // Гарантирует, что FNetwork — бэкенд нужного типа (HPSDR/Pluto): при + // несовпадении пересоздаёт его, перенося колбэки данных. Вызывается из + // ConnectDevice по Dev.Kind. Discovery использует транзиентные экземпляры. + procedure EnsureBackend(Kind: TBackendKind); + // Возможности текущего бэкенда (для гейтинга UI / пресетов sample rate). + function BackendCaps: TBackendCaps; + // True если активный бэкенд — Pluto (VHF/UHF band-план вместо HF). + function IsPluto: Boolean; + // Список URI для probe сетевых Pluto (mDNS недоступен) — задаёт фронтенд + // перед Discover (сохранённые Pluto + ручной ввод IP). + procedure SetPlutoProbeURIs(const URIs: array of string); // ---- События наружу ---- property OnInvoke: TUIInvokeEvent read FOnInvoke write FOnInvoke; @@ -1074,8 +1089,8 @@ begin // Bandstack: VFO A и B независимы и могут стоять на разных бэндах. В кэш // ЭТОГО бэнда пишем только ту частоту VFO, что реально в нём — иначе чужая // частота (напр. VFO A на 40м при сохранении 30м) загрязнит memory бэнда. - if FreqToBandIdx(FVfoA) <> FCurrentBand then B.VfoA := FBandCache[FCurrentBand].VfoA; - if FreqToBandIdx(FVfoB) <> FCurrentBand then B.VfoB := FBandCache[FCurrentBand].VfoB; + if FreqToBandIdx(FVfoA, IsPluto) <> FCurrentBand then B.VfoA := FBandCache[FCurrentBand].VfoA; + if FreqToBandIdx(FVfoB, IsPluto) <> FCurrentBand then B.VfoB := FBandCache[FCurrentBand].VfoB; FBandCache[FCurrentBand] := B; FSettings.SaveBand(FDevMAC, FCurrentBand, FBandCache[FCurrentBand]); end; @@ -1220,6 +1235,14 @@ begin B := FBandCache[BandIdx]; FCurrentBand := BandIdx; + // Pluto: кэш диапазона может нести HF-частоту (дефолты Settings). Если VfoA не + // попадает в VHF-слот — берём частоту по умолчанию из плана. + if IsPluto and (FreqToBandIdx(B.VfoA, True) <> BandIdx) then + begin + B.VfoA := BandDefaultFreq(BandIdx, True); + B.VfoB := B.VfoA; + end; + ApplyBandDSP(B); // --- VFO / drive (только RestoreBand — bandstack-путь VFO не трогает) --- @@ -1303,7 +1326,7 @@ begin // FreqToBandIdx вернёт -1 для VHF/UHF). if FCurrentXvtr < 0 then begin - BandIdx := FreqToBandIdx(FVfoA); + BandIdx := FreqToBandIdx(FVfoA, IsPluto); if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then begin FCurrentBand := BandIdx; @@ -1333,7 +1356,7 @@ begin FVfoA := Hz; if FCurrentXvtr < 0 then begin - BandIdx := FreqToBandIdx(FVfoA); + BandIdx := FreqToBandIdx(FVfoA, IsPluto); if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then begin FCurrentBand := BandIdx; @@ -1369,7 +1392,7 @@ begin ApplyTuneCore(FVfoB); if FCurrentXvtr < 0 then begin - BandIdx := FreqToBandIdx(FVfoB); + BandIdx := FreqToBandIdx(FVfoB, IsPluto); if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then begin FCurrentBand := BandIdx; @@ -1403,7 +1426,7 @@ begin // --- Bandstack: активируемый VFO на другом ham-бэнде → восстановить бэнд --- if FCurrentXvtr < 0 then begin - NewBand := FreqToBandIdx(ActiveFreq); + NewBand := FreqToBandIdx(ActiveFreq, IsPluto); if (NewBand >= 0) and (NewBand <> FCurrentBand) then begin SaveCurrentBand; // сохранить покидаемый бэнд @@ -1482,7 +1505,7 @@ begin // при подсвеченной кнопке нового бэнда (рассинхрон). if FCurrentXvtr < 0 then begin - NewBand := FreqToBandIdx(ActiveFreq); + NewBand := FreqToBandIdx(ActiveFreq, IsPluto); if (NewBand >= 0) and (NewBand <> FCurrentBand) then begin SaveCurrentBand; @@ -2303,6 +2326,8 @@ function TRadioController.ConnectDevice(const Dev: THPSDRDevice): Boolean; var i: Integer; begin + // Подбираем бэкенд под тип устройства (HPSDR/Pluto) до подключения. + EnsureBackend(Dev.Kind); Result := FNetwork.Connect(Dev); if not Result then Exit; @@ -2341,6 +2366,14 @@ begin FSampleRate := FLoadedGlobal.SampleRate; FSpanHz := FSampleRate; end; + // Pluto: HPSDR-наследие rate (48..1536k, часто <520k) невалидно. Клампим к + // дефолту бэкенда, если ниже минимума (overlay покажет валидный набор). + with FNetwork.Caps do + if (Kind = bkPluto) and (FSampleRate < MinSampleRate) then + begin + FSampleRate := 1536000; + FSpanHz := FSampleRate; + end; FNRMode := EnsureRange(FLoadedGlobal.NRMode, 0, 4); FNBMode := EnsureRange(FLoadedGlobal.NBMode, 0, 2); FSNB := FLoadedGlobal.SNBEnabled; @@ -2420,6 +2453,16 @@ begin // Перестройка диапазона — команда несёт финальную перестройку по активному VFO. RestoreBand(FCurrentBand); + // Pluto/VHF: HF-частоты вне диапазона. Если восстановленный VFO ниже минимума + // бэкенда — ставим дефолт VHF (временно 100 МГц, пока нет VHF band-плана). + with FNetwork.Caps do + if (Kind = bkPluto) and (FVfoA < MinFreqHz) then + begin + FVfoB := 100000000; + FCenterFreq := 100000000; + SetVfoA(100000000); // перестроит дисплей + LO + end; + // Восстанавливаем XVTR-режим, если он был активен при выходе. FCurrentBand уже // загружен (HF-диапазон до XVTR) — ActivateXvtr только выставит FVfoA/FCurrentXvtr. if (FLoadedGlobal.LastXvtr >= 0) and (FLoadedGlobal.LastXvtr < CFG_XVTR_COUNT) @@ -2482,25 +2525,103 @@ begin end; procedure TDiscoverThread.Execute; +// Сканируем ОБА бэкенда транзиентными экземплярами (независимо от FNetwork — +// он может быть уже подключён/другого типа). Найденные сигналятся колбэком +// OnDeviceFound во время Discover() и копятся в store. var - Devs: THPSDRDeviceArray; + Found: Integer; + HP: THPSDRNetwork; + Pluto: TPlutoBackend; + PDev: TRadioDevice; + i: Integer; begin - Devs := FCtrl.FNetwork.Discover(2000); - // Найденные устройства сигналятся колбэком OnDeviceFound во время Discover(). - if Length(Devs) = 0 then + Found := 0; + + // openHPSDR (UDP broadcast) + HP := THPSDRNetwork.Create; + try + HP.OnDeviceFound := FCtrl.OnDeviceFound; + Found := Found + Length(HP.Discover(2000)); + finally + HP.Free; + end; + + // Pluto (libiio scan + прямой probe сетевых URI) — если libiio доступна. + if IIOLoad then + begin + Pluto := TPlutoBackend.Create; + try + Pluto.OnDeviceFound := FCtrl.OnDeviceFound; + // USB scan + Found := Found + Length(Pluto.Discover(1500)); + // Прямой probe сетевых адресов (mDNS недоступен) — из списка фронтенда. + for i := 0 to High(FCtrl.FPlutoProbeURIs) do + if Pluto.ProbeURI(FCtrl.FPlutoProbeURIs[i], PDev) then + Inc(Found); + finally + Pluto.Free; + end; + end; + + if Found = 0 then FCtrl.Invoke(FCtrl.DiscoverFinishedNone); end; -procedure TRadioController.OnDeviceFound(const Dev: THPSDRDevice); -// Сетевой поток: строим строку для списка → pending → маршалинг в ApplyDeviceFound. +procedure TRadioController.EnsureBackend(Kind: TBackendKind); +// Пересоздаёт FNetwork нужного класса, перенося колбэки данных. No-op если тип +// уже верный. Старый бэкенд отключается и освобождается. var - BoardName: string; + NewB: TRadioBackend; begin - BoardName := BoardTypeName(Dev.BoardType); - FPendingFoundDev := Dev; - FPendingFoundEntry := Format('%s %s FW:%d DDC:%d', - [Dev.IPAddress, BoardName, - Dev.FirmwareVersion, Dev.NumDDCs]); + if (FNetwork <> nil) and (FNetwork.Caps.Kind = Kind) then Exit; + + case Kind of + bkPluto: NewB := TPlutoBackend.Create; + else NewB := THPSDRNetwork.Create; + end; + + if FNetwork <> nil then + begin + if FNetwork.Connected then FNetwork.Disconnect; + NewB.OnDeviceFound := FNetwork.OnDeviceFound; + NewB.OnDDCIQ := FNetwork.OnDDCIQ; + NewB.OnMicPacket := FNetwork.OnMicPacket; + NewB.OnHPStatus := FNetwork.OnHPStatus; + NewB.OnWideband := FNetwork.OnWideband; + FreeAndNil(FNetwork); + end; + FNetwork := NewB; +end; + +function TRadioController.BackendCaps: TBackendCaps; +begin + if FNetwork <> nil then Result := FNetwork.Caps + else FillChar(Result, SizeOf(Result), 0); +end; + +function TRadioController.IsPluto: Boolean; +begin + Result := (FNetwork <> nil) and (FNetwork.Caps.Kind = bkPluto); +end; + +procedure TRadioController.SetPlutoProbeURIs(const URIs: array of string); +var i: Integer; +begin + SetLength(FPlutoProbeURIs, Length(URIs)); + for i := 0 to High(URIs) do FPlutoProbeURIs[i] := URIs[i]; +end; + +procedure TRadioController.OnDeviceFound(const Dev: THPSDRDevice); +// Сетевой/scan-поток: строим строку для списка → pending → маршалинг в +// ApplyDeviceFound. Строка зависит от типа бэкенда (HPSDR vs Pluto). +begin + FPendingFoundDev := Dev; + if Dev.Kind = bkPluto then + FPendingFoundEntry := Format('PlutoSDR %s', [Dev.URI]) + else + FPendingFoundEntry := Format('%s %s FW:%d DDC:%d', + [Dev.IPAddress, BoardTypeName(Dev.BoardType), + Dev.FirmwareVersion, Dev.NumDDCs]); Invoke(ApplyDeviceFound); end; @@ -2509,7 +2630,9 @@ procedure TRadioController.ApplyDeviceFound; begin FDiscoverNoneFound := False; FDeviceStore.AddDiscovered(FPendingFoundDev.IPAddress, FPendingFoundEntry, - FPendingFoundDev.BoardType, FPendingFoundDev.MAC); + FPendingFoundDev.BoardType, FPendingFoundDev.MAC, + FPendingFoundDev.Kind, FPendingFoundDev.URI, + FPendingFoundDev.Serial); Changed(rfDeviceList); end; diff --git a/SampleRateOverlay.pas b/SampleRateOverlay.pas index 5a0174b..8b9fd21 100644 --- a/SampleRateOverlay.pas +++ b/SampleRateOverlay.pas @@ -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 шт.); имена формируются как '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; diff --git a/doc/PLUTO_INTEGRATION_PLAN.md b/doc/PLUTO_INTEGRATION_PLAN.md new file mode 100644 index 0000000..a4454a1 --- /dev/null +++ b/doc/PLUTO_INTEGRATION_PLAN.md @@ -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 практически ~5–6 MSPS) | +| RF-полоса (аналог. фильтр) | нет (фикс CIC) | отдельная настройка RX/TX LPF | +| Диапазон частот | 0–61.44 МГц (HF/6m) | 325 МГц–3.8 ГГц (сток), **70 МГц–6 ГГц с хаком**. HF не используем | +| Разрядность IQ | 24-бит | 12-бит ADC, по шине 16-бит | +| Усиление RX | step atten + dither/random | manual gain 0–73 дБ / 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 м | 144–146 МГц | требует freq-range хак Pluto (<325 МГц) | +| 70 см | 430–440 МГц | требует freq-range хак Pluto | +| 23 см | 1240–1300 МГц | нативно | +| 13 см | 2300–2450 МГц | нативно (включает 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.50–10489.99 МГц (10 ГГц). Принимается через LNB: + LNB LO (обычно 9750 МГц) → Pluto RX ≈ 739.5–739.99 МГц. Нативный диапазон Pluto. +- **Uplink (TX):** 2400.05–2400.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 (0–73 дБ) / режим 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 | diff --git a/ewsdr.lpi b/ewsdr.lpi index 559baa3..3838189 100644 --- a/ewsdr.lpi +++ b/ewsdr.lpi @@ -17,36 +17,23 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/test_pluto.lpr b/test_pluto.lpr new file mode 100644 index 0000000..1a9c4a5 --- /dev/null +++ b/test_pluto.lpr @@ -0,0 +1,83 @@ +program test_pluto; + +{$MODE Delphi} + +// Ручной тест Pluto/AD936x-бэкенда против реального железа (без UI/DSP). +// Сборка: fpc -Mobjfpc -dHEADLESS -Fu. -FUlib-headless/ 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.