{
  Copyright (C)
  2026 - Uladzimir Karpenka, EW8BAK

  This program is free software; you can redistribute it and/or
  modify it under the terms of the GNU General Public License
  as published by the Free Software Foundation; either version 2
  of the License, or (at your option) any later version.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program; if not, write to the Free Software
  Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
}

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;
    FFreqCalPPM: Double;    // частотная калибровка опорника, ppm (xo_correction)
    FXoNomHz:    Int64;     // номинал XO, Гц (снимается при коннекте, 0 = не знаем)
    FGainMode:   string;    // gain_control_mode (manual/slow_attack/...)
    FGainDb:     Integer;   // hardwaregain при ручном режиме

    FTxAttDb:    Double;    // hardwaregain voltage0(out), дБ (<0); off на приёме

    // RX-стрим (cf-ad9361-lpc): I/Q-каналы, буфер, поток refill.
    FRxBuf:      Piio_buffer;
    FRxChI:      Piio_channel;
    FRxChQ:      Piio_channel;
    FRxThread:   TThread;
    FStreaming:  Boolean;
    FRxSeq:      LongWord;
    // Второй антенный вход (RX2). Два разных механизма — какой доступен, зависит
    // от прошивки/FPGA-образа платы:
    //  • 2R2T-образ: второй канал инстанцирован, в cf-ad9361-lpc есть voltage2/3 →
    //    выбор = пара стрим-каналов (FRx2Avail). Задел на второй пан (этап 3.6).
    //  • 1R1T-образ (напр. LibreSDR/tezuka): второй канал в FPGA не разведён, но
    //    внутри AD9361 есть мультиплексор — какой физический разъём кормит
    //    единственный тракт. Выбор = debug-атрибуты 'adi,1rx-1tx-mode-use-rx-num'/
    //    '-tx-num' (1|2) + 'initialize'=1 (драйвер заново прогоняет ad9361_setup
    //    из pdata). ⚠Сырые записи битов RX1/RX2 в регистры 0x2/0x3 (как в
    //    switch_rfinput.sh прошивки) НЕ годятся: тракт переключается, а учёт
    //    драйвера (gain_control_mode/agc_mode на канал) остаётся от старого —
    //    hardwaregain потом отвергается («Rx gain can be set in MGC mode only»),
    //    а в железе продолжает крутиться AGC. rf_port_select тут ни при чём:
    //    RX2-разъём разведён на второй КАНАЛ, а не на порт B первого.
    FRx2Avail:   Boolean;   // 2R2T: в cf-ad9361-lpc есть voltage2/voltage3
    FRxMuxAvail: Boolean;   // 1R1T: доступен RX/TX-мультиплексор AD9361
    FRxChanSel:  Byte;      // 0 = RX1, 1 = RX2
    FTxChanSel:  Byte;      // 0 = TX1, 1 = TX2

    // TX-стрим (cf-ad9361-dds-core-lpc): I/Q output-каналы, буфер, поток push.
    // Full-duplex: стрим живёт всё время Run; на приёме гоним нули (мощности нет
    // за счёт макс. аттенюации). IQ от WDSP (192k) апсемплятся ×FTxRatio до рейта.
    FTxBuf:      Piio_buffer;
    FTxChI:      Piio_channel;
    FTxChQ:      Piio_channel;
    FTxThread:   TThread;
    FTxStreaming: Boolean;
    FTxRatio:    Integer;   // FSampleRate div 192000 (целочисленный апсемпл ×L)
    FTxBufPairs: Integer;   // размер TX-буфера в IQ-парах (по sample-rate, см. TxBufPairsForRate)
    // Полифазный FIR-интерполятор: FTxCoef[p*TAPS + k] = h[p + k*L] (фаза p, отвод k).
    // Линия задержки FTxDlI/Q хранит последние TAPS входных сэмплов между вызовами.
    FTxCoef:     array of Double;
    FTxDlI:      array of Double;
    FTxDlQ:      array of Double;
    // FIFO int16-пар @ device-rate (кольцо): продюсер — SendDUCIQ, консьюмер — push-поток.
    FTxFifo:     array of SmallInt;   // интерливнутые I,Q
    FTxFifoCap:  Integer;             // ёмкость в SmallInt-элементах (пар*2)
    FTxFifoHead: Integer;             // индекс чтения (консьюмер)
    FTxFifoTail: Integer;             // индекс записи (продюсер)
    FTxFifoCount: Integer;            // занято элементов
    FTxLock:     TRTLCriticalSection;

    // Телеметрия. Каждый iio_channel_attr_read_* — USB round-trip (~5 мс), а их
    // 5-6 за опрос: ~30 мс. Раньше это делал UI-поток (MeterTimerTick, 10 Гц) и
    // замирал на весь опрос. Теперь опрашивает свой поток, а ReadTelemetry лишь
    // отдаёт последний снимок и никогда не блокирует.
    FTeleThread: TThread;
    FTeleLock:   TRTLCriticalSection;
    FTeleTemp:   Double;    // TELEMETRY_NONE, пока не прочитано
    FTeleSoc:    Double;
    FTeleRssi:   Double;
    FTeleGain:   Double;    // hardwaregain RX, дБ (в AGC-режимах крутит железо)
    FTeleValid:  Boolean;   // был ли хоть один успешный опрос
    procedure StartTelemetry;
    procedure StopTelemetry;
    // Блокирующий опрос железа: только из FTeleThread.
    function  PollTelemetryBlocking(out TempC, SocTempC, RSSIdB,
                                    GainDb: Double): Boolean;

    // Низкоуровневые помощники применения атрибутов к ad9361-phy
    procedure ApplyFreqCal;
    procedure ApplyRxLO(Hz: Double);
    procedure ApplyTxLO(Hz: Double);
    procedure SetTxLOPowerdown(PoweredOn: Boolean);   // TX LO вкл/выкл (по PTT)
    procedure ApplySampleRate(Hz: Integer);
    procedure BuildTxResampler;   // (пере)строить полифазные коэффициенты под FTxRatio
    // Force=True — перед записью режима пишем ДРУГОЙ режим: драйвер пропускает
    // перепрог AGC-регистров, если считает режим неизменным, а после смены
    // антенного мультиплексора настройки AGC нового канала ещё не запрограммированы
    // (в железе крутится auto-AGC, hardwaregain отвергается с EOPNOTSUPP).
    procedure ApplyGain(Force: Boolean = False);
    procedure ApplyTxAtten;
    // Имя phy-канала управления выбранным RX-трактом (гейн/RSSI/rf_bandwidth):
    // 'voltage0' = RX1, 'voltage1' = RX2 (только 2R2T; в 1R1T канал всегда один).
    function  RxPhyChanName: string;
    // 1R1T: переключить мультиплексор AD9361 на выбранные разъёмы (см. FRxMuxAvail).
    // Дёшево: debug-атрибут (маппинг каналов у драйвера) + биты регистра 0x3/0x2.
    procedure ApplyRxMux;
    procedure ApplyTxMux;
    procedure StartRX;
    procedure StopRX;
    procedure StartTX;
    procedure StopTX;
  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;
                          Tuning: Boolean = False; TwoTone: Boolean = False); override;
    procedure SendFullHP; override;
    procedure SendPTT(Active: Boolean); override;
    procedure SetAntPorts(RxPort, TxPort: Byte); override;
    procedure SetRxGain(ModeIdx: Integer; GainDb: Integer); override;
    procedure SetTxAtten(Db: Double); override;
    procedure SendDUCIQ(const IData, QData: array of Integer); override;
    procedure ClearDUCIQQueue; override;
    function  ReadTelemetry(out TempC, SocTempC, RSSIdB: Double): Boolean; override;
    function  RxGainNowDb: Double; override;
    procedure SetFreqCalPPM(P: Double); 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_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)
  TX_SRC_RATE    = 192000;   // фиксированный TX-рейт WDSP (TX_SAMPLE_RATE)
  // Размер TX-буфера масштабируется от sample-rate:
  // round(rate/60) → следующая степень 2. Фикс 4096 «недостаточно для full-duplex
  // выше 1 Msps — сэмплы дропаются» (комментарий драйвера). Минимум — TX_BUF_MIN.
  TX_BUF_MIN     = 4096;     // нижняя граница (~7 мс @ 576k)
  TX_BUF_MAX     = 65536;    // верхняя граница (защита от гигантских буферов)
  // Полифазный анти-имидж интерполятор 192k→device-rate (вместо линейной интерполяции,
  // которая оставляла образы на кратных 192k с подавлением только sinc²).
  TX_FIR_TAPS    = 16;       // отводов на фазу (групп-задержка TAPS/2 входных сэмплов)
  TX_FIR_CUT     = 40000;    // частота среза анти-имидж ФНЧ, Гц (покрывает SSB/NFM/AM/data)
  PLUTO_TX_OFF   = -89.75;   // макс. аттенюация AD9361 — TX практически выключен

