feat: Pluto TX path — attenuation-based power, full-duplex IQ stream

TX path for Pluto/AD936x (RX was already working):
- PlutoBackend: continuous full-duplex TX stream (cf-ad9361-dds-core-lpc)
  with FIFO + push thread; SendDUCIQ upsamples WDSP 192k -> device rate
  (integer, linear interp), 24->16 bit, Q inverted; zeros on underrun.
- Power via output attenuator (ADI-recommended): SetTxAtten writes
  hardwaregain on voltage0(out). Controller maps drive% -> dB
  (PlutoTxAttForDrive/ApplyPlutoTxAtten); -89.75 dB on RX (~off),
  ceiling PlutoTxMaxAttDb on 100% drive.
- Configurable max-att (default -10 dB, ADI-linear) for external amp:
  persisted in global settings, UI spin on PA page, routed through
  controller SetPlutoTxMaxAtt (UI stays decoupled from logic).
- RadioBackend: virtual SetTxAtten (no-op for HPSDR).
- Update doc/PLUTO_INTEGRATION_PLAN.md: phases 3/4/5 + risks.

Builds clean (headless + qt6 GUI). Not yet verified on hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 12:27:49 +03:00
co-authored by Claude Opus 4.8
parent 1321e415f7
commit d08fc17056
7 changed files with 376 additions and 19 deletions
+229 -4
View File
@@ -49,6 +49,8 @@ type
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;
@@ -57,13 +59,35 @@ type
FStreaming: Boolean;
FRxSeq: LongWord;
// 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 (целочисленный апсемпл)
FTxPrevI: LongInt; // последний 24-бит сэмпл (линейная интерполяция между вызовами)
FTxPrevQ: LongInt;
// FIFO int16-пар @ device-rate (кольцо): продюсер — SendDUCIQ, консьюмер — push-поток.
FTxFifo: array of SmallInt; // интерливнутые I,Q
FTxFifoCap: Integer; // ёмкость в SmallInt-элементах (пар*2)
FTxFifoHead: Integer; // индекс чтения (консьюмер)
FTxFifoTail: Integer; // индекс записи (продюсер)
FTxFifoCount: Integer; // занято элементов
FTxLock: TRTLCriticalSection;
// Низкоуровневые помощники применения атрибутов к ad9361-phy
procedure ApplyRxLO(Hz: Double);
procedure ApplyTxLO(Hz: Double);
procedure ApplySampleRate(Hz: Integer);
procedure ApplyGain;
procedure ApplyTxAtten;
procedure StartRX;
procedure StopRX;
procedure StartTX;
procedure StopTX;
protected
function GetConnected: Boolean; override;
function GetRunning: Boolean; override;
@@ -95,6 +119,9 @@ type
procedure SendFullHP; override;
procedure SendPTT(Active: Boolean); 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, RSSIdB: Double): Boolean; override;
end;
@@ -112,6 +139,9 @@ const
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_BUF_PAIRS = 4096; // IQ-пар в одном TX-буфере push (~2.7 мс @ 1.536 MS/s)
PLUTO_TX_OFF = -89.75; // макс. аттенюация AD9361 — TX практически выключен
type
// RX-поток: блокирующий refill libiio → конверсия int16→24bit BE → OnDDCIQ.
@@ -125,6 +155,18 @@ type
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;
@@ -194,6 +236,55 @@ begin
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 PlutoSyntheticMAC(const S: string): TBytes;
var
H: QWord;
@@ -229,12 +320,21 @@ begin
FGainDb := 40;
FRxBuf := nil; FRxChI := nil; FRxChQ := nil; FRxThread := nil;
FStreaming := False; FRxSeq := 0;
FTxBuf := nil; FTxChI := nil; FTxChQ := nil; FTxThread := nil;
FTxStreaming := False; FTxRatio := 8; FTxPrevI := 0; FTxPrevQ := 0;
FTxAttDb := PLUTO_TX_OFF; // на старте TX выключен (макс. аттенюация)
// FIFO на ~4 TX-буфера запаса под джиттер продюсера/консьюмера.
FTxFifoCap := TX_BUF_PAIRS * 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(FTxLock);
inherited Destroy;
end;
@@ -406,11 +506,13 @@ begin
if FSampleRate < PLUTO_MIN_SR then FSampleRate := PLUTO_DEF_SR;
ApplySampleRate(FSampleRate);
ApplyGain;
ApplyTxAtten; // TX заглушён (макс. аттенюация) до явного PTT
Result := True;
end;
procedure TPlutoBackend.Disconnect;
begin
StopTX;
StopRX;
if FCtx <> nil then
begin
@@ -443,11 +545,13 @@ begin
end;
procedure TPlutoBackend.ApplySampleRate(Hz: Integer);
var Ch: Piio_channel; WasStreaming: Boolean;
var Ch: Piio_channel; WasStreaming, WasTxStreaming: Boolean;
begin
if (FPhy = nil) or (Hz < PLUTO_MIN_SR) then Exit;
WasStreaming := FStreaming;
if WasStreaming then StopRX; // менять rate с открытым буфером нельзя
WasStreaming := FStreaming;
WasTxStreaming := FTxStreaming;
if WasTxStreaming then StopTX; // device-rate общий для RX/TX — пересоздать оба
if WasStreaming then StopRX; // менять rate с открытым буфером нельзя
// Предпочтительно ad9361_set_bb_rate: настраивает BBPLL И загружает FIR-таблицу
// децимации под частоту. Без этого на холодную (битый/дефолтный FIR) спектр —
@@ -468,6 +572,7 @@ begin
iio_channel_attr_write_longlong(Ch, 'rf_bandwidth', Hz);
if WasStreaming then StartRX;
if WasTxStreaming then StartTX; // пересоздаём TX с новым FTxRatio
end;
procedure TPlutoBackend.ApplyGain;
@@ -481,6 +586,17 @@ begin
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;
begin
if FStreaming or not FConnected or (FRxDev = nil) then Exit;
@@ -516,6 +632,47 @@ begin
end;
end;
procedure TPlutoBackend.StartTX;
begin
if FTxStreaming or not FConnected or (FTxDev = nil) then Exit;
FTxChI := iio_device_find_channel(FTxDev, 'voltage0', 1); // output I
FTxChQ := iio_device_find_channel(FTxDev, 'voltage1', 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;
FTxPrevI := 0; FTxPrevQ := 0;
EnterCriticalSection(FTxLock);
FTxFifoHead := 0; FTxFifoTail := 0; FTxFifoCount := 0;
LeaveCriticalSection(FTxLock);
FTxBuf := iio_device_create_buffer(FTxDev, TX_BUF_PAIRS, 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;
@@ -538,7 +695,11 @@ begin
begin
ApplyRxLO(FRXFreq);
ApplyTxLO(FTXFreq);
if Run then StartRX else StopRX; // RX-стрим; TX-стрим — фаза 4
// Full-duplex: оба стрима живут весь Run. TX по умолчанию заглушён макс.
// аттенюацией (FTxAttDb=OFF) — реальная мощность включается из контроллера
// через SetTxAtten на PTT.
if Run then begin StartRX; StartTX; end
else begin StopTX; StopRX; end;
end;
end;
@@ -579,6 +740,70 @@ begin
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 через контроллер.
// Линейно апсемплим ×FTxRatio, конвертим 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, j, t: Integer;
ci, cq, oi, oq: Int64;
n: Integer;
begin
if (not FTxStreaming) or (FTxRatio < 1) then Exit;
n := Length(IData);
if Length(QData) < n then n := Length(QData);
EnterCriticalSection(FTxLock);
try
for i := 0 to n - 1 do
begin
ci := IData[i]; cq := QData[i];
for j := 1 to FTxRatio do
begin
// линейная интерполяция prev→cur по FTxRatio точкам (последняя = cur)
oi := FTxPrevI + (ci - FTxPrevI) * j div FTxRatio;
oq := FTxPrevQ + (cq - FTxPrevQ) * j div FTxRatio;
if FTxFifoCount + 2 > FTxFifoCap then
begin
// overflow: сбрасываем самые старые пары, держим хвост (минимум латентности)
FTxFifoHead := (FTxFifoHead + 2) mod FTxFifoCap;
Dec(FTxFifoCount, 2);
end;
t := FTxFifoTail;
FTxFifo[t] := Clamp16(oi div 256); // 24→16 бит
FTxFifo[t + 1] := Clamp16(-(oq div 256)); // Q инверсия (как RX)
FTxFifoTail := (t + 2) mod FTxFifoCap;
Inc(FTxFifoCount, 2);
end;
FTxPrevI := ci; FTxPrevQ := cq;
end;
finally
LeaveCriticalSection(FTxLock);
end;
end;
procedure TPlutoBackend.ClearDUCIQQueue;
begin
EnterCriticalSection(FTxLock);
FTxFifoHead := 0; FTxFifoTail := 0; FTxFifoCount := 0;
FTxPrevI := 0; FTxPrevQ := 0;
LeaveCriticalSection(FTxLock);
end;
function TPlutoBackend.ReadTelemetry(out TempC, RSSIdB: Double): Boolean;
// Опрос аппаратной телеметрии ad9361-phy (вызывается контроллером, не из RX-потока):
// temp0/input — температура чипа в миллиградусах C (longlong);