// Детект доступности антенного мультиплексора (тело ниже — нужно до Connect).
function DebugAttrExists(Dev: Piio_device; const Attr: string): Boolean; forward;

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;

  // Телеметрия: опрашивает железо в своём темпе и публикует снимок. Держит UI
  // подальше от блокирующих USB-транзакций. Доступ к контексту libiio здесь не
  // более конкурентен, чем раньше: UI-поток дёргал те же атрибуты параллельно с
  // refill в RX-потоке — сменился только поток, который ждёт.
  TPlutoTeleThread = class(TThread)
  private
    FBk: TPlutoBackend;
  protected
    procedure Execute; override;
  public
    constructor Create(ABk: TPlutoBackend);
  end;

  // TX-поток: тянет int16-пары из FIFO бэкенда (или нули при underrun), пишет в
  // iio-буфер и push'ит. iio_buffer_push блокирует до приёма DMA → пейсинг ровно
  // на device-rate. Работает с private-полями TPlutoBackend (одна единица компиляции).
  TPlutoTXThread = 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;

constructor TPlutoTXThread.Create(ABk: TPlutoBackend);
begin
  FBk := ABk;
  FreeOnTerminate := False;
  inherited Create(False);
end;

procedure TPlutoTXThread.Execute;
var
  step: ptrint;
  pFirst, pEnd, p: PByte;
  h: Integer;
  n: ptrint;
begin
  while not Terminated do
  begin
    if FBk.FTxBuf = nil then begin Sleep(5); Continue; end;
    step   := iio_buffer_step(FBk.FTxBuf);
    pFirst := iio_buffer_first(FBk.FTxBuf, FBk.FTxChI);
    pEnd   := iio_buffer_end(FBk.FTxBuf);

    // Наполняем буфер из FIFO; при underrun добиваем нулями (full-duplex idle).
    EnterCriticalSection(FBk.FTxLock);
    p := pFirst;
    while PtrUInt(p) < PtrUInt(pEnd) do
    begin
      if FBk.FTxFifoCount > 0 then
      begin
        h := FBk.FTxFifoHead;
        PSmallInt(p)^     := FBk.FTxFifo[h];
        PSmallInt(p + 2)^ := FBk.FTxFifo[h + 1];
        FBk.FTxFifoHead   := (h + 2) mod FBk.FTxFifoCap;
        Dec(FBk.FTxFifoCount, 2);
      end
      else
      begin
        PSmallInt(p)^     := 0;
        PSmallInt(p + 2)^ := 0;
      end;
      Inc(p, step);
    end;
    LeaveCriticalSection(FBk.FTxLock);

    if Terminated then Break;
    n := iio_buffer_push(FBk.FTxBuf);   // блокирует → пейсинг на device-rate
    if n < 0 then Sleep(2);
  end;
end;

function TxBufPairsForRate(Rate: Integer): Integer;
// Размер TX-буфера в IQ-парах под device-rate: round(rate/60)
// округлённое вверх до степени 2, с клампом [TX_BUF_MIN..TX_BUF_MAX].
// 576k→16384, 1536k→32768, 3072k→65536.
var
  Target, P: Integer;
begin
  Target := (Rate + 30) div 60;          // round(rate/60)
  P := TX_BUF_MIN;
  while P < Target do P := P shl 1;       // следующая степень 2 ≥ Target
  if P < TX_BUF_MIN then P := TX_BUF_MIN;
  if P > TX_BUF_MAX then P := TX_BUF_MAX;
  Result := P;
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;
  FFreqCalPPM := 0.0; FXoNomHz := 0;
  FRxBuf := nil; FRxChI := nil; FRxChQ := nil; FRxThread := nil;
  FStreaming := False; FRxSeq := 0;
  FTxBuf := nil; FTxChI := nil; FTxChQ := nil; FTxThread := nil;
  FTxStreaming := False; FTxRatio := 8;
  FTeleThread := nil; FTeleValid := False;
  FTeleTemp := TELEMETRY_NONE; FTeleSoc := TELEMETRY_NONE; FTeleRssi := TELEMETRY_NONE;
  FTeleGain := TELEMETRY_NONE;
  InitCriticalSection(FTeleLock);
  FTxAttDb := PLUTO_TX_OFF;   // на старте TX выключен (макс. аттенюация)
  // FIFO на ~4 TX-буфера запаса под джиттер продюсера/консьюмера. Окончательный
  // размер выставляется в StartTX по фактическому device-rate (TxBufPairsForRate).
  FTxBufPairs := TxBufPairsForRate(FSampleRate);
  FTxFifoCap  := FTxBufPairs * 2 * 4;
  SetLength(FTxFifo, FTxFifoCap);
  FTxFifoHead := 0; FTxFifoTail := 0; FTxFifoCount := 0;
  InitCriticalSection(FTxLock);
  FillChar(FDevice, SizeOf(FDevice), 0);
end;

destructor TPlutoBackend.Destroy;
begin
  Disconnect;          // остановит и телеметрию
  DoneCriticalSection(FTeleLock);
  DoneCriticalSection(FTxLock);
  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.HasCWKeyer      := False;   // голый AD936x — телеграф формирует PC
  Result.HasPLLStatus    := False;
  Result.HasHWGain       := True;    // manual gain / hw-AGC
  Result.HasRFBandwidth  := True;
  Result.HasFullDuplex   := True;
  Result.MinSampleRate   := PLUTO_MIN_SR;
  // Пресеты для 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;
  // Стримим ровно один тракт ⇒ только главный пан. Одновременный приём с двух
  // антенн (второй пан на RX2, общий RX LO) — этап 3.6, нужен 2R2T-образ.
  Result.MaxPans            := 1;
  Result.IndependentPanFreq := False;
  // Пан один ⇒ ADC-селектор панов не при чём: у AD936x «антенна» — это выбор
  // физических разъёмов (RX1/RX2, TX1/TX2) на диапазон, вкладка Antenna настроек.
  Result.NumADCs     := 1;
  Result.HasAntSwitch := FRx2Avail or FRxMuxAvail;
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;
  PV: PAnsiChar;
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;

  // 2R2T-образ — по НАЛИЧИЮ каналов второго тракта (клоны не отличить по строкам
  // описания). Управление RX2 (гейн/RSSI) — phy input voltage1, требуем и его.
  FRx2Avail := (FRxDev <> nil) and
               (iio_device_find_channel(FRxDev, 'voltage2', 0) <> nil) and
               (iio_device_find_channel(FRxDev, 'voltage3', 0) <> nil) and
               (iio_device_find_channel(FPhy,   'voltage1', 0) <> nil);
  // Модель берём из ЖИВОГО контекста: в записи устройства она могла приехать из
  // сохранёнки/фолбэка ProbeURI ('AD936x SDR') и не содержать имени платы.
  PV := iio_context_get_attr_value(FCtx, 'hw_model');
  if PV <> nil then FDevice.Model := string(PV);
  // 1R1T-образ: RX/TX-мультиплексор AD9361. Определяем по реально
  // доступным debug-атрибутам, а не по hw_model: у обычного Pluto второй
  // канал могли вывести аппаратно, не меняя строку модели.
  FRxMuxAvail := (not FRx2Avail) and
                 Assigned(iio_device_debug_attr_write) and
                 DebugAttrExists(FPhy, 'adi,1rx-1tx-mode-use-rx-num') and
                 DebugAttrExists(FPhy, 'adi,1rx-1tx-mode-use-tx-num') and
                 DebugAttrExists(FPhy, 'initialize');
  if not (FRx2Avail or FRxMuxAvail) then FRxChanSel := 0;

  FConnected := True;
  // ★Калибровки — в автомат. AD936x нулит утечку гетеродина и квадратурный
  // разбаланс передатчика калибровкой TX quad, и в режиме 'auto' драйвер сам
  // прогоняет её на каждой смене TX LO. Найдено на живом Pluto: там стояло
  // 'manual_tx_quad', то есть калибровка НЕ переигрывалась вовсе — утечка LO
  // (в телеграфе она видна отдельной несущей в стороне от рабочей частоты)
  // оставалась такой, какой её оставила прошлая программа. Состояние живёт в
  // чипе между сессиями, поэтому выставляем явно.
  if Assigned(iio_device_attr_write) then
    iio_device_attr_write(FPhy, 'calib_mode', 'auto');
  // Приводим железо в известное состояние: валидный rate + вход + усиление.
  if FSampleRate < PLUTO_MIN_SR then FSampleRate := PLUTO_DEF_SR;
  ApplySampleRate(FSampleRate);
  ApplyRxMux;     // разъёмы могли остаться от прошлой сессии (состояние в чипе)
  ApplyTxMux;
  ApplyGain(True);
  ApplyTxAtten;   // TX заглушён (макс. аттенюация) до явного PTT
  ApplyFreqCal;   // частотная калибровка опорника (xo_correction)
  StartTelemetry;
  Result := True;
end;

procedure TPlutoBackend.Disconnect;
begin
  StopTelemetry;   // до iio_context_destroy: поток ещё читает атрибуты
  StopTX;
  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.ApplyFreqCal;
// xo_correction — частота опорного XO в Гц, какой её считает драйвер. Правка
// разъезжается на ВСЁ: RX LO, TX LO, sample rate — один параметр вместо
// подгонки offset'ов по трансвертерам.
//   Номинал снимаем округлением прочитанного до 100 кГц: в железе могла
//   остаться наша же коррекция с прошлой сессии (она сбрасывается только по
//   перезагрузке платы), а ±100 ppm от 40 МГц — это ±4 кГц, округление их
//   съедает и компаундинга не будет.
var
  V: clonglong;
begin
  if (FPhy = nil) or (not IIOLoaded)
     or (not Assigned(iio_device_attr_read_longlong))
     or (not Assigned(iio_device_attr_write_longlong)) then Exit;
  if FXoNomHz = 0 then
  begin
    if iio_device_attr_read_longlong(FPhy, 'xo_correction', V) <> 0 then Exit;
    if V <= 0 then Exit;
    FXoNomHz := (Int64(V) + 50000) div 100000 * 100000;
  end;
  iio_device_attr_write_longlong(FPhy, 'xo_correction',
    Round(FXoNomHz * (1.0 + FFreqCalPPM * 1.0E-6)));
  // Синтезаторы считают делители в момент записи частоты — переписываем LO,
  // иначе правка подхватится только на следующей перестройке.
  if FRXFreq > 0 then ApplyRxLO(FRXFreq);
  if FTXFreq > 0 then ApplyTxLO(FTXFreq);
end;

procedure TPlutoBackend.SetFreqCalPPM(P: Double);
begin
  FFreqCalPPM := P;
  ApplyFreqCal;
end;

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
  begin
    iio_channel_attr_write(Ch, 'powerdown', '0');   // вернуть LO, если внешнее ПО его погасило
    iio_channel_attr_write_longlong(Ch, 'frequency', Round(Hz));
  end;
end;

procedure TPlutoBackend.SetTxLOPowerdown(PoweredOn: Boolean);
// TX LO (altvoltage1) вкл/выкл. Гасим на приёме → нет утечки несущей TX на частоте
// VFO в RX-спектре; включаем на PTT. Также возвращает LO, если стороннее ПО его
// погасило. RX LO (altvoltage0) не трогаем — приём непрерывен.
var Ch: Piio_channel;
begin
  if FPhy = nil then Exit;
  Ch := iio_device_find_channel(FPhy, 'altvoltage1', 1);
  if Ch = nil then Exit;
  if PoweredOn then iio_channel_attr_write(Ch, 'powerdown', '0')
  else              iio_channel_attr_write(Ch, 'powerdown', '1');
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));
  // powerdown TX LO по состоянию передачи (на приёме гасим — убирает спайк-утечку).
  SetTxLOPowerdown(FTransmitting);
end;

procedure TPlutoBackend.ApplySampleRate(Hz: Integer);
var Ch: Piio_channel; WasStreaming, WasTxStreaming: Boolean;
begin
  if (FPhy = nil) or (Hz < PLUTO_MIN_SR) then Exit;
  WasStreaming   := FStreaming;
  WasTxStreaming := FTxStreaming;
  if WasTxStreaming then StopTX;   // device-rate общий для RX/TX — пересоздать оба
  if WasStreaming then StopRX;     // менять rate с открытым буфером нельзя

  // Предпочтительно ad9361_set_bb_rate: настраивает BBPLL И загружает FIR-таблицу
  // децимации под частоту. Без этого на холодную (битый/дефолтный FIR) спектр —
  // широкая палка по центру, не реагирует на перестройку. Fallback — прямая
  // запись sampling_frequency (работает, если FIR уже корректен).
  if Assigned(ad9361_set_bb_rate) then
    ad9361_set_bb_rate(FPhy, culong(Hz))
  else
  begin
    Ch := iio_device_find_channel(FPhy, 'voltage0', 0);
    if Ch <> nil then
      iio_channel_attr_write_longlong(Ch, 'sampling_frequency', Hz);
  end;

  // RF-полоса отдельно (set_bb_rate её не трогает) — на phy-канале выбранного
  // приёмного тракта (у 2R2T они независимы).
  Ch := iio_device_find_channel(FPhy, PAnsiChar(AnsiString(RxPhyChanName)), 0);
  if Ch <> nil then
    iio_channel_attr_write_longlong(Ch, 'rf_bandwidth', Hz);

  if WasStreaming then StartRX;
  if WasTxStreaming then StartTX;   // пересоздаём TX с новым FTxRatio
end;

procedure TPlutoBackend.BuildTxResampler;
// Полифазный FIR-интерполятор ×L (L=FTxRatio): прототип — оконный sinc-ФНЧ длиной
// TAPS*L с частотой среза TX_FIR_CUT, нормированный так, что DC-усиление любой фазы
// ≈ 1 (полная сумма = L). Раскладка в фазы: coef[p*TAPS+k] = proto[p + k*L].
var
  L, M, n, p, k: Integer;
  fc, x, s, w, sum, scale: Double;
  proto: array of Double;
begin
  L := FTxRatio;
  if L < 1 then L := 1;
  M := TX_FIR_TAPS * L;
  SetLength(proto, M);
  fc := TX_FIR_CUT / FSampleRate;     // нормированный срез (циклов/сэмпл на выходе)
  if fc > 0.49 then fc := 0.49;
  sum := 0;
  for n := 0 to M - 1 do
  begin
    x := n - (M - 1) / 2.0;
    if Abs(x) < 1e-9 then s := 2.0 * fc
    else s := Sin(2.0 * Pi * fc * x) / (Pi * x);
    // окно Блэкмана (~ −58 дБ боковые)
    w := 0.42 - 0.5 * Cos(2.0 * Pi * n / (M - 1)) + 0.08 * Cos(4.0 * Pi * n / (M - 1));
    proto[n] := s * w;
    sum := sum + proto[n];
  end;
  if sum = 0 then sum := 1;
  scale := L / sum;                   // DC-усиление на фазу ≈ 1
  SetLength(FTxCoef, M);
  for p := 0 to L - 1 do
    for k := 0 to TX_FIR_TAPS - 1 do
      FTxCoef[p * TX_FIR_TAPS + k] := proto[p + k * L] * scale;
  // линия задержки на TAPS входных сэмплов (сброс)
  SetLength(FTxDlI, TX_FIR_TAPS);
  SetLength(FTxDlQ, TX_FIR_TAPS);
  for k := 0 to TX_FIR_TAPS - 1 do begin FTxDlI[k] := 0; FTxDlQ[k] := 0; end;
end;

function DebugAttrExists(Dev: Piio_device; const Attr: string): Boolean;
var Buf: array[0..63] of AnsiChar;
begin
  Result := Assigned(iio_device_debug_attr_read) and (Dev <> nil) and
            (iio_device_debug_attr_read(Dev, PAnsiChar(AnsiString(Attr)),
                                        @Buf[0], SizeOf(Buf)) >= 0);
end;

function TPlutoBackend.RxPhyChanName: string;
// Управляющий phy-канал приёмного тракта: RX1 = input voltage0, RX2 = voltage1.
// В 1R1T-образе канал ровно один (voltage0) — мультиплексор меняет вход ПОД ним,
// поэтому гейн/RSSI остаются на voltage0.
begin
  if (FRxChanSel = 1) and FRx2Avail then Result := 'voltage1'
  else                                   Result := 'voltage0';
end;

procedure TPlutoBackend.ApplyRxMux;
// Приёмный разъём: debug-параметр (по нему драйвер маппит gain-атрибуты на канал)
// + биты RX1/RX2 (маска 0xC0) регистра 0x003 — как switch_rfinput.sh прошивки.
// ⚠Вызывающий обязан следом сделать ApplyGain(True): AGC-регистры нового канала
// ещё не запрограммированы (см. ApplyGain).
var
  Val: cuint32;
  RxNum: Integer;
begin
  if not (FConnected and FRxMuxAvail and (FPhy <> nil)) then Exit;
  if FRxChanSel = 1 then RxNum := 2 else RxNum := 1;
  iio_device_debug_attr_write(FPhy, 'adi,1rx-1tx-mode-use-rx-num',
                              PAnsiChar(AnsiString(IntToStr(RxNum))));
  Val := 0;
  if iio_device_reg_read(FPhy, $3, Val) <> 0 then Exit;
  Val := Val and $3F;
  if FRxChanSel = 1 then Val := Val or $80   // RX2
  else                   Val := Val or $40;  // RX1
  iio_device_reg_write(FPhy, $3, Val);
end;

procedure TPlutoBackend.ApplyTxMux;
// То же для передачи (switch_rfoutput.sh): '-tx-num' + биты TX1/TX2 регистра 0x002.
var
  Val: cuint32;
  TxNum: Integer;
begin
  if not (FConnected and FRxMuxAvail and (FPhy <> nil)) then Exit;
  if FTxChanSel = 1 then TxNum := 2 else TxNum := 1;
  iio_device_debug_attr_write(FPhy, 'adi,1rx-1tx-mode-use-tx-num',
                              PAnsiChar(AnsiString(IntToStr(TxNum))));
  Val := 0;
  if iio_device_reg_read(FPhy, $2, Val) <> 0 then Exit;
  Val := Val and $3F;
  if FTxChanSel = 1 then Val := Val or $80   // TX2
  else                   Val := Val or $40;  // TX1
  iio_device_reg_write(FPhy, $2, Val);
end;

procedure TPlutoBackend.SetAntPorts(RxPort, TxPort: Byte);
// Выбор физических разъёмов: 0 = RX1/TX1, 1 = RX2/TX2. Зовётся контроллером при
// смене диапазона/трансвертера (антенна — свойство диапазона, см. вкладку Antenna).
var
  RxChanged, TxChanged: Boolean;
begin
  if RxPort > 1 then RxPort := 0;
  if TxPort > 1 then TxPort := 0;
  if not (FRx2Avail or FRxMuxAvail) then Exit;   // одна пара разъёмов — нечего выбирать
  RxChanged := RxPort <> FRxChanSel;
  TxChanged := TxPort <> FTxChanSel;
  if not (RxChanged or TxChanged) then Exit;
  FRxChanSel := RxPort;
  FTxChanSel := TxPort;
  if not FConnected then Exit;
  if FRx2Avail then
  begin
    // 2R2T-образ: разъём = ДРУГАЯ пара стрим-каналов ⇒ пересоздать буферы, затем
    // вернуть гейн/аттенюацию (у 2R2T они ещё и живут на phy-канале своего тракта).
    if RxChanged and FStreaming   then begin StopRX; StartRX; end;
    if TxChanged and FTxStreaming then begin StopTX; StartTX; end;
    ApplyGain;
    ApplyTxAtten;
  end
  else
  begin
    // 1R1T: мультиплексор внутри чипа — стримы/LO/rate не трогаем (дёшево).
    // Обе записи ДО восстановления усиления, и гейн — форсированный: у нового
    // канала AGC-регистры ещё не запрограммированы.
    if RxChanged then ApplyRxMux;
    if TxChanged then ApplyTxMux;
    ApplyGain(True);
    ApplyTxAtten;
  end;
end;

procedure TPlutoBackend.ApplyGain(Force: Boolean);
var Ch: Piio_channel; Other: string;
begin
  if FPhy = nil then Exit;
  // Усиление/режим AGC — поканальные: пишем в тракт выбранной антенны.
  Ch := iio_device_find_channel(FPhy, PAnsiChar(AnsiString(RxPhyChanName)), 0);
  if Ch = nil then Exit;
  if Force then
  begin
    // Прогоняем через другой режим — иначе драйвер сочтёт режим неизменным и не
    // перепрограммирует AGC-регистры канала (после смены мультиплексора там
    // остаётся auto-AGC, а hardwaregain отвергается: «Rx gain can be set in MGC only»).
    if FGainMode = 'manual' then Other := 'slow_attack' else Other := 'manual';
    iio_channel_attr_write(Ch, 'gain_control_mode', PAnsiChar(AnsiString(Other)));
  end;
  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.ApplyTxAtten;
var Ch: Piio_channel;
begin
  if FPhy = nil then Exit;
  Ch := iio_device_find_channel(FPhy, 'voltage0', 1);   // TX att — output voltage0
  if Ch = nil then Exit;
  // hardwaregain выходного тракта — отрицательные дБ, шаг 0.25 (0=макс. мощность).
  if Assigned(iio_channel_attr_write_double) then
    iio_channel_attr_write_double(Ch, 'hardwaregain', FTxAttDb);
end;

procedure TPlutoBackend.StartRX;
var Base: Integer; ChI, ChQ: string;
begin
  if FStreaming or not FConnected or (FRxDev = nil) then Exit;
  // Пара стрим-каналов выбранного тракта: RX1 = voltage0/1, RX2 = voltage2/3.
  // Включаем ТОЛЬКО её — второй тракт не стримим (иначе вдвое трафик/CPU).
  Base := 0;
  if (FRxChanSel = 1) and FRx2Avail then Base := 2;
  ChI := 'voltage' + IntToStr(Base);
  ChQ := 'voltage' + IntToStr(Base + 1);
  FRxChI := iio_device_find_channel(FRxDev, PAnsiChar(AnsiString(ChI)), 0);
  FRxChQ := iio_device_find_channel(FRxDev, PAnsiChar(AnsiString(ChQ)), 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;
  // Снять enable с пары каналов: иначе после смены антенного входа (2R2T) в
  // новом буфере окажутся включёнными оба тракта — чередование I1,Q1,I2,Q2.
  if FRxChI <> nil then iio_channel_disable(FRxChI);
  if FRxChQ <> nil then iio_channel_disable(FRxChQ);
  FRxChI := nil;
  FRxChQ := nil;
end;

procedure TPlutoBackend.StartTX;
var Base: Integer; ChI, ChQ: string;
begin
  if FTxStreaming or not FConnected or (FTxDev = nil) then Exit;
  // 2R2T-образ: пара выходных каналов выбранного разъёма (TX1 = voltage0/1,
  // TX2 = voltage2/3). В 1R1T пара всегда одна — разъём переключает ApplyTxMux.
  Base := 0;
  if (FTxChanSel = 1) and FRx2Avail then Base := 2;
  ChI := 'voltage' + IntToStr(Base);
  ChQ := 'voltage' + IntToStr(Base + 1);
  FTxChI := iio_device_find_channel(FTxDev, PAnsiChar(AnsiString(ChI)), 1);   // output I
  FTxChQ := iio_device_find_channel(FTxDev, PAnsiChar(AnsiString(ChQ)), 1);   // output Q
  if (FTxChI = nil) or (FTxChQ = nil) then
  begin FLastError := 'TX I/Q channels not found'; Exit; end;
  iio_channel_enable(FTxChI);
  iio_channel_enable(FTxChQ);
  // Апсемпл-ратио под текущий device-rate (рейты-пресеты кратны 192k → целое).
  FTxRatio := FSampleRate div TX_SRC_RATE;
  if FTxRatio < 1 then FTxRatio := 1;
  BuildTxResampler;   // полифазные коэффициенты + сброс линии задержки под FTxRatio
  // Размер push-буфера под текущий rate + FIFO на ~4 таких буфера запаса.
  FTxBufPairs := TxBufPairsForRate(FSampleRate);
  EnterCriticalSection(FTxLock);
  FTxFifoCap := FTxBufPairs * 2 * 4;
  if Length(FTxFifo) <> FTxFifoCap then SetLength(FTxFifo, FTxFifoCap);
  FTxFifoHead := 0; FTxFifoTail := 0; FTxFifoCount := 0;
  LeaveCriticalSection(FTxLock);
  FTxBuf := iio_device_create_buffer(FTxDev, FTxBufPairs, False);
  if FTxBuf = nil then begin FLastError := 'TX create_buffer failed'; Exit; end;
  ApplyTxAtten;   // стартуем с текущей аттенюации (по умолчанию OFF до PTT)
  FTxStreaming := True;
  FTxThread := TPlutoTXThread.Create(Self);
end;

procedure TPlutoBackend.StopTX;
begin
  if FTxThread <> nil then
  begin
    FTxThread.Terminate;
    if (FTxBuf <> nil) and Assigned(iio_buffer_cancel) then
      iio_buffer_cancel(FTxBuf);   // разблокировать push немедленно
    FTxThread.WaitFor;
    FreeAndNil(FTxThread);
  end;
  FTxStreaming := False;
  if FTxBuf <> nil then
  begin
    iio_buffer_destroy(FTxBuf);
    FTxBuf := nil;
  end;
end;

// ---- Команды контроллера (фаза 1: сохраняем состояние + базовое применение) ----

procedure TPlutoBackend.ConfigureDDCs(NumDDCs: Byte; SampleRate: Word;
  ADCSource: Byte; DitherEnabled, RandomEnabled: Boolean);
begin
  // ADCSource (АЦП-источник HPSDR) у AD936x смысла не имеет — антенные разъёмы
  // переключает SetAntPorts. SampleRate приходит в ksps (как у HPSDR), Pluto — в Гц.
  // Контроллер может прислать HPSDR-наследие (<520k) — клампим к дефолту.
  FSampleRate := Integer(SampleRate) * 1000;
  if FSampleRate < PLUTO_MIN_SR then FSampleRate := PLUTO_DEF_SR;
  if not FConnected then Exit;
  ApplySampleRate(FSampleRate);   // перезапустит RX при смене
  // ad9361_set_bb_rate перепрошивает FIR/BBPLL и сбрасывает усиление/AGC к
  // дефолтам — возвращаем разъёмы и форсированно гейн + TX-аттенюацию (иначе
  // в железе остаётся auto-AGC: высокая шумовая полка).
  ApplyRxMux;
  ApplyTxMux;
  ApplyGain(True);
  ApplyTxAtten;
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);
    // Full-duplex: оба стрима живут весь Run. TX по умолчанию заглушён макс.
    // аттенюацией (FTxAttDb=OFF) — реальная мощность включается из контроллера
    // через SetTxAtten на PTT.
    if Run then begin StartRX; StartTX; end
    else        begin StopTX;  StopRX;  end;
  end;
end;

procedure TPlutoBackend.UpdateState(RXFreqHz, TXFreqHz: Double; DriveLevel: Byte;
  Transmitting, PAEnabled, AlexEnabled: Boolean; Tuning: Boolean; TwoTone: 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;
  // TX LO включаем на передаче, гасим на приёме (нет утечки несущей в RX-спектр).
  // TX-стрим живёт непрерывно (full-duplex); реальная мощность — через аттенюацию.
  if FConnected then SetTxLOPowerdown(Active);
end;

procedure TPlutoBackend.SetRxGain(ModeIdx: Integer; GainDb: Integer);
begin
  case ModeIdx of
    1: FGainMode := 'fast_attack';
    2: FGainMode := 'slow_attack';
    3: FGainMode := 'hybrid';
  else FGainMode := 'manual';
  end;
  FGainDb := GainDb;
  if FConnected then ApplyGain;
end;

procedure TPlutoBackend.SetTxAtten(Db: Double);
begin
  if Db > 0 then Db := 0;
  if Db < PLUTO_TX_OFF then Db := PLUTO_TX_OFF;
  FTxAttDb := Db;
  if FConnected then ApplyTxAtten;
end;

procedure TPlutoBackend.SendDUCIQ(const IData, QData: array of Integer);
// IData/QData — 24-bit пары @ TX_SRC_RATE (192k) от WDSP через контроллер.
// Полифазный FIR-интерполятор ×FTxRatio (анти-имидж ФНЧ, см. BuildTxResampler):
// на каждый входной сэмпл выдаём L выходных, конвертим 24→int16 (>>8, full-scale
// MSB-aligned), Q инвертируем (та же конвенция, что и на RX) и кладём в FIFO.
// Underrun в push-потоке добивается нулями, overflow — отбрасываем старое.
  function Clamp16(V: Int64): SmallInt; inline;
  begin
    if V > 32767 then Result := 32767
    else if V < -32768 then Result := -32768
    else Result := SmallInt(V);
  end;
var
  i, p, k, t, L, taps, base, n: Integer;
  ai, aq: Double;
begin
  if (not FTxStreaming) or (FTxRatio < 1) or (Length(FTxCoef) = 0) then Exit;
  n := Length(IData);
  if Length(QData) < n then n := Length(QData);
  L := FTxRatio;
  taps := TX_FIR_TAPS;
  EnterCriticalSection(FTxLock);
  try
    for i := 0 to n - 1 do
    begin
      // сдвигаем линию задержки (FTxDl[0] = новейший входной сэмпл)
      for k := taps - 1 downto 1 do
      begin FTxDlI[k] := FTxDlI[k - 1]; FTxDlQ[k] := FTxDlQ[k - 1]; end;
      FTxDlI[0] := IData[i]; FTxDlQ[0] := QData[i];
      // L выходных сэмплов — фазы p = 0..L-1, каждая = свёртка с подфильтром
      for p := 0 to L - 1 do
      begin
        ai := 0; aq := 0;
        base := p * taps;
        for k := 0 to taps - 1 do
        begin
          ai := ai + FTxCoef[base + k] * FTxDlI[k];
          aq := aq + FTxCoef[base + k] * FTxDlQ[k];
        end;
        if FTxFifoCount + 2 > FTxFifoCap then
        begin
          // overflow: сбрасываем самые старые пары, держим хвост (минимум латентности)
          FTxFifoHead := (FTxFifoHead + 2) mod FTxFifoCap;
          Dec(FTxFifoCount, 2);
        end;
        t := FTxFifoTail;
        FTxFifo[t]     := Clamp16(Round(ai / 256.0));      // 24→16 бит
        FTxFifo[t + 1] := Clamp16(-Round(aq / 256.0));     // Q инверсия (как RX)
        FTxFifoTail    := (t + 2) mod FTxFifoCap;
        Inc(FTxFifoCount, 2);
      end;
    end;
  finally
    LeaveCriticalSection(FTxLock);
  end;
end;

procedure TPlutoBackend.ClearDUCIQQueue;
var k: Integer;
begin
  EnterCriticalSection(FTxLock);
  FTxFifoHead := 0; FTxFifoTail := 0; FTxFifoCount := 0;
  for k := 0 to Length(FTxDlI) - 1 do begin FTxDlI[k] := 0; FTxDlQ[k] := 0; end;
  LeaveCriticalSection(FTxLock);
end;

function TPlutoBackend.PollTelemetryBlocking(out TempC, SocTempC, RSSIdB,
  GainDb: Double): Boolean;
// БЛОКИРУЮЩИЙ опрос железа — только из FTeleThread (каждое чтение = USB round-trip):
//   ad9361-phy temp0/input — температура трансивера в миллиградусах C (longlong);
//   xadc temp0             — температура Zynq SoC/FPGA; сырой raw + offset (longlong)
//                            + scale (double), C = (raw + offset) * scale / 1000;
//   ad9361-phy voltage0/rssi — RSSI входного тракта в дБ (double; строка '12.34 dB',
//                            libiio парсит ведущее число). Валиден только во время приёма.
var
  Ch, ChX: Piio_channel;
  XDev: Piio_device;
  raw, offs: clonglong;
  scale, d: cdouble;
begin
  TempC    := TELEMETRY_NONE;
  SocTempC := TELEMETRY_NONE;
  RSSIdB   := TELEMETRY_NONE;
  GainDb   := TELEMETRY_NONE;
  Result   := False;
  if (not FConnected) or (FPhy = nil) or (not IIOLoaded) then Exit;

  Ch := iio_device_find_channel(FPhy, 'temp0', 0);
  if (Ch <> nil) and (iio_channel_attr_read_longlong(Ch, 'input', raw) = 0) then
  begin
    TempC  := raw / 1000.0;
    Result := True;
  end;

  // Температура Zynq SoC/FPGA — отдельное IIO-устройство 'xadc' (всегда присутствует).
  // Канал отдаёт сырой код + offset + scale (в отличие от ad9361, дающего милли-°C):
  //   T(°C) = (raw + offset) * scale / 1000.
  if FCtx <> nil then
  begin
    XDev := iio_context_find_device(FCtx, 'xadc');
    if XDev <> nil then
    begin
      ChX := iio_device_find_channel(XDev, 'temp0', 0);
      if (ChX <> nil) and Assigned(iio_channel_attr_read_double)
         and (iio_channel_attr_read_longlong(ChX, 'raw', raw) = 0)
         and (iio_channel_attr_read_longlong(ChX, 'offset', offs) = 0)
         and (iio_channel_attr_read_double(ChX, 'scale', scale) = 0) then
      begin
        SocTempC := (raw + offs) * scale / 1000.0;
        Result   := True;
      end;
    end;
  end;

  // RSSI имеет смысл только при активном RX-стриме (иначе чтение даёт ошибку/мусор).
  // Читаем с phy-канала выбранного приёмного тракта (RX1/RX2 — независимы).
  if FStreaming then
  begin
    Ch := iio_device_find_channel(FPhy, PAnsiChar(AnsiString(RxPhyChanName)), 0);
    if (Ch <> nil) and Assigned(iio_channel_attr_read_double)
       and (iio_channel_attr_read_double(Ch, 'rssi', d) = 0) then
    begin
      RSSIdB := d;
      Result := True;
    end;
    // hardwaregain: в режимах AGC железо крутит усиление само, и без него шкала
    // dBm «дышит» вместе с ним (см. TRadioController.FrontEndCalDB).
    if (Ch <> nil) and Assigned(iio_channel_attr_read_double)
       and (iio_channel_attr_read_double(Ch, 'hardwaregain', d) = 0) then
    begin
      GainDb := d;
      Result := True;
    end;
  end;
end;

function TPlutoBackend.RxGainNowDb: Double;
// В ручном режиме значение известно точно и без опроса железа; в AGC-режимах
// отдаём последний снимок телеметрии (обновляется ~2 Гц — быстрый AGC всё
// равно шевелится быстрее, для стабильной шкалы нужен MANUAL или SLOW).
begin
  if FGainMode = 'manual' then Exit(FGainDb);
  EnterCriticalSection(FTeleLock);
  try
    if FTeleValid and (FTeleGain > TELEMETRY_NONE) then Result := FTeleGain
    else Result := FGainDb;
  finally
    LeaveCriticalSection(FTeleLock);
  end;
end;

function TPlutoBackend.ReadTelemetry(out TempC, SocTempC, RSSIdB: Double): Boolean;
// Неблокирующий: отдаёт последний снимок, снятый FTeleThread. Безопасно звать из UI.
begin
  EnterCriticalSection(FTeleLock);
  try
    TempC    := FTeleTemp;
    SocTempC := FTeleSoc;
    RSSIdB   := FTeleRssi;
    Result   := FTeleValid;
  finally
    LeaveCriticalSection(FTeleLock);
  end;
end;

procedure TPlutoBackend.StartTelemetry;
begin
  if FTeleThread = nil then
    FTeleThread := TPlutoTeleThread.Create(Self);
end;

procedure TPlutoBackend.StopTelemetry;
begin
  if FTeleThread = nil then Exit;
  FTeleThread.Terminate;
  FTeleThread.WaitFor;      // обязателен до разрушения контекста libiio
  FreeAndNil(FTeleThread);
  EnterCriticalSection(FTeleLock);
  try
    FTeleValid := False;
    FTeleTemp  := TELEMETRY_NONE;
    FTeleSoc   := TELEMETRY_NONE;
    FTeleRssi  := TELEMETRY_NONE;
  finally
    LeaveCriticalSection(FTeleLock);
  end;
end;

{ TPlutoTeleThread }

constructor TPlutoTeleThread.Create(ABk: TPlutoBackend);
begin
  FBk := ABk;
  FreeOnTerminate := False;
  inherited Create(False);
end;

procedure TPlutoTeleThread.Execute;
const
  POLL_MS = 500;   // ~2 Гц: чаще незачем, каждый опрос ~30 мс USB-транзакций
  STEP_MS = 25;    // мелкий шаг сна — чтобы Terminate отрабатывал быстро
var
  t, s, r, g: Double;
  ok: Boolean;
  Waited: Integer;
begin
  while not Terminated do
  begin
    if FBk.FConnected then
    begin
      ok := FBk.PollTelemetryBlocking(t, s, r, g);
      if ok then
      begin
        EnterCriticalSection(FBk.FTeleLock);
        try
          FBk.FTeleTemp  := t;
          FBk.FTeleSoc   := s;
          FBk.FTeleRssi  := r;
          FBk.FTeleGain  := g;
          FBk.FTeleValid := True;
        finally
          LeaveCriticalSection(FBk.FTeleLock);
        end;
      end;
    end;
    Waited := 0;
    while (Waited < POLL_MS) and not Terminated do
    begin
      Sleep(STEP_MS);
      Inc(Waited, STEP_MS);
    end;
  end;
end;

end.
