From c645b0d1e6d5a77bf69a4a72e2c2718bd7a1c247 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 18 Jul 2026 14:35:34 +0300 Subject: [PATCH 1/7] =?UTF-8?q?feature(settings):=20=D0=B2=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B0=20Calibration=20=E2=80=94=20=D0=B4?= =?UTF-8?q?=D0=B5=D1=82=D0=B5=D0=BA=D1=82=D0=BE=D1=80=20=D0=BC=D0=BE=D1=89?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D0=B8/=D0=9A=D0=A1=D0=92,=20V/A,=20S-?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Паритет с Thetis-Enhanced (Det. Cal. / Volts&Amps правки): - TCalibration в TGlobalSettings (JSON cal_*): модель детектора Alex-моста, per-band множитель FWD/REV 0.5..2.0, множитель напряжения, Voff/Sens датчика тока, офсет S-метра. - AlexDetToWatts: константы Thetis computeAlexFwdPower/computeRefPower по моделям (ANAN-100 / 200D / 7000DLE-Anvelina Pro 3 / 8000DLE-Orion MkII, 6m-спецкейс REV). Дефолт = прежняя формула ANAN-100 (поведение не меняется). - OnHPStatus: FWD и REV через модельную формулу x множитель банда; КСВ = (1+p)/(1-p), p=sqrt(RevW/FwdW) (Thetis) вместо отношения сырых ADC; напряжение x SupplyVoltCal; ток с калибруемыми Voff/Sens. - WDSPEngine.SMeterCalDB: офсет в GetSMeterDBm/GetSliceSMeter (главный S-метр, slice-флаги, web). - Выбор модели в комбо подставляет модельные дефолты датчика тока (Anvelina Pro 3/7000DLE: 340 мВ / 88 мВ/А — Thetis GetDefaultVoltCalibration). - Фиксы: гард FLoading при постройке вкладки (SetItemIndex стреляет OnChange на программном присвоении -> AV на nil-контролах), FPageCalib/FNavCalib в списках SelectPage (вкладка была невидимой). Co-Authored-By: Claude Fable 5 --- HPSDRProtocol.pas | 71 ++++++++++++-- MainForm.pas | 9 ++ RadioController.pas | 48 ++++++++-- Settings.pas | 44 +++++++++ SettingsForm.pas | 221 +++++++++++++++++++++++++++++++++++++++++++- WDSPEngine.pas | 8 +- 6 files changed, 381 insertions(+), 20 deletions(-) diff --git a/HPSDRProtocol.pas b/HPSDRProtocol.pas index 6a20cc0..6f4e239 100644 --- a/HPSDRProtocol.pas +++ b/HPSDRProtocol.pas @@ -400,7 +400,9 @@ function BoardSupportsSupplyCurrent(BoardType: Integer): Boolean; // Convert raw ADC value to supply current in Amps (Board 5 / Orion MkII only) // Uses UserADC1 (bytes 55-56). Returns -1 for unsupported boards. -function ADCToSupplyCurrent(RawADC: Word; BoardType: Integer): Double; +// VoffMV/SensMVA — калибровка датчика (Thetis AmpVoff/AmpSens, ACS712-style). +function ADCToSupplyCurrent(RawADC: Word; BoardType: Integer; + VoffMV: Double = 360.0; SensMVA: Double = 120.0): Double; // Convert raw ADC value to RF power in Watts (ANAN-100) function ADCToWatts100(RawADC: Word): Double; @@ -408,6 +410,16 @@ function ADCToWatts100(RawADC: Word): Double; // Convert raw ADC value to RF power in Watts (ANAN-10) function ADCToWatts10(RawADC: Word): Double; +// Alex bridge FWD/REV detector → Watts, константы Thetis computeAlexFwdPower/ +// computeRefPower по модели трансивера: +// 0 = ANAN-10/100/100B/100D (Vref 3.3V) +// 1 = ANAN-200D / Orion (Vref 5.0V) +// 2 = ANAN-7000DLE / Anvelina Pro 3 / G2 / Red Pitaya +// 3 = ANAN-8000DLE / Orion MkII +// IsRev — REV-детектор (свой bridge_volt/offset), Is6m — 6m-коррекция REV. +function AlexDetToWatts(RawADC: Word; DetModel: Integer; + IsRev, Is6m: Boolean): Double; + implementation procedure PackU32BE(Value: LongWord; out B: array of Byte; Offset: Integer); @@ -476,14 +488,15 @@ begin Result := BoardType = 5; end; -function ADCToSupplyCurrent(RawADC: Word; BoardType: Integer): Double; +function ADCToSupplyCurrent(RawADC: Word; BoardType: Integer; + VoffMV: Double; SensMVA: Double): Double; begin // Board 5 (Orion MkII): UserADC1, Vref=5V → mV = adc/4095*5000. - // Current sensor: Voff=360mV, Sens=120mV/A (Thetis defaults, ACS712-style). - // Formula: amps = (mV - Voff) / Sens. - if BoardType = 5 then + // Formula: amps = (mV - Voff) / Sens. Дефолты Voff=360мВ, Sens=120мВ/А + // (Thetis, ACS712-style); переопределяются калибровкой. + if (BoardType = 5) and (SensMVA > 0) then begin - Result := ((RawADC / 4095.0) * 5000.0 - 360.0) / 120.0; + Result := ((RawADC / 4095.0) * 5000.0 - VoffMV) / SensMVA; if Result < 0.0 then Result := 0.0; end else Result := -1.0; @@ -507,4 +520,50 @@ begin Result := (V * V) / 0.09; end; +function AlexDetToWatts(RawADC: Word; DetModel: Integer; + IsRev, Is6m: Boolean): Double; +var + Bridge, Vref, V: Double; + CalOff: Integer; +begin + // Константы из Thetis (console.cs computeAlexFwdPower / computeRefPower). + // W = ((ADC - CalOff)/4095 * Vref)^2 / Bridge. + case DetModel of + 1: begin // ANAN-200D / Orion + Vref := 5.0; + if IsRev then begin + Bridge := 0.108; if Is6m then Bridge := 0.5; + CalOff := 2; + end else begin + Bridge := 0.108; CalOff := 4; + end; + end; + 2: begin // ANAN-7000DLE / Anvelina Pro 3 / G2 / Red Pitaya + Vref := 5.0; + if IsRev then begin + Bridge := 0.15; if Is6m then Bridge := 0.7; + CalOff := 28; + end else begin + Bridge := 0.12; CalOff := 32; + end; + end; + 3: begin // ANAN-8000DLE / Orion MkII + Vref := 5.0; Bridge := 0.08; + if IsRev then CalOff := 16 else CalOff := 18; + end; + else begin // 0: ANAN-10/100/100B/100D + Vref := 3.3; + if IsRev then begin + Bridge := 0.095; if Is6m then Bridge := 0.5; + CalOff := 3; + end else begin + Bridge := 0.095; CalOff := 6; + end; + end; + end; + V := ((RawADC - CalOff) / 4095.0) * Vref; + if V < 0 then V := 0; + Result := (V * V) / Bridge; +end; + end. diff --git a/MainForm.pas b/MainForm.pas index ea5de28..2ee321b 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -698,6 +698,7 @@ type function DefaultMicSource: TTXMicSource; procedure ApplyMOX(Active: Boolean); procedure OnPASettingsChange(MaxPower: Double; const BandCal: array of Double); + procedure OnCalibrationSettingsChange(const C: TCalibration); procedure OnPlutoTxMaxAttChange(MaxAttDb: Double); procedure OnAnt936xChange(const A: TAD936xAnt); // разъёмы AD936x (Antenna) procedure OnVHFCalSettingsChange(const VHFCal: array of Double); @@ -7413,6 +7414,12 @@ begin UpdateDriveLabel; // изменился потолок → пересчитать дБ-подпись drive end; +procedure TMainForm.OnCalibrationSettingsChange(const C: TCalibration); +begin + // Логика (санитизация/офсет S-метра в DSP/persist) — в контроллере. + FController.SetCalibration(C); +end; + procedure TMainForm.OnAnt936xChange(const A: TAD936xAnt); // Вкладка Antenna (AD936x): таблица «диапазон → разъём». Контроллер сохраняет её // и сразу применяет разъёмы текущего диапазона/трансвертера. @@ -8226,6 +8233,7 @@ begin SF.OnVisibilityChange := ApplyVisibility; SF.OnFPSChange := ApplyFPS; SF.OnPAChange := OnPASettingsChange; + SF.OnCalibrationChange := OnCalibrationSettingsChange; SF.OnPlutoTxChange := OnPlutoTxMaxAttChange; SF.OnAnt936xChange := OnAnt936xChange; SF.OnVHFCalChange := OnVHFCalSettingsChange; @@ -8251,6 +8259,7 @@ begin SF.LoadAnt936x(FController.Ant936x, FController.FXvtrSettings); SF.LoadXvtrSettings(FController.FXvtrSettings); SF.LoadPASettings(FController.FPAMaxPower, FController.FPABandCal); + SF.LoadCalibration(FController.FCal); SF.LoadPlutoTxMaxAtt(FController.FPlutoTxMaxAttDb); SF.LoadVHFCalSettings(FController.FVHFBandCal); SF.LoadCATSettings( diff --git a/RadioController.pas b/RadioController.pas index b43a867..3f93422 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -375,6 +375,10 @@ type FBandCache: array[0..CFG_BAND_COUNT-1] of TBandSettings; FPABandCal: array[0..CFG_BAND_COUNT-1] of Double; FVHFBandCal: array[0..CFG_XVTR_COUNT-1] of Double; + // Калибровки (детектор мощности/КСВ, напряжение/ток, S-метр) — вкладка + // Calibration. Читается сетевым потоком в OnHPStatus (double-поля, torn + // read безопасен как у FPABandCal); менять через SetCalibration. + FCal: TCalibration; FDevMAC: array[0..5] of Byte; // Глобальные настройки устройства, загруженные ConnectDevice. Хранятся для // UI-рендера полей, которых нет в контроллере (DisplayFPS/FreqMhzDigits/CAT). @@ -681,6 +685,7 @@ type function PlutoTxAttForDrive: Double; // дБ<0 под текущий FDrivePercent procedure ApplyPlutoTxAtten; // ставит att по FTransmitting/FTuning procedure SetPlutoTxMaxAtt(Db: Double); // потолок на drive=100% (внешний усилитель) + procedure SetCalibration(const C: TCalibration); // вкладка Calibration → FCal + офсет S-метра в DSP procedure ApplyWDSPDrive(Pct: Integer); // цифровой drive WDSP: Pluto=1.0 (мощность — атт), HPSDR=Pct/100 // Диапазоны / трансвертеры / спан @@ -943,6 +948,7 @@ begin FFMStepOn := True; FRawFMDeviation := RAW_FM_DEV_DEFAULT; FLocalAudio := True; // GUI: звук на локальную карту; демон выключит явно + TSettingsManager.DefaultCalibration(FCal); FLastSMeter := -130; FLastSWR := 1; FLastSupplyV := -1; FLastSupplyA := -1; FLastPlutoTempC := TELEMETRY_NONE; FLastPlutoSocTempC := TELEMETRY_NONE; FLastPlutoRSSIdB := TELEMETRY_NONE; @@ -965,6 +971,7 @@ begin FDSPEngine.OnSliceDemodAudio := OnSliceDemodAudioReady; FDSPEngine.OnDemodAudio := OnDemodAudioReady; FDSPEngine.SetDisplayResLimit(FDisplayPixels); + FDSPEngine.SMeterCalDB := FCal.SMeterOffsetDB; // QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock). FBeaconRefHz := 10489750000.0; // центральный BPSK-маяк NB-транспондера (downlink) @@ -1186,10 +1193,12 @@ procedure TRadioController.OnHPStatus(const Status: THighPriorityStatus); const SWR_MIN_FWD_W = 1.0; // ниже — fwd это ADC-шум, SWR не считаем SWR_CAP = 9.9; + BAND_6M = 10; // индекс 6m в HF-плане (160m..6m) var FwdPwr, RevPwr: Word; - SupplyV, SupplyA, FwdW, SWRV, Rho: Double; - IsTx: Boolean; + SupplyV, SupplyA, FwdW, RevW, SWRV, Rho, DetK: Double; + Bi: Integer; + Is6m, IsTx: Boolean; begin FwdPwr := (Status.FwdPwrAlex0Hi shl 8) or Status.FwdPwrAlex0Lo; RevPwr := (Status.RevPwrAlex0Hi shl 8) or Status.RevPwrAlex0Lo; @@ -1208,19 +1217,31 @@ begin SupplyV := ADCToSupplyVolts( (Status.SupplyVoltsHi shl 8) or Status.SupplyVoltsLo, FNetwork.Device.BoardType); + if SupplyV >= 0 then SupplyV := SupplyV * FCal.SupplyVoltCal; if BoardSupportsSupplyCurrent(FNetwork.Device.BoardType) then SupplyA := ADCToSupplyCurrent( (Status.UserADC1Hi shl 8) or Status.UserADC1Lo, - FNetwork.Device.BoardType); + FNetwork.Device.BoardType, + FCal.AmpVoffMV, FCal.AmpSensMVA); end; - FwdW := ADCToWatts100(FwdPwr); + // Детектор Alex-моста: константы по модели (вкладка Calibration) + per-band + // множитель. Множитель общий для FWD/REV, поэтому КСВ от него не зависит + // (как Thetis PABandCalMult). + Bi := TxBandIdx; + Is6m := Bi = BAND_6M; + DetK := 1.0; + if (Bi >= 0) and (Bi < CFG_BAND_COUNT) and (FCal.PADetCal[Bi] > 0) then + DetK := FCal.PADetCal[Bi]; + FwdW := AlexDetToWatts(FwdPwr, FCal.PwrDetModel, False, Is6m) * DetK; + RevW := AlexDetToWatts(RevPwr, FCal.PwrDetModel, True, Is6m) * DetK; // FTransmitting — программный флаг; HPS_PTT — вход HW-PTT (footswitch/mic). IsTx := FTransmitting or ((Status.StatusBits and HPS_PTT) <> 0); - // SWR = (1+ρ)/(1-ρ), ρ = adc_r/adc_f; только при значимой fwd-мощности. - if IsTx and (FwdW >= SWR_MIN_FWD_W) and (FwdPwr > 0) then + // SWR = (1+ρ)/(1-ρ), ρ = sqrt(RevW/FwdW) (Thetis); только при значимой + // fwd-мощности. + if IsTx and (FwdW >= SWR_MIN_FWD_W) then begin - Rho := RevPwr / FwdPwr; + Rho := Sqrt(RevW / FwdW); if Rho >= 0.99 then SWRV := SWR_CAP else @@ -2794,6 +2815,7 @@ begin Result.PlutoTxMaxAttDb := FPlutoTxMaxAttDb; for i := 0 to CFG_BAND_COUNT - 1 do Result.PABandCal[i] := FPABandCal[i]; for i := 0 to CFG_XVTR_COUNT - 1 do Result.VHFBandCal[i] := FVHFBandCal[i]; + Result.Cal := FCal; Result.ActiveVfo := FActiveVfo; Result.NRMode := FNRMode; Result.NBMode := FNBMode; @@ -3539,6 +3561,17 @@ begin SaveGlobalSettings; // персист потолка (per-device) end; +procedure TRadioController.SetCalibration(const C: TCalibration); +begin + FCal := C; + // Санитизация: битый конфиг не должен дать деление на ноль/абсурдные ватты. + FCal.PwrDetModel := EnsureRange(FCal.PwrDetModel, 0, 3); + if FCal.AmpSensMVA <= 0 then FCal.AmpSensMVA := 120.0; + if Assigned(FDSPEngine) then + FDSPEngine.SMeterCalDB := FCal.SMeterOffsetDB; + SaveGlobalSettings; // персист (гейт по FDevConnected внутри) +end; + procedure TRadioController.ApplyWDSPDrive(Pct: Integer); // На Pluto мощность задаётся ОДИН раз — железной аттенюацией (PlutoTxAttForDrive), // поэтому цифровой уровень WDSP держим на максимуме (1.0), а не режем по drive%. @@ -5018,6 +5051,7 @@ begin FPABandCal[i] := FLoadedGlobal.PABandCal[i]; for i := 0 to CFG_XVTR_COUNT - 1 do FVHFBandCal[i] := FLoadedGlobal.VHFBandCal[i]; + SetCalibration(FLoadedGlobal.Cal); // drive% (0..100) — раньше грузился через TrkDrive.Position; теперь напрямую, // слайдер выставит rfDevice-рендер. Калиброванный байт считаем после FCurrentBand. FDrivePercent := EnsureRange(FLoadedGlobal.DriveLevel, 0, 100); diff --git a/Settings.pas b/Settings.pas index 0011af8..d9e4f1d 100644 --- a/Settings.pas +++ b/Settings.pas @@ -330,6 +330,18 @@ type EnableXvtrHf: Boolean; // режим XVTR HF end; + // Калибровки (вкладка Calibration): детектор мощности/КСВ Alex-моста, + // напряжение/ток питания, S-метр. Модель детектора задаёт константы моста + // (Vref/bridge_volt/ADC-offset, как в Thetis computeAlexFwdPower). + TCalibration = record + PwrDetModel: Integer; // 0=ANAN-10/100 (3.3V), 1=ANAN-200D, 2=ANAN-7000DLE/Anvelina Pro 3, 3=ANAN-8000DLE/Orion MkII + PADetCal: array[0..CFG_BAND_COUNT-1] of Double; // множитель FWD/REV на банд, 0.50..2.00 (1.0 = без коррекции) + SupplyVoltCal: Double; // множитель напряжения питания (1.0 = без коррекции) + AmpVoffMV: Double; // сдвиг нуля датчика тока, мВ (Thetis default 360) + AmpSensMVA: Double; // чувствительность датчика тока, мВ/А (Thetis default 120) + SMeterOffsetDB: Double; // офсет S-метра, дБ (прибавляется к WDSP dBm) + end; + TGlobalSettings = record Volume: Integer; TxMonVolume: Integer; // громкость self-monitor RX-аудио на TX (RX MUTE off) @@ -397,6 +409,8 @@ type PlutoTxMaxAttDb: Double; // Pluto: TX-аттенюация на drive=100%, дБ<0 (дефолт -10) PABandCal: array[0..CFG_BAND_COUNT-1] of Double; // калибровка на диапазон 38.8..100.0 VHFBandCal: array[0..CFG_XVTR_COUNT-1] of Double; // калибровка для XVTR слотов 38.8..100.0 + // --- Calibration (детектор мощности/КСВ, напряжение/ток, S-метр) --- + Cal: TCalibration; // --- ADC settings --- DitherEnabled: Boolean; // DDC Specific byte 5 — уменьшает интермодуляцию RandomEnabled: Boolean; // DDC Specific byte 6 — рандомизация ADC @@ -454,6 +468,7 @@ type class function MacToStr(const MAC: array of Byte): string; class procedure DefaultBand(BandIdx: Integer; out B: TBandSettings); class procedure DefaultGlobal(out G: TGlobalSettings); + class procedure DefaultCalibration(out C: TCalibration); class procedure DefaultTX(out T: TTXSettings); // Размер окна — не привязан к MAC, хранится в корне JSON procedure SaveWindowBounds(L, T, W, H, DPI: Integer; Maximized: Boolean); @@ -614,6 +629,19 @@ begin // ADC — включены по умолчанию: поддерживаются на Hermes, Angelia, Orion G.DitherEnabled := True; G.RandomEnabled := True; + DefaultCalibration(G.Cal); +end; + +class procedure TSettingsManager.DefaultCalibration(out C: TCalibration); +var i: Integer; +begin + FillChar(C, SizeOf(C), 0); + C.PwrDetModel := 0; // ANAN-10/100 — совпадает с прежней зашитой формулой + for i := 0 to CFG_BAND_COUNT-1 do C.PADetCal[i] := 1.0; + C.SupplyVoltCal := 1.0; + C.AmpVoffMV := 360.0; + C.AmpSensMVA := 120.0; + C.SMeterOffsetDB := 0.0; end; constructor TSettingsManager.Create(const FilePath: string); @@ -856,6 +884,14 @@ begin // ADC settings G.DitherEnabled := JB(GObj,'dither_enabled', True); G.RandomEnabled := JB(GObj,'random_enabled', True); + // Calibration (детектор мощности/КСВ, напряжение/ток, S-метр) + G.Cal.PwrDetModel := EnsureRange(JI(GObj,'cal_pwr_det_model', 0), 0, 3); + for i := 0 to CFG_BAND_COUNT-1 do + G.Cal.PADetCal[i] := EnsureRange(JD(GObj,'cal_pa_det_'+IntToStr(i), 1.0), 0.5, 2.0); + G.Cal.SupplyVoltCal := EnsureRange(JD(GObj,'cal_supply_volt', 1.0), 0.1, 5.0); + G.Cal.AmpVoffMV := EnsureRange(JD(GObj,'cal_amp_voff_mv', 360.0), 0.0, 2000.0); + G.Cal.AmpSensMVA := EnsureRange(JD(GObj,'cal_amp_sens_mva', 120.0), 1.0, 1000.0); + G.Cal.SMeterOffsetDB := EnsureRange(JD(GObj,'cal_smeter_db', 0.0), -30.0, 30.0); for i := 0 to CFG_BAND_COUNT-1 do begin @@ -965,6 +1001,14 @@ begin // ADC settings JW(O,'dither_enabled',G.DitherEnabled); JW(O,'random_enabled',G.RandomEnabled); + // Calibration + JW(O,'cal_pwr_det_model',G.Cal.PwrDetModel); + for i := 0 to CFG_BAND_COUNT-1 do + JW(O,'cal_pa_det_'+IntToStr(i),G.Cal.PADetCal[i]); + JW(O,'cal_supply_volt',G.Cal.SupplyVoltCal); + JW(O,'cal_amp_voff_mv',G.Cal.AmpVoffMV); + JW(O,'cal_amp_sens_mva',G.Cal.AmpSensMVA); + JW(O,'cal_smeter_db',G.Cal.SMeterOffsetDB); end; procedure TSettingsManager.SaveBand(const MAC: array of Byte; BandIdx: Integer; diff --git a/SettingsForm.pas b/SettingsForm.pas index 7cfc762..5829e39 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -41,6 +41,8 @@ type TOnAnt936xChange = procedure(const A: TAD936xAnt) of object; // VHF (XVTR) PA calibration: 8 slots, 38.8..100.0 TOnVHFCalChange = procedure(const VHFCal: array of Double) of object; + // Калибровки (вкладка Calibration): детектор мощности/КСВ, напряжение/ток, S-метр + TOnCalibrationChange = procedure(const C: TCalibration) of object; // TX-настройки целиком (любая правка → один callback с актуальной структурой) TOnTXSettingsChange = procedure(const T: TTXSettings) of object; @@ -150,6 +152,18 @@ type FEdVHFCal: array[0..CFG_XVTR_COUNT-1] of TFlatFloatSpinEdit; FVHFCalLabels: array[0..CFG_XVTR_COUNT-1] of TLabel; + // ---- Calibration tab controls ---- + FPageCalib: TScrollBox; + FNavCalib: TFlatButton; + FCmbPwrDetModel: TFlatComboBox; // модель детектора Alex-моста + FEdPADetCal: array[0..10] of TFlatFloatSpinEdit; // множитель FWD/REV на банд + FEdSupplyVoltCal: TFlatFloatSpinEdit; // множитель напряжения питания + FEdAmpVoff: TFlatFloatSpinEdit; // датчик тока: сдвиг нуля, мВ + FEdAmpSens: TFlatFloatSpinEdit; // датчик тока: мВ/А + FEdSMeterOffset: TFlatFloatSpinEdit; // офсет S-метра, дБ + FCalConfig: TCalibration; + FOnCalibrationChange: TOnCalibrationChange; + // ---- Transmit tab controls ---- // Текущее состояние, отдаётся в OnTXChange целиком при любой правке. FTX: TTXSettings; @@ -337,6 +351,9 @@ type procedure BuildDisplayTab; procedure BuildRX1Tab; procedure BuildPATab; + procedure BuildCalibrationTab; + procedure CollectCalFromUI; + procedure OnCalAnyChange(Sender: TObject); procedure BuildCATTab; procedure BuildTransmitTab; procedure BuildAlexTab; @@ -445,6 +462,7 @@ type procedure LoadFreqMhzDigits(Digits: Integer); procedure LoadPASettings(MaxPower: Double; const BandCal: array of Double); procedure LoadPlutoTxMaxAtt(MaxAttDb: Double); + procedure LoadCalibration(const C: TCalibration); // Вкладка Antenna: Alex-таблица (openHPSDR) либо таблица разъёмов AD936x. procedure SetAntennaBackend(IsAD936x, HasAntSwitch: Boolean); // Таблица разъёмов + имена/видимость слотов трансвертеров. @@ -474,6 +492,7 @@ type property OnPlutoTxChange: TOnPlutoTxChange read FOnPlutoTxChange write FOnPlutoTxChange; property OnAnt936xChange: TOnAnt936xChange read FOnAnt936xChange write FOnAnt936xChange; property OnVHFCalChange: TOnVHFCalChange read FOnVHFCalChange write FOnVHFCalChange; + property OnCalibrationChange: TOnCalibrationChange read FOnCalibrationChange write FOnCalibrationChange; property OnDisplayChange: TOnDisplayParamChange read FOnDisplayChange write FOnDisplayChange; property OnWaterfallChange: TOnWaterfallParamChange read FOnWaterfallChange write FOnWaterfallChange; property OnWfRenderChange: TOnWfRenderChange read FOnWfRenderChange write FOnWfRenderChange; @@ -704,11 +723,12 @@ begin FNavWaterfall := MakeNavButton('Waterfall', 182); FNavTransmit := MakeNavButton('Transmit', 218); FNavPA := MakeNavButton('Power Amplifier', 254); - FNavAdvanced := MakeNavButton('Advanced', 290); - FNavCAT := MakeNavButton('CAT', 326); - FNavAlex := MakeNavButton('Antenna / Alex', 362); - FNavXvtr := MakeNavButton('Transverter', 398); - FNavQO := MakeNavButton('QO-100', 434); + FNavCalib := MakeNavButton('Calibration', 290); + FNavAdvanced := MakeNavButton('Advanced', 326); + FNavCAT := MakeNavButton('CAT', 362); + FNavAlex := MakeNavButton('Antenna / Alex', 398); + FNavXvtr := MakeNavButton('Transverter', 434); + FNavQO := MakeNavButton('QO-100', 470); FContentPanel := TPanel.Create(Self); FContentPanel.Parent := Self; @@ -722,6 +742,7 @@ begin FPageWaterfall := MakeScrollPage; FPageTransmit := MakeScrollPage; FPagePA := MakeScrollPage; + FPageCalib := MakeScrollPage; FPageAdvanced := MakeScrollPage; FPageCAT := MakeScrollPage; FPageAlex := MakeScrollPage; @@ -730,12 +751,14 @@ begin FAlexBoardType := 0; TSettingsManager.DefaultAlex(FAlexConfig); TSettingsManager.DefaultXvtr(FXvtrConfig); + TSettingsManager.DefaultCalibration(FCalConfig); BuildAudioTab; BuildDisplayTab; BuildRX1Tab; BuildTransmitTab; BuildPATab; + BuildCalibrationTab; BuildAdvancedTab; BuildCATTab; BuildAlexTab; @@ -768,6 +791,7 @@ begin else if Sender = FNavWaterfall then SelectPage(FPageWaterfall, FNavWaterfall) else if Sender = FNavTransmit then SelectPage(FPageTransmit, FNavTransmit) else if Sender = FNavPA then SelectPage(FPagePA, FNavPA) + else if Sender = FNavCalib then SelectPage(FPageCalib, FNavCalib) else if Sender = FNavAdvanced then SelectPage(FPageAdvanced, FNavAdvanced) else if Sender = FNavCAT then SelectPage(FPageCAT, FNavCAT) else if Sender = FNavAlex then SelectPage(FPageAlex, FNavAlex) @@ -783,6 +807,7 @@ begin FPageWaterfall.Visible:= APage = FPageWaterfall; FPageTransmit.Visible := APage = FPageTransmit; FPagePA.Visible := APage = FPagePA; + FPageCalib.Visible := APage = FPageCalib; FPageAdvanced.Visible := APage = FPageAdvanced; FPageCAT.Visible := APage = FPageCAT; FPageAlex.Visible := APage = FPageAlex; @@ -795,6 +820,7 @@ begin FNavWaterfall.Active:= ANav = FNavWaterfall; FNavTransmit.Active := ANav = FNavTransmit; FNavPA.Active := ANav = FNavPA; + FNavCalib.Active := ANav = FNavCalib; FNavAdvanced.Active := ANav = FNavAdvanced; FNavCAT.Active := ANav = FNavCAT; FNavAlex.Active := ANav = FNavAlex; @@ -1859,6 +1885,191 @@ begin end; +procedure TSettingsForm.BuildCalibrationTab; +// Вкладка Calibration (паритет с Thetis-Enhanced): модель детектора мощности +// Alex-моста + множитель FWD/REV на банд (Det. Cal.), калибровка напряжения/ +// тока питания (PAVoltCal/AmpVoff/AmpSens) и офсет S-метра. +const + MARGIN = 22; + GRP_PAD = 18; + LBL_W = 54; + ED_W = 72; + COL_W = LBL_W + ED_W + 14; + COLS = 4; + ROW_H = 32; + R1 = 42; + GRP1_H = 236; + GRP2_H = 156; + GRP3_H = 92; + VA_LBL_W = 220; + + function MakeCalSpin(AParent: TWinControl; ALeft, ATop: Integer; + AMin, AMax, AInc: Double; ADec: Integer; + ADefault: Double): TFlatFloatSpinEdit; + var E: TFlatFloatSpinEdit; + begin + E := TFlatFloatSpinEdit.Create(Self); + E.Parent := AParent; + E.SetBounds(ALeft, ATop, ED_W, BTN_H + 2); + E.Color := CLR_INPUT; + E.Font.Color := CLR_INPUT_TEXT; + E.Font.Name := UI_FONT; + E.Font.Size := 9; + E.MinValue := AMin; + E.MaxValue := AMax; + E.Increment := AInc; + E.DecimalPlaces := ADec; + E.Value := ADefault; + E.OnChange := OnCalAnyChange; + Result := E; + end; + +var + Grp: TPanel; + Lbl: TLabel; + i, r, c, X, Y, GTop: Integer; +begin + // Гард: SetItemIndex у TFlatComboBox стреляет OnChange и при программном + // присвоении — без гарда OnCalAnyChange дёрнется из «ItemIndex := 0» ниже, + // когда остальные контролы вкладки ещё nil → AV в CollectCalFromUI. + FLoading := True; + + with TLabel.Create(Self) do + begin + Parent := FPageCalib; + Caption := 'Calibration'; + SetBounds(MARGIN, 18, 400, 28); + Font.Name := UI_FONT; + Font.Size := 16; + Font.Style := [fsBold]; + Font.Color := CLR_TEXT; + end; + + with TLabel.Create(Self) do + begin + Parent := FPageCalib; + Caption := 'Power/SWR detector, supply voltage/current and S-meter calibration.'; + SetBounds(MARGIN, 48, 620, 20); + Font.Name := UI_FONT; + Font.Size := 9; + Font.Color := CLR_TEXTDIM; + end; + + // ── Power / SWR detector ──────────────────────────────────────────────── + GTop := 86; + Grp := MakeGroupPanel(FPageCalib, 'Power / SWR detector', MARGIN, GTop, 700, GRP1_H); + + Lbl := MakeLbl(Grp, 'Detector model', GRP_PAD, R1 + 6, 132); + FCmbPwrDetModel := MakeCombo(Grp, GRP_PAD + 150, R1, 280, OnCalAnyChange); + FCmbPwrDetModel.Items.Add('ANAN-10/100/100B/100D (3.3V)'); + FCmbPwrDetModel.Items.Add('ANAN-200D / Orion'); + FCmbPwrDetModel.Items.Add('ANAN-7000DLE / Anvelina Pro 3 / G2'); + FCmbPwrDetModel.Items.Add('ANAN-8000DLE / Orion MkII'); + FCmbPwrDetModel.ItemIndex := 0; + + Lbl := MakeLbl(Grp, + 'FWD/REV multiplier per band: 1.00 = no correction, >1 if meter under-reads ' + + '(e.g. 88 W shown at real 100 W -> 1.14). SWR is not affected.', + GRP_PAD, R1 + ROW_H + 10, 650); + + for i := 0 to 10 do + begin + r := i div COLS; + c := i mod COLS; + X := GRP_PAD + c * (COL_W + 4); + Y := R1 + ROW_H + 46 + r * ROW_H; + + Lbl := MakeLbl(Grp, PA_BAND_NAMES[i] + ':', X, Y + 5, LBL_W); + + FEdPADetCal[i] := MakeCalSpin(Grp, X + LBL_W, Y, 0.50, 2.00, 0.01, 2, 1.00); + FEdPADetCal[i].Tag := i; + end; + + // ── Supply voltage / current ──────────────────────────────────────────── + GTop := GTop + GRP1_H + 14; + Grp := MakeGroupPanel(FPageCalib, 'Supply voltage / current', MARGIN, GTop, 700, GRP2_H); + + Lbl := MakeLbl(Grp, 'Voltage multiplier (1.000 = no correction)', GRP_PAD, R1 + 6, VA_LBL_W); + FEdSupplyVoltCal := MakeCalSpin(Grp, GRP_PAD + VA_LBL_W + 10, R1, 0.100, 5.000, 0.001, 3, 1.000); + + Lbl := MakeLbl(Grp, 'Current sensor zero offset (mV)', GRP_PAD, R1 + ROW_H + 6, VA_LBL_W); + FEdAmpVoff := MakeCalSpin(Grp, GRP_PAD + VA_LBL_W + 10, R1 + ROW_H, 0.0, 2000.0, 1.0, 0, 360.0); + + Lbl := MakeLbl(Grp, 'Current sensor sensitivity (mV/A)', GRP_PAD, R1 + 2 * ROW_H + 6, VA_LBL_W); + FEdAmpSens := MakeCalSpin(Grp, GRP_PAD + VA_LBL_W + 10, R1 + 2 * ROW_H, 1.0, 1000.0, 0.1, 1, 120.0); + + Lbl := MakeLbl(Grp, 'Defaults 360 mV / 120 mV/A (ACS712-style, Orion MkII)', + GRP_PAD + VA_LBL_W + 10 + ED_W + 14, R1 + ROW_H + 6, 320); + + // ── S-meter ───────────────────────────────────────────────────────────── + GTop := GTop + GRP2_H + 14; + Grp := MakeGroupPanel(FPageCalib, 'S-meter', MARGIN, GTop, 700, GRP3_H); + + Lbl := MakeLbl(Grp, 'Offset (dB), added to all S-meter readings', GRP_PAD, R1 + 6, VA_LBL_W); + FEdSMeterOffset := MakeCalSpin(Grp, GRP_PAD + VA_LBL_W + 10, R1, -30.0, 30.0, 0.1, 1, 0.0); + + FLoading := False; +end; + +procedure TSettingsForm.CollectCalFromUI; +var i: Integer; +begin + FCalConfig.PwrDetModel := FCmbPwrDetModel.ItemIndex; + // UI редактирует HF-банды 0..10; слот 11 (пустой в HF-плане) не трогаем. + for i := 0 to 10 do + FCalConfig.PADetCal[i] := FEdPADetCal[i].Value; + FCalConfig.SupplyVoltCal := FEdSupplyVoltCal.Value; + FCalConfig.AmpVoffMV := FEdAmpVoff.Value; + FCalConfig.AmpSensMVA := FEdAmpSens.Value; + FCalConfig.SMeterOffsetDB := FEdSMeterOffset.Value; +end; + +procedure TSettingsForm.OnCalAnyChange(Sender: TObject); +begin + if FLoading then Exit; + // Смена модели детектора подставляет модельные дефолты датчика тока + // (Thetis GetDefaultVoltCalibration): ANAN-7000DLE/Anvelina Pro 3 — 340/88, + // остальные — 360/120. Правится руками после, если датчик нестандартный. + if Sender = FCmbPwrDetModel then + begin + FLoading := True; + try + if FCmbPwrDetModel.ItemIndex = 2 then + begin + FEdAmpVoff.Value := 340.0; + FEdAmpSens.Value := 88.0; + end else + begin + FEdAmpVoff.Value := 360.0; + FEdAmpSens.Value := 120.0; + end; + finally + FLoading := False; + end; + end; + CollectCalFromUI; + if Assigned(FOnCalibrationChange) then + FOnCalibrationChange(FCalConfig); +end; + +procedure TSettingsForm.LoadCalibration(const C: TCalibration); +var i: Integer; +begin + FLoading := True; + try + FCalConfig := C; + FCmbPwrDetModel.ItemIndex := EnsureRange(C.PwrDetModel, 0, 3); + for i := 0 to 10 do + FEdPADetCal[i].Value := C.PADetCal[i]; + FEdSupplyVoltCal.Value := C.SupplyVoltCal; + FEdAmpVoff.Value := C.AmpVoffMV; + FEdAmpSens.Value := C.AmpSensMVA; + FEdSMeterOffset.Value := C.SMeterOffsetDB; + finally + FLoading := False; + end; +end; + procedure TSettingsForm.BuildAdvancedTab; const MARGIN = 22; diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 8a79bfa..2485085 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -451,6 +451,7 @@ type FTXMode: Integer; // may differ from RX when a slice is TX source FRawFMDeviation: Double; FSMeter: Double; + FSMeterCalDB: Double; // калибровочный офсет S-метра, дБ (вкладка Calibration) FFilterLow: Integer; FFilterHigh: Integer; FAGCMode: TWDSPAGCMode; @@ -743,6 +744,8 @@ type property SNBEnabled: Boolean read FSNBEnabled; property LastError: string read FLastError; property SMeter: Double read FSMeter; + // Офсет S-метра из вкладки Calibration; применяется в GetSMeterDBm/GetSliceSMeter + property SMeterCalDB: Double read FSMeterCalDB write FSMeterCalDB; property FFTSize: Integer read FFFTSize; property WindowType: Integer read FWindowType; property SpecDetector: Integer read FSpecDetector; @@ -1245,6 +1248,7 @@ begin FAGCTop := -90.0; FVolume := 0.7; FSMeter := -130.0; + FSMeterCalDB := 0.0; FNRMode := NR_MODE_OFF; FNBMode := NB_MODE_OFF; FSNBEnabled := False; @@ -2827,7 +2831,7 @@ begin FSliceLock.Enter; try idx := FindSliceIdx(Id); - if idx >= 0 then Result := FSlices[idx].SMeter; + if idx >= 0 then Result := FSlices[idx].SMeter + FSMeterCalDB; finally FSliceLock.Leave; end; @@ -3880,7 +3884,7 @@ function TWDSPEngine.GetSMeterDBm: Double; begin // НЕ вызываем GetRXAMeter здесь повторно — значение уже обновлено // в DSP-колбэке. Повторный вызов сбрасывает аккумулятор WDSP → 0. - Result := FSMeter; + Result := FSMeter + FSMeterCalDB; end; // --------------------------------------------------------------------------- From ced42fbac9ac9bd436a261b0145ba20d7a934fb8 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 21 Jul 2026 00:13:35 +0300 Subject: [PATCH 2/7] fix(hpsdr): prevent false connection lost on tick race The RX timeout check sampled GetTickCount64 on the UI thread while the UDP receive thread could update FRXLastPktTime a millisecond later. Both values are QWord, so subtracting the newer packet tick from the older UI tick underflowed to a very large value and falsely satisfied the 3-second timeout. Snapshot the packet timestamp once for each timeout check and require NowTick >= LastPktTick before calculating the elapsed interval. The initial-packet timeout uses the same snapshot. This preserves real timeout detection while ignoring a packet timestamp that is marginally newer than the UI tick. Diagnostics confirmed that DDC UDP packets, callbacks, and HP status remained continuous during the reported failures; the issue was local timeout arithmetic rather than network loss or radio stream interruption. --- MainForm.pas | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index 2ee321b..6cdd417 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -3409,6 +3409,7 @@ procedure TMainForm.SpectrumTimerTick(Sender: TObject); var NowTick: QWord; ElapsedTick: QWord; + LastPktTick: QWord; I: Integer; begin // «×» шапки пана: закрытие отложено сюда (нельзя free панель из её кнопки). @@ -3438,8 +3439,12 @@ begin begin NowTick := GetTickCount64; ElapsedTick := NowTick - FController.FRXStartTime; + // Сетевой поток может обновить FRXLastPktTime на 1 ms ПОСЛЕ NowTick. + // QWord-вычитание в таком случае underflow'ится в огромное значение и + // давало ложный "Connection lost" при совершенно живом DDC-потоке. + LastPktTick := FController.FRXLastPktTime; // Ждём первый пакет 5 секунд после старта - if (FController.FRXLastPktTime = 0) and (ElapsedTick > 5000) then + if (LastPktTick = 0) and (ElapsedTick > 5000) then begin BtnStartStop.Caption := 'START'; StyleButton(BtnStartStop, False); @@ -3451,7 +3456,8 @@ begin Exit; end; // После первого пакета — следим чтобы поток не прерывался более 3 сек - if (FController.FRXLastPktTime > 0) and ((NowTick - FController.FRXLastPktTime) > 3000) then + if (LastPktTick > 0) and (NowTick >= LastPktTick) and + ((NowTick - LastPktTick) > 3000) then begin BtnStartStop.Caption := 'START'; StyleButton(BtnStartStop, False); From 797bfc2e7c5cbb34f97a097d3a2e8f5381692a56 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 21 Jul 2026 11:02:04 +0300 Subject: [PATCH 3/7] =?UTF-8?q?feature(calibration):=20=D0=BE=D1=84=D1=81?= =?UTF-8?q?=D0=B5=D1=82=20S-=D0=BC=D0=B5=D1=82=D1=80=D0=B0=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=B8=D0=B0=D0=BF=D0=B0=D0=B7=D0=BE=D0=BD=20=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=82=D1=80=D0=B0=D0=BD=D1=81=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=82=D0=B5=D1=80,=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=20=D0=BF=D0=BE=20=D0=B6=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=B7=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вместо одного офсета на всё устройство — таблицы «на диапазон» и «на слот трансвертера»; состав таблиц задаёт активное железо. - Settings: SMeterOffsetDB -> SMeterBandDB[12] / SMeterXvtrDB[8] (JSON cal_smeter_band_i / cal_smeter_xvtr_i). Легаси-ключ cal_smeter_db становится дефолтом всех слотов — показания после обновления не меняются. Разделение Pluto/openHPSDR даёт сама секция JSON: она ключуется по MAC устройства (у Pluto — synthetic MAC из serial). - Офсет убран из WDSPEngine (FSMeterCalDB): движок не знает ни диапазонов, ни трансвертеров. Теперь TRadioController.SMeterCalFor(VisibleHz) — окно включённого XVTR-слота приоритетнее диапазона, индекс диапазона по плану железа (FreqToBandIdx с IsPluto); ReadSMeterDBm для главного S-метра (MainForm + ewsdrd) и SliceSMeter по TargetHz слайса — паны N на других диапазонах считаются по своему банду, а не по главному. - SettingsForm: таблицы на 12 слотов, подписи/видимость/раскладку ставит RelayoutCalTab; SetCalibrationBackend(IsPluto) вызывается рядом с SetAntennaBackend. У openHPSDR — HF-план, у Pluto — VHF/UHF-план и скрытые группы Power/SWR detector и Supply V/A (нет Alex-моста и его телеметрии). Слоты трансвертеров показываются только включённые, с их именами. Co-Authored-By: Claude Opus 4.8 --- MainForm.pas | 5 +- RadioController.pas | 49 +++++++- Settings.pas | 26 ++++- SettingsForm.pas | 275 +++++++++++++++++++++++++++++++++++--------- WDSPEngine.pas | 11 +- ewsdr.lpi | 6 +- ewsdrd.lpr | 2 +- 7 files changed, 301 insertions(+), 73 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index 6cdd417..eb1225f 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -3481,7 +3481,7 @@ begin if FController.FWDSPReady then begin - FController.FLastSMeter := FController.FDSPEngine.GetSMeterDBm; + FController.FLastSMeter := FController.ReadSMeterDBm; FController.FDSPEngine.SetSpectrumWidth(FPan.SpectrumWidth); Inc(FAgcLineCounter); if FAgcLineCounter >= 6 then @@ -8262,6 +8262,9 @@ begin // физических разъёмов на диапазон/трансвертер (если разъёмов две пары). SF.SetAntennaBackend(FController.BackendCaps.Kind = bkPluto, FController.BackendCaps.HasAntSwitch); + // Вкладка Calibration: band-план и состав групп — по активному железу + // (у AD936x нет моста детектора мощности и телеметрии питания). + SF.SetCalibrationBackend(FController.BackendCaps.Kind = bkPluto); SF.LoadAnt936x(FController.Ant936x, FController.FXvtrSettings); SF.LoadXvtrSettings(FController.FXvtrSettings); SF.LoadPASettings(FController.FPAMaxPower, FController.FPABandCal); diff --git a/RadioController.pas b/RadioController.pas index 3f93422..39ee9fa 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -623,6 +623,11 @@ type procedure SetSliceDSP(Id, NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean); procedure SetSliceFMSquelch(Id: Integer; On_: Boolean; Level: Integer); function SliceSMeter(Id: Integer): Double; // dBm, из DSP-потока + // S-метр главного приёмника, dBm, с калибровочным офсетом текущего + // диапазона/трансвертера (движок отдаёт сырое значение). + function ReadSMeterDBm: Double; + // Офсет S-метра (дБ) для видимой частоты: слот трансвертера или диапазон. + function SMeterCalFor(VisibleHz: Double): Double; procedure SetSliceDevice(Id, DevIndex: Integer; const DevName: string); function SliceCount: Integer; function GetSlice(Id: Integer; out S: TCtrlSlice): Boolean; @@ -685,7 +690,7 @@ type function PlutoTxAttForDrive: Double; // дБ<0 под текущий FDrivePercent procedure ApplyPlutoTxAtten; // ставит att по FTransmitting/FTuning procedure SetPlutoTxMaxAtt(Db: Double); // потолок на drive=100% (внешний усилитель) - procedure SetCalibration(const C: TCalibration); // вкладка Calibration → FCal + офсет S-метра в DSP + procedure SetCalibration(const C: TCalibration); // вкладка Calibration → FCal (офсеты S-метра — в SMeterCalFor) procedure ApplyWDSPDrive(Pct: Integer); // цифровой drive WDSP: Pluto=1.0 (мощность — атт), HPSDR=Pct/100 // Диапазоны / трансвертеры / спан @@ -971,7 +976,6 @@ begin FDSPEngine.OnSliceDemodAudio := OnSliceDemodAudioReady; FDSPEngine.OnDemodAudio := OnDemodAudioReady; FDSPEngine.SetDisplayResLimit(FDisplayPixels); - FDSPEngine.SMeterCalDB := FCal.SMeterOffsetDB; // QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock). FBeaconRefHz := 10489750000.0; // центральный BPSK-маяк NB-транспондера (downlink) @@ -2039,9 +2043,42 @@ begin end; function TRadioController.SliceSMeter(Id: Integer): Double; +// Слайс может стоять на другом диапазоне (паны N кросс-банд), поэтому офсет +// берём по ЕГО видимой частоте, а не по главному диапазону. +var idx: Integer; begin - if Assigned(FDSPEngine) then Result := FDSPEngine.GetSliceSMeter(Id) - else Result := -130.0; + Result := -130.0; + if not Assigned(FDSPEngine) then Exit; + Result := FDSPEngine.GetSliceSMeter(Id); + idx := FindSliceIndex(Id); + if idx >= 0 then Result := Result + SMeterCalFor(FSlices[idx].TargetHz); +end; + +function TRadioController.ReadSMeterDBm: Double; +begin + if not Assigned(FDSPEngine) then Exit(-130.0); + Result := FDSPEngine.GetSMeterDBm + SMeterCalFor(ActiveVfoHz); +end; + +function TRadioController.SMeterCalFor(VisibleHz: Double): Double; +// Трансвертер имеет приоритет над диапазоном: его окно частот — видимые +// (не IF), и усиление тракта задаёт конвертер/LNB, а не КВ-вход трансивера. +// Индекс диапазона — по плану активного железа (HF openHPSDR / VHF-UHF Pluto). +var i, Bi: Integer; +begin + Result := 0.0; + if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) + and FXvtrSettings.Entries[FCurrentXvtr].Enabled + and (VisibleHz >= FXvtrSettings.Entries[FCurrentXvtr].FreqBegin) + and (VisibleHz <= FXvtrSettings.Entries[FCurrentXvtr].FreqEnd) then + Exit(FCal.SMeterXvtrDB[FCurrentXvtr]); + for i := 0 to CFG_XVTR_COUNT-1 do + if FXvtrSettings.Entries[i].Enabled + and (VisibleHz >= FXvtrSettings.Entries[i].FreqBegin) + and (VisibleHz <= FXvtrSettings.Entries[i].FreqEnd) then + Exit(FCal.SMeterXvtrDB[i]); + Bi := FreqToBandIdx(VisibleHz, IsPluto); + if (Bi >= 0) and (Bi < CFG_BAND_COUNT) then Result := FCal.SMeterBandDB[Bi]; end; procedure TRadioController.SetSliceDevice(Id, DevIndex: Integer; @@ -3567,8 +3604,8 @@ begin // Санитизация: битый конфиг не должен дать деление на ноль/абсурдные ватты. FCal.PwrDetModel := EnsureRange(FCal.PwrDetModel, 0, 3); if FCal.AmpSensMVA <= 0 then FCal.AmpSensMVA := 120.0; - if Assigned(FDSPEngine) then - FDSPEngine.SMeterCalDB := FCal.SMeterOffsetDB; + // Офсет S-метра берётся из FCal на каждом чтении (SMeterCalFor) — применять + // отдельно нечего: он зависит от диапазона/трансвертера, а не от движка. SaveGlobalSettings; // персист (гейт по FDevConnected внутри) end; diff --git a/Settings.pas b/Settings.pas index d9e4f1d..df2e668 100644 --- a/Settings.pas +++ b/Settings.pas @@ -339,7 +339,12 @@ type SupplyVoltCal: Double; // множитель напряжения питания (1.0 = без коррекции) AmpVoffMV: Double; // сдвиг нуля датчика тока, мВ (Thetis default 360) AmpSensMVA: Double; // чувствительность датчика тока, мВ/А (Thetis default 120) - SMeterOffsetDB: Double; // офсет S-метра, дБ (прибавляется к WDSP dBm) + // Офсет S-метра, дБ — раздельно на диапазон и на слот трансвертера (усиление + // тракта разное на 160м и на 10м, а у XVTR своё — конвертер/LNB). Смысл + // индекса диапазона задаёт band-план железа (HF у openHPSDR, VHF/UHF у + // Pluto); таблицы не пересекаются, т.к. секция JSON ключуется по MAC. + SMeterBandDB: array[0..CFG_BAND_COUNT-1] of Double; + SMeterXvtrDB: array[0..CFG_XVTR_COUNT-1] of Double; end; TGlobalSettings = record @@ -641,7 +646,7 @@ begin C.SupplyVoltCal := 1.0; C.AmpVoffMV := 360.0; C.AmpSensMVA := 120.0; - C.SMeterOffsetDB := 0.0; + // SMeterBandDB/SMeterXvtrDB — нули после FillChar (без коррекции). end; constructor TSettingsManager.Create(const FilePath: string); @@ -769,7 +774,7 @@ end; function TSettingsManager.LoadDevice(const MAC: array of Byte; out G: TGlobalSettings; var Bands: array of TBandSettings): Boolean; -var MacStr: string; DevObj,GObj,BObj: TJSONObject; i: Integer; +var MacStr: string; DevObj,GObj,BObj: TJSONObject; i: Integer; SMLegacy: Double; begin MacStr := MacToStr(MAC); Result := FRoot.Find(MacStr) <> nil; @@ -891,7 +896,15 @@ begin G.Cal.SupplyVoltCal := EnsureRange(JD(GObj,'cal_supply_volt', 1.0), 0.1, 5.0); G.Cal.AmpVoffMV := EnsureRange(JD(GObj,'cal_amp_voff_mv', 360.0), 0.0, 2000.0); G.Cal.AmpSensMVA := EnsureRange(JD(GObj,'cal_amp_sens_mva', 120.0), 1.0, 1000.0); - G.Cal.SMeterOffsetDB := EnsureRange(JD(GObj,'cal_smeter_db', 0.0), -30.0, 30.0); + // S-метр: офсет на диапазон/трансвертер. Легаси-ключ (одно значение на всё + // устройство) становится дефолтом всех слотов — показания не меняются. + SMLegacy := EnsureRange(JD(GObj,'cal_smeter_db', 0.0), -30.0, 30.0); + for i := 0 to CFG_BAND_COUNT-1 do + G.Cal.SMeterBandDB[i] := + EnsureRange(JD(GObj,'cal_smeter_band_'+IntToStr(i), SMLegacy), -30.0, 30.0); + for i := 0 to CFG_XVTR_COUNT-1 do + G.Cal.SMeterXvtrDB[i] := + EnsureRange(JD(GObj,'cal_smeter_xvtr_'+IntToStr(i), SMLegacy), -30.0, 30.0); for i := 0 to CFG_BAND_COUNT-1 do begin @@ -1008,7 +1021,10 @@ begin JW(O,'cal_supply_volt',G.Cal.SupplyVoltCal); JW(O,'cal_amp_voff_mv',G.Cal.AmpVoffMV); JW(O,'cal_amp_sens_mva',G.Cal.AmpSensMVA); - JW(O,'cal_smeter_db',G.Cal.SMeterOffsetDB); + for i := 0 to CFG_BAND_COUNT-1 do + JW(O,'cal_smeter_band_'+IntToStr(i),G.Cal.SMeterBandDB[i]); + for i := 0 to CFG_XVTR_COUNT-1 do + JW(O,'cal_smeter_xvtr_'+IntToStr(i),G.Cal.SMeterXvtrDB[i]); end; procedure TSettingsManager.SaveBand(const MAC: array of Byte; BandIdx: Integer; diff --git a/SettingsForm.pas b/SettingsForm.pas index 5829e39..b842c9b 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -153,14 +153,31 @@ type FVHFCalLabels: array[0..CFG_XVTR_COUNT-1] of TLabel; // ---- Calibration tab controls ---- + // Таблицы «на диапазон» строятся на 12 слотов (максимум плана); подписи, + // видимость и раскладка задаются железом в RelayoutCalTab: у openHPSDR + // виден HF-план, у Pluto — VHF/UHF-план, слоты трансвертеров — только + // включённые. Значения пишутся в per-device секцию JSON (ключ = MAC), + // поэтому таблицы разных аппаратов не пересекаются. FPageCalib: TScrollBox; FNavCalib: TFlatButton; + FCalGrpDet: TPanel; // Power / SWR detector + FCalGrpVA: TPanel; // Supply voltage / current + FCalGrpSM: TPanel; // S-meter FCmbPwrDetModel: TFlatComboBox; // модель детектора Alex-моста - FEdPADetCal: array[0..10] of TFlatFloatSpinEdit; // множитель FWD/REV на банд + FEdPADetCal: array[0..CFG_BAND_COUNT-1] of TFlatFloatSpinEdit; // множитель FWD/REV на банд + FLblPADetCal: array[0..CFG_BAND_COUNT-1] of TLabel; FEdSupplyVoltCal: TFlatFloatSpinEdit; // множитель напряжения питания FEdAmpVoff: TFlatFloatSpinEdit; // датчик тока: сдвиг нуля, мВ FEdAmpSens: TFlatFloatSpinEdit; // датчик тока: мВ/А - FEdSMeterOffset: TFlatFloatSpinEdit; // офсет S-метра, дБ + FEdSMBand: array[0..CFG_BAND_COUNT-1] of TFlatFloatSpinEdit; // офсет S-метра на диапазон + FLblSMBand: array[0..CFG_BAND_COUNT-1] of TLabel; + FEdSMXvtr: array[0..CFG_XVTR_COUNT-1] of TFlatFloatSpinEdit; // офсет S-метра на трансвертер + FLblSMXvtr: array[0..CFG_XVTR_COUNT-1] of TLabel; + FLblSMBandHdr: TLabel; + FLblSMXvtrHdr: TLabel; + FCalIsPluto: Boolean; // активное железо — AD936x + FCalXvtrOn: array[0..CFG_XVTR_COUNT-1] of Boolean; // слот трансвертера включён + FCalXvtrName: array[0..CFG_XVTR_COUNT-1] of string; // подпись слота FCalConfig: TCalibration; FOnCalibrationChange: TOnCalibrationChange; @@ -352,6 +369,7 @@ type procedure BuildRX1Tab; procedure BuildPATab; procedure BuildCalibrationTab; + procedure RelayoutCalTab; procedure CollectCalFromUI; procedure OnCalAnyChange(Sender: TObject); procedure BuildCATTab; @@ -463,6 +481,8 @@ type procedure LoadPASettings(MaxPower: Double; const BandCal: array of Double); procedure LoadPlutoTxMaxAtt(MaxAttDb: Double); procedure LoadCalibration(const C: TCalibration); + // Железо вкладки Calibration: band-план и состав групп (Pluto/openHPSDR). + procedure SetCalibrationBackend(IsPluto: Boolean); // Вкладка Antenna: Alex-таблица (openHPSDR) либо таблица разъёмов AD936x. procedure SetAntennaBackend(IsAD936x, HasAntSwitch: Boolean); // Таблица разъёмов + имена/видимость слотов трансвертеров. @@ -569,6 +589,21 @@ const PA_BAND_NAMES: array[0..10] of string = ('160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m'); + // Раскладка вкладки Calibration — общая для построения (BuildCalibrationTab) + // и перекладки под активное железо (RelayoutCalTab). + CAL_MARGIN = 22; + CAL_PAD = 18; + CAL_ROW_H = 32; + CAL_R1 = 42; + CAL_ED_W = 72; + CAL_LBL_W = 54; + CAL_SM_LBL_W = 68; // подписи трансвертеров длиннее ('70cm', 'QO-100') + CAL_COLS = 4; + CAL_GRP_W = 700; + CAL_GRP1_H = 236; // Power / SWR detector + CAL_GRP2_H = 156; // Supply voltage / current + CAL_VA_LBL_W = 220; + { TSettingsForm } constructor TSettingsForm.Create(AOwner: TComponent); @@ -1888,20 +1923,11 @@ end; procedure TSettingsForm.BuildCalibrationTab; // Вкладка Calibration (паритет с Thetis-Enhanced): модель детектора мощности // Alex-моста + множитель FWD/REV на банд (Det. Cal.), калибровка напряжения/ -// тока питания (PAVoltCal/AmpVoff/AmpSens) и офсет S-метра. +// тока питания (PAVoltCal/AmpVoff/AmpSens) и офсет S-метра на диапазон и на +// слот трансвертера. Позиции/подписи/видимость band-строк ставит RelayoutCalTab +// под активное железо — здесь только создание контролов. const - MARGIN = 22; - GRP_PAD = 18; - LBL_W = 54; - ED_W = 72; - COL_W = LBL_W + ED_W + 14; - COLS = 4; - ROW_H = 32; - R1 = 42; - GRP1_H = 236; - GRP2_H = 156; - GRP3_H = 92; - VA_LBL_W = 220; + GRP3_H0 = 200; // стартовая высота S-метра, уточняется в RelayoutCalTab function MakeCalSpin(AParent: TWinControl; ALeft, ATop: Integer; AMin, AMax, AInc: Double; ADec: Integer; @@ -1910,7 +1936,7 @@ const begin E := TFlatFloatSpinEdit.Create(Self); E.Parent := AParent; - E.SetBounds(ALeft, ATop, ED_W, BTN_H + 2); + E.SetBounds(ALeft, ATop, CAL_ED_W, BTN_H + 2); E.Color := CLR_INPUT; E.Font.Color := CLR_INPUT_TEXT; E.Font.Name := UI_FONT; @@ -1927,18 +1953,24 @@ const var Grp: TPanel; Lbl: TLabel; - i, r, c, X, Y, GTop: Integer; + i, GTop: Integer; begin // Гард: SetItemIndex у TFlatComboBox стреляет OnChange и при программном // присвоении — без гарда OnCalAnyChange дёрнется из «ItemIndex := 0» ниже, // когда остальные контролы вкладки ещё nil → AV в CollectCalFromUI. FLoading := True; + FCalIsPluto := False; + for i := 0 to CFG_XVTR_COUNT - 1 do + begin + FCalXvtrOn[i] := False; + FCalXvtrName[i] := ''; + end; with TLabel.Create(Self) do begin Parent := FPageCalib; Caption := 'Calibration'; - SetBounds(MARGIN, 18, 400, 28); + SetBounds(CAL_MARGIN, 18, 400, 28); Font.Name := UI_FONT; Font.Size := 16; Font.Style := [fsBold]; @@ -1949,7 +1981,7 @@ begin begin Parent := FPageCalib; Caption := 'Power/SWR detector, supply voltage/current and S-meter calibration.'; - SetBounds(MARGIN, 48, 620, 20); + SetBounds(CAL_MARGIN, 48, 620, 20); Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; @@ -1957,10 +1989,12 @@ begin // ── Power / SWR detector ──────────────────────────────────────────────── GTop := 86; - Grp := MakeGroupPanel(FPageCalib, 'Power / SWR detector', MARGIN, GTop, 700, GRP1_H); + FCalGrpDet := MakeGroupPanel(FPageCalib, 'Power / SWR detector', + CAL_MARGIN, GTop, CAL_GRP_W, CAL_GRP1_H); + Grp := FCalGrpDet; - Lbl := MakeLbl(Grp, 'Detector model', GRP_PAD, R1 + 6, 132); - FCmbPwrDetModel := MakeCombo(Grp, GRP_PAD + 150, R1, 280, OnCalAnyChange); + Lbl := MakeLbl(Grp, 'Detector model', CAL_PAD, CAL_R1 + 6, 132); + FCmbPwrDetModel := MakeCombo(Grp, CAL_PAD + 150, CAL_R1, 280, OnCalAnyChange); FCmbPwrDetModel.Items.Add('ANAN-10/100/100B/100D (3.3V)'); FCmbPwrDetModel.Items.Add('ANAN-200D / Orion'); FCmbPwrDetModel.Items.Add('ANAN-7000DLE / Anvelina Pro 3 / G2'); @@ -1970,58 +2004,187 @@ begin Lbl := MakeLbl(Grp, 'FWD/REV multiplier per band: 1.00 = no correction, >1 if meter under-reads ' + '(e.g. 88 W shown at real 100 W -> 1.14). SWR is not affected.', - GRP_PAD, R1 + ROW_H + 10, 650); + CAL_PAD, CAL_R1 + CAL_ROW_H + 10, 650); - for i := 0 to 10 do + for i := 0 to CFG_BAND_COUNT - 1 do begin - r := i div COLS; - c := i mod COLS; - X := GRP_PAD + c * (COL_W + 4); - Y := R1 + ROW_H + 46 + r * ROW_H; - - Lbl := MakeLbl(Grp, PA_BAND_NAMES[i] + ':', X, Y + 5, LBL_W); - - FEdPADetCal[i] := MakeCalSpin(Grp, X + LBL_W, Y, 0.50, 2.00, 0.01, 2, 1.00); + FLblPADetCal[i] := MakeLbl(Grp, '', 0, 0, CAL_LBL_W); + FEdPADetCal[i] := MakeCalSpin(Grp, 0, 0, 0.50, 2.00, 0.01, 2, 1.00); FEdPADetCal[i].Tag := i; end; // ── Supply voltage / current ──────────────────────────────────────────── - GTop := GTop + GRP1_H + 14; - Grp := MakeGroupPanel(FPageCalib, 'Supply voltage / current', MARGIN, GTop, 700, GRP2_H); + GTop := GTop + CAL_GRP1_H + 14; + FCalGrpVA := MakeGroupPanel(FPageCalib, 'Supply voltage / current', + CAL_MARGIN, GTop, CAL_GRP_W, CAL_GRP2_H); + Grp := FCalGrpVA; - Lbl := MakeLbl(Grp, 'Voltage multiplier (1.000 = no correction)', GRP_PAD, R1 + 6, VA_LBL_W); - FEdSupplyVoltCal := MakeCalSpin(Grp, GRP_PAD + VA_LBL_W + 10, R1, 0.100, 5.000, 0.001, 3, 1.000); + Lbl := MakeLbl(Grp, 'Voltage multiplier (1.000 = no correction)', + CAL_PAD, CAL_R1 + 6, CAL_VA_LBL_W); + FEdSupplyVoltCal := MakeCalSpin(Grp, CAL_PAD + CAL_VA_LBL_W + 10, CAL_R1, + 0.100, 5.000, 0.001, 3, 1.000); - Lbl := MakeLbl(Grp, 'Current sensor zero offset (mV)', GRP_PAD, R1 + ROW_H + 6, VA_LBL_W); - FEdAmpVoff := MakeCalSpin(Grp, GRP_PAD + VA_LBL_W + 10, R1 + ROW_H, 0.0, 2000.0, 1.0, 0, 360.0); + Lbl := MakeLbl(Grp, 'Current sensor zero offset (mV)', + CAL_PAD, CAL_R1 + CAL_ROW_H + 6, CAL_VA_LBL_W); + FEdAmpVoff := MakeCalSpin(Grp, CAL_PAD + CAL_VA_LBL_W + 10, CAL_R1 + CAL_ROW_H, + 0.0, 2000.0, 1.0, 0, 360.0); - Lbl := MakeLbl(Grp, 'Current sensor sensitivity (mV/A)', GRP_PAD, R1 + 2 * ROW_H + 6, VA_LBL_W); - FEdAmpSens := MakeCalSpin(Grp, GRP_PAD + VA_LBL_W + 10, R1 + 2 * ROW_H, 1.0, 1000.0, 0.1, 1, 120.0); + Lbl := MakeLbl(Grp, 'Current sensor sensitivity (mV/A)', + CAL_PAD, CAL_R1 + 2 * CAL_ROW_H + 6, CAL_VA_LBL_W); + FEdAmpSens := MakeCalSpin(Grp, CAL_PAD + CAL_VA_LBL_W + 10, CAL_R1 + 2 * CAL_ROW_H, + 1.0, 1000.0, 0.1, 1, 120.0); Lbl := MakeLbl(Grp, 'Defaults 360 mV / 120 mV/A (ACS712-style, Orion MkII)', - GRP_PAD + VA_LBL_W + 10 + ED_W + 14, R1 + ROW_H + 6, 320); + CAL_PAD + CAL_VA_LBL_W + 10 + CAL_ED_W + 14, + CAL_R1 + CAL_ROW_H + 6, 320); // ── S-meter ───────────────────────────────────────────────────────────── - GTop := GTop + GRP2_H + 14; - Grp := MakeGroupPanel(FPageCalib, 'S-meter', MARGIN, GTop, 700, GRP3_H); + GTop := GTop + CAL_GRP2_H + 14; + FCalGrpSM := MakeGroupPanel(FPageCalib, 'S-meter', + CAL_MARGIN, GTop, CAL_GRP_W, GRP3_H0); + Grp := FCalGrpSM; - Lbl := MakeLbl(Grp, 'Offset (dB), added to all S-meter readings', GRP_PAD, R1 + 6, VA_LBL_W); - FEdSMeterOffset := MakeCalSpin(Grp, GRP_PAD + VA_LBL_W + 10, R1, -30.0, 30.0, 0.1, 1, 0.0); + FLblSMBandHdr := MakeLbl(Grp, + 'Offset (dB) added to S-meter readings — per band (+ = meter reads higher).', + CAL_PAD, 30, 650); + + for i := 0 to CFG_BAND_COUNT - 1 do + begin + FLblSMBand[i] := MakeLbl(Grp, '', 0, 0, CAL_SM_LBL_W); + FEdSMBand[i] := MakeCalSpin(Grp, 0, 0, -30.0, 30.0, 0.1, 1, 0.0); + FEdSMBand[i].Tag := i; + end; + + FLblSMXvtrHdr := MakeLbl(Grp, 'Per transverter slot', CAL_PAD, 0, 300); + + for i := 0 to CFG_XVTR_COUNT - 1 do + begin + FLblSMXvtr[i] := MakeLbl(Grp, '', 0, 0, CAL_SM_LBL_W); + FLblSMXvtr[i].AutoSize := False; + FLblSMXvtr[i].ShowHint := True; + FEdSMXvtr[i] := MakeCalSpin(Grp, 0, 0, -30.0, 30.0, 0.1, 1, 0.0); + FEdSMXvtr[i].Tag := i; + end; + + RelayoutCalTab; // стартовая раскладка (HF-план, трансвертеры скрыты) FLoading := False; end; +procedure TSettingsForm.RelayoutCalTab; +// Показывает только то, что есть у активного железа: band-план (HF у openHPSDR, +// VHF/UHF у Pluto), из трансвертеров — включённые слоты. Детектор мощности и +// датчики питания — свойства Alex-моста openHPSDR, у AD936x такой телеметрии +// нет, поэтому обе группы прячутся, а S-метр поднимается наверх. +var + i, r, c, X, Y, GTop, BandRows, XvtrRows, Slot, H: Integer; + Nm: string; +begin + // Гард: вкладка ещё строится (или LoadXvtrSettings пришёл раньше неё). + if (FCalGrpDet = nil) or (FCalGrpVA = nil) or (FCalGrpSM = nil) + or (FLblSMXvtrHdr = nil) then Exit; + + // ---- строки диапазонов: позиция по индексу плана, пустые слоты скрыты ---- + BandRows := 0; + for i := 0 to CFG_BAND_COUNT - 1 do + begin + Nm := BandName(i, FCalIsPluto); + r := i div CAL_COLS; + c := i mod CAL_COLS; + + X := CAL_PAD + c * (CAL_LBL_W + CAL_ED_W + 18); + Y := CAL_R1 + CAL_ROW_H + 46 + r * CAL_ROW_H; + if FLblPADetCal[i] <> nil then + begin + FLblPADetCal[i].Caption := Nm + ':'; + FLblPADetCal[i].SetBounds(X, Y + 5, CAL_LBL_W, 18); + FLblPADetCal[i].Visible := Nm <> ''; + end; + if FEdPADetCal[i] <> nil then + begin + FEdPADetCal[i].SetBounds(X + CAL_LBL_W, Y, CAL_ED_W, BTN_H + 2); + FEdPADetCal[i].Visible := Nm <> ''; + end; + + X := CAL_PAD + c * (CAL_SM_LBL_W + CAL_ED_W + 14); + Y := 52 + r * CAL_ROW_H; + if FLblSMBand[i] <> nil then + begin + FLblSMBand[i].Caption := Nm + ':'; + FLblSMBand[i].SetBounds(X, Y + 5, CAL_SM_LBL_W, 18); + FLblSMBand[i].Visible := Nm <> ''; + end; + if FEdSMBand[i] <> nil then + begin + FEdSMBand[i].SetBounds(X + CAL_SM_LBL_W, Y, CAL_ED_W, BTN_H + 2); + FEdSMBand[i].Visible := Nm <> ''; + end; + if Nm <> '' then BandRows := r + 1; + end; + + // ---- строки трансвертеров: только включённые слоты, без дырок ---- + Y := 52 + BandRows * CAL_ROW_H + 6; + FLblSMXvtrHdr.SetBounds(CAL_PAD, Y, 300, 18); + Slot := 0; + for i := 0 to CFG_XVTR_COUNT - 1 do + begin + if FCalXvtrOn[i] then + begin + r := Slot div CAL_COLS; + c := Slot mod CAL_COLS; + X := CAL_PAD + c * (CAL_SM_LBL_W + CAL_ED_W + 14); + if FCalXvtrName[i] <> '' then Nm := FCalXvtrName[i] + else Nm := 'XVTR' + IntToStr(i); + FLblSMXvtr[i].Caption := Nm + ':'; + FLblSMXvtr[i].Hint := 'XVTR' + IntToStr(i) + ' (' + Nm + ')'; + FLblSMXvtr[i].SetBounds(X, Y + 24 + r * CAL_ROW_H + 5, CAL_SM_LBL_W, 18); + FEdSMXvtr[i].SetBounds(X + CAL_SM_LBL_W, Y + 24 + r * CAL_ROW_H, + CAL_ED_W, BTN_H + 2); + Inc(Slot); + end; + FLblSMXvtr[i].Visible := FCalXvtrOn[i]; + FEdSMXvtr[i].Visible := FCalXvtrOn[i]; + end; + XvtrRows := (Slot + CAL_COLS - 1) div CAL_COLS; + FLblSMXvtrHdr.Visible := Slot > 0; + + if Slot > 0 then H := Y + 24 + XvtrRows * CAL_ROW_H + 12 + else H := Y + 12; + + // ---- вертикальная укладка групп ---- + GTop := 86; + FCalGrpDet.Visible := not FCalIsPluto; + FCalGrpVA.Visible := not FCalIsPluto; + if not FCalIsPluto then + begin + FCalGrpDet.SetBounds(CAL_MARGIN, GTop, CAL_GRP_W, CAL_GRP1_H); + GTop := GTop + CAL_GRP1_H + 14; + FCalGrpVA.SetBounds(CAL_MARGIN, GTop, CAL_GRP_W, CAL_GRP2_H); + GTop := GTop + CAL_GRP2_H + 14; + end; + FCalGrpSM.SetBounds(CAL_MARGIN, GTop, CAL_GRP_W, H); +end; + +procedure TSettingsForm.SetCalibrationBackend(IsPluto: Boolean); +begin + FCalIsPluto := IsPluto; + RelayoutCalTab; +end; + procedure TSettingsForm.CollectCalFromUI; var i: Integer; begin FCalConfig.PwrDetModel := FCmbPwrDetModel.ItemIndex; - // UI редактирует HF-банды 0..10; слот 11 (пустой в HF-плане) не трогаем. - for i := 0 to 10 do - FCalConfig.PADetCal[i] := FEdPADetCal[i].Value; + for i := 0 to CFG_BAND_COUNT - 1 do + begin + if FEdPADetCal[i] <> nil then FCalConfig.PADetCal[i] := FEdPADetCal[i].Value; + if FEdSMBand[i] <> nil then FCalConfig.SMeterBandDB[i] := FEdSMBand[i].Value; + end; + for i := 0 to CFG_XVTR_COUNT - 1 do + if FEdSMXvtr[i] <> nil then FCalConfig.SMeterXvtrDB[i] := FEdSMXvtr[i].Value; FCalConfig.SupplyVoltCal := FEdSupplyVoltCal.Value; FCalConfig.AmpVoffMV := FEdAmpVoff.Value; FCalConfig.AmpSensMVA := FEdAmpSens.Value; - FCalConfig.SMeterOffsetDB := FEdSMeterOffset.Value; end; procedure TSettingsForm.OnCalAnyChange(Sender: TObject); @@ -2059,12 +2222,16 @@ begin try FCalConfig := C; FCmbPwrDetModel.ItemIndex := EnsureRange(C.PwrDetModel, 0, 3); - for i := 0 to 10 do - FEdPADetCal[i].Value := C.PADetCal[i]; + for i := 0 to CFG_BAND_COUNT - 1 do + begin + if FEdPADetCal[i] <> nil then FEdPADetCal[i].Value := C.PADetCal[i]; + if FEdSMBand[i] <> nil then FEdSMBand[i].Value := C.SMeterBandDB[i]; + end; + for i := 0 to CFG_XVTR_COUNT - 1 do + if FEdSMXvtr[i] <> nil then FEdSMXvtr[i].Value := C.SMeterXvtrDB[i]; FEdSupplyVoltCal.Value := C.SupplyVoltCal; FEdAmpVoff.Value := C.AmpVoffMV; FEdAmpSens.Value := C.AmpSensMVA; - FEdSMeterOffset.Value := C.SMeterOffsetDB; finally FLoading := False; end; @@ -4012,7 +4179,11 @@ begin FVHFCalLabels[i].Caption := 'VHF' + IntToStr(i) + ':'; FVHFCalLabels[i].Hint := FVHFCalLabels[i].Caption; end; + // Офсеты S-метра на вкладке Calibration — только по включённым слотам. + FCalXvtrOn[i] := X.Entries[i].Enabled; + FCalXvtrName[i] := X.Entries[i].ButtonText; end; + RelayoutCalTab; if FChkXvtrTunPwr <> nil then FChkXvtrTunPwr.Checked := X.UseXVTRTunePower; LoadQOFromConfig; // QO100_SLOT → отдельная страница QO-100 finally diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 2485085..b966605 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -451,7 +451,6 @@ type FTXMode: Integer; // may differ from RX when a slice is TX source FRawFMDeviation: Double; FSMeter: Double; - FSMeterCalDB: Double; // калибровочный офсет S-метра, дБ (вкладка Calibration) FFilterLow: Integer; FFilterHigh: Integer; FAGCMode: TWDSPAGCMode; @@ -744,8 +743,6 @@ type property SNBEnabled: Boolean read FSNBEnabled; property LastError: string read FLastError; property SMeter: Double read FSMeter; - // Офсет S-метра из вкладки Calibration; применяется в GetSMeterDBm/GetSliceSMeter - property SMeterCalDB: Double read FSMeterCalDB write FSMeterCalDB; property FFTSize: Integer read FFFTSize; property WindowType: Integer read FWindowType; property SpecDetector: Integer read FSpecDetector; @@ -1248,7 +1245,6 @@ begin FAGCTop := -90.0; FVolume := 0.7; FSMeter := -130.0; - FSMeterCalDB := 0.0; FNRMode := NR_MODE_OFF; FNBMode := NB_MODE_OFF; FSNBEnabled := False; @@ -2831,7 +2827,7 @@ begin FSliceLock.Enter; try idx := FindSliceIdx(Id); - if idx >= 0 then Result := FSlices[idx].SMeter + FSMeterCalDB; + if idx >= 0 then Result := FSlices[idx].SMeter; finally FSliceLock.Leave; end; @@ -3884,7 +3880,10 @@ function TWDSPEngine.GetSMeterDBm: Double; begin // НЕ вызываем GetRXAMeter здесь повторно — значение уже обновлено // в DSP-колбэке. Повторный вызов сбрасывает аккумулятор WDSP → 0. - Result := FSMeter + FSMeterCalDB; + // Калибровочный офсет здесь НЕ прибавляется: он зависит от диапазона/ + // трансвертера, о которых движок не знает — его добавляет TRadioController + // (ReadSMeterDBm / SliceSMeter). + Result := FSMeter; end; // --------------------------------------------------------------------------- diff --git a/ewsdr.lpi b/ewsdr.lpi index 82176c3..2c969dc 100644 --- a/ewsdr.lpi +++ b/ewsdr.lpi @@ -17,9 +17,9 @@ - + - + @@ -126,6 +126,8 @@ + + diff --git a/ewsdrd.lpr b/ewsdrd.lpr index 1af8123..7049ac8 100644 --- a/ewsdrd.lpr +++ b/ewsdrd.lpr @@ -466,7 +466,7 @@ begin // не обновляет в headless — освежаем здесь, как GUI-таймер (MainForm). Без // этого PushState слал бы клиентам залипший -130 dBm. if FCtrl.FRunning and FCtrl.FWDSPReady then - FCtrl.FLastSMeter := FCtrl.FDSPEngine.GetSMeterDBm; + FCtrl.FLastSMeter := FCtrl.ReadSMeterDBm; FWebAd.PushState; // исходящее зеркало состояния/спектра lastPush := nowMs; end; From 366d4c9821bba12df78e6a7bc81efe7d37903b05 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 21 Jul 2026 11:16:34 +0300 Subject: [PATCH 4/7] =?UTF-8?q?feature(calibration):=20=D0=BE=D1=84=D1=81?= =?UTF-8?q?=D0=B5=D1=82=20=D1=81=D0=BF=D0=B5=D0=BA=D1=82=D1=80=D0=B0/?= =?UTF-8?q?=D0=B2=D0=BE=D0=B4=D0=BE=D0=BF=D0=B0=D0=B4=D0=B0=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=B8=D0=B0=D0=BF=D0=B0=D0=B7=D0=BE=D0=BD=20=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=82=D1=80=D0=B0=D0=BD=D1=81=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=82=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вторая калибровочная таблица рядом с S-метром (паритет с Display Cal Offset в Thetis): у display-тракта своё окно/детектор, и его уровень расходится с показаниями стрелки на пару дБ, поэтому таблицы раздельные. - Settings: DispBandDB[12] / DispXvtrDB[8] (JSON cal_disp_band_i / cal_disp_xvtr_i, дефолт 0 — картинка не меняется до первой правки). - WDSPEngine: колбэк OnDispCal(PanId) — движок раз в кадр спрашивает офсет в UpdateSpectrum и прибавляет к пикселям (главный спектр/водопад в копирующем цикле, паны in-place при ненулевом офсете). На TX-дисплее не применяется. Так обошлись без хуков на каждое присваивание FCenterFreq. - RadioController: общий CalLookup(Hz, BandTbl, XvtrTbl) — на нём SMeterCalFor и DispCalFor(PanId): главный дисплей по FCenterFreq, пан по FPanFreqHz[p], т.е. кросс-банд паны получают свой офсет. Колбэк вешается в CreateEngines, поэтому работает и в GUI, и в ewsdrd; web-зеркало получает готовые кадры. - SettingsForm: группа «Spectrum / waterfall» с той же разбивкой (диапазоны + включённые трансвертеры активного железа) и кнопка «= S-meter» — копия таблицы стрелки как старт калибровки; общая раскладка сеток вынесена в LayoutCalOffsets. Co-Authored-By: Claude Opus 4.8 --- RadioController.pas | 35 ++++++- Settings.pas | 16 ++++ SettingsForm.pas | 224 +++++++++++++++++++++++++++++++++----------- WDSPEngine.pas | 30 +++++- 4 files changed, 244 insertions(+), 61 deletions(-) diff --git a/RadioController.pas b/RadioController.pas index 39ee9fa..a5147c2 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -452,6 +452,10 @@ type procedure OnAudioReady(const Left, Right: array of Single; Count: Integer); procedure OnDemodAudioReady(const Left, Right: array of Single; Count: Integer); procedure OnDMRAudioReady(const PCM: array of Single; Count: Integer); + // Общая выборка калибровочной таблицы «диапазон/трансвертер» по частоте. + function CalLookup(VisibleHz: Double; + const BandTbl: array of Double; + const XvtrTbl: array of Double): Double; procedure OnSpectrumReady(const Pixels: array of Single; Count: Integer); procedure OnWaterfallReady(const Pixels: array of Single; Count: Integer); procedure OnMicPacket(const Data: TMicDataPacket); @@ -628,6 +632,9 @@ type function ReadSMeterDBm: Double; // Офсет S-метра (дБ) для видимой частоты: слот трансвертера или диапазон. function SMeterCalFor(VisibleHz: Double): Double; + // Офсет спектра/водопада (дБ) для дисплея: PanId 0 = главный, 1..N — пан. + // Колбэк движка (display-поток), см. TOnDispCalQuery. + function DispCalFor(PanId: Integer): Double; procedure SetSliceDevice(Id, DevIndex: Integer; const DevName: string); function SliceCount: Integer; function GetSlice(Id: Integer; out S: TCtrlSlice): Boolean; @@ -975,6 +982,9 @@ begin FDSPEngine.OnSliceAudio := OnSliceAudioReady; FDSPEngine.OnSliceDemodAudio := OnSliceDemodAudioReady; FDSPEngine.OnDemodAudio := OnDemodAudioReady; + // Калибровка уровня спектра/водопада на диапазон/трансвертер — движок + // спрашивает её раз в кадр (сам он про диапазоны не знает). + FDSPEngine.OnDispCal := DispCalFor; FDSPEngine.SetDisplayResLimit(FDisplayPixels); // QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock). @@ -2060,7 +2070,8 @@ begin Result := FDSPEngine.GetSMeterDBm + SMeterCalFor(ActiveVfoHz); end; -function TRadioController.SMeterCalFor(VisibleHz: Double): Double; +function TRadioController.CalLookup(VisibleHz: Double; + const BandTbl: array of Double; const XvtrTbl: array of Double): Double; // Трансвертер имеет приоритет над диапазоном: его окно частот — видимые // (не IF), и усиление тракта задаёт конвертер/LNB, а не КВ-вход трансивера. // Индекс диапазона — по плану активного железа (HF openHPSDR / VHF-UHF Pluto). @@ -2071,14 +2082,30 @@ begin and FXvtrSettings.Entries[FCurrentXvtr].Enabled and (VisibleHz >= FXvtrSettings.Entries[FCurrentXvtr].FreqBegin) and (VisibleHz <= FXvtrSettings.Entries[FCurrentXvtr].FreqEnd) then - Exit(FCal.SMeterXvtrDB[FCurrentXvtr]); + Exit(XvtrTbl[FCurrentXvtr]); for i := 0 to CFG_XVTR_COUNT-1 do if FXvtrSettings.Entries[i].Enabled and (VisibleHz >= FXvtrSettings.Entries[i].FreqBegin) and (VisibleHz <= FXvtrSettings.Entries[i].FreqEnd) then - Exit(FCal.SMeterXvtrDB[i]); + Exit(XvtrTbl[i]); Bi := FreqToBandIdx(VisibleHz, IsPluto); - if (Bi >= 0) and (Bi < CFG_BAND_COUNT) then Result := FCal.SMeterBandDB[Bi]; + if (Bi >= 0) and (Bi < CFG_BAND_COUNT) then Result := BandTbl[Bi]; +end; + +function TRadioController.SMeterCalFor(VisibleHz: Double): Double; +begin + Result := CalLookup(VisibleHz, FCal.SMeterBandDB, FCal.SMeterXvtrDB); +end; + +function TRadioController.DispCalFor(PanId: Integer): Double; +// Зовётся из display-потока движка раз в кадр. Главный дисплей калибруем по +// центру полосы, доп. пан — по своей частоте (паны бывают кросс-банд). +var Hz: Double; +begin + if (PanId > 0) and (PanId < MAX_PANS) then Hz := FPanFreqHz[PanId] + else Hz := FCenterFreq; + if Hz <= 0 then Exit(0.0); + Result := CalLookup(Hz, FCal.DispBandDB, FCal.DispXvtrDB); end; procedure TRadioController.SetSliceDevice(Id, DevIndex: Integer; diff --git a/Settings.pas b/Settings.pas index df2e668..bb96fda 100644 --- a/Settings.pas +++ b/Settings.pas @@ -345,6 +345,11 @@ type // Pluto); таблицы не пересекаются, т.к. секция JSON ключуется по MAC. SMeterBandDB: array[0..CFG_BAND_COUNT-1] of Double; SMeterXvtrDB: array[0..CFG_XVTR_COUNT-1] of Double; + // Офсет спектра/водопада, дБ — та же разбивка. Отдельно от S-метра (как + // Display Cal Offset в Thetis): у display-тракта своё окно/детектор, и его + // уровень отличается от показаний стрелки на пару дБ. + DispBandDB: array[0..CFG_BAND_COUNT-1] of Double; + DispXvtrDB: array[0..CFG_XVTR_COUNT-1] of Double; end; TGlobalSettings = record @@ -905,6 +910,13 @@ begin for i := 0 to CFG_XVTR_COUNT-1 do G.Cal.SMeterXvtrDB[i] := EnsureRange(JD(GObj,'cal_smeter_xvtr_'+IntToStr(i), SMLegacy), -30.0, 30.0); + // Спектр/водопад: своя таблица офсетов (дефолт 0 — как было). + for i := 0 to CFG_BAND_COUNT-1 do + G.Cal.DispBandDB[i] := + EnsureRange(JD(GObj,'cal_disp_band_'+IntToStr(i), 0.0), -30.0, 30.0); + for i := 0 to CFG_XVTR_COUNT-1 do + G.Cal.DispXvtrDB[i] := + EnsureRange(JD(GObj,'cal_disp_xvtr_'+IntToStr(i), 0.0), -30.0, 30.0); for i := 0 to CFG_BAND_COUNT-1 do begin @@ -1025,6 +1037,10 @@ begin JW(O,'cal_smeter_band_'+IntToStr(i),G.Cal.SMeterBandDB[i]); for i := 0 to CFG_XVTR_COUNT-1 do JW(O,'cal_smeter_xvtr_'+IntToStr(i),G.Cal.SMeterXvtrDB[i]); + for i := 0 to CFG_BAND_COUNT-1 do + JW(O,'cal_disp_band_'+IntToStr(i),G.Cal.DispBandDB[i]); + for i := 0 to CFG_XVTR_COUNT-1 do + JW(O,'cal_disp_xvtr_'+IntToStr(i),G.Cal.DispXvtrDB[i]); end; procedure TSettingsManager.SaveBand(const MAC: array of Byte; BandIdx: Integer; diff --git a/SettingsForm.pas b/SettingsForm.pas index b842c9b..747aa29 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -175,6 +175,14 @@ type FLblSMXvtr: array[0..CFG_XVTR_COUNT-1] of TLabel; FLblSMBandHdr: TLabel; FLblSMXvtrHdr: TLabel; + FCalGrpDisp: TPanel; // Spectrum / waterfall + FEdDispBand: array[0..CFG_BAND_COUNT-1] of TFlatFloatSpinEdit; // офсет спектра на диапазон + FLblDispBand: array[0..CFG_BAND_COUNT-1] of TLabel; + FEdDispXvtr: array[0..CFG_XVTR_COUNT-1] of TFlatFloatSpinEdit; // офсет спектра на трансвертер + FLblDispXvtr: array[0..CFG_XVTR_COUNT-1] of TLabel; + FLblDispBandHdr: TLabel; + FLblDispXvtrHdr: TLabel; + FBtnDispFromSM: TFlatButton; // «= S-meter» — копия таблицы FCalIsPluto: Boolean; // активное железо — AD936x FCalXvtrOn: array[0..CFG_XVTR_COUNT-1] of Boolean; // слот трансвертера включён FCalXvtrName: array[0..CFG_XVTR_COUNT-1] of string; // подпись слота @@ -370,6 +378,15 @@ type procedure BuildPATab; procedure BuildCalibrationTab; procedure RelayoutCalTab; + // Раскладка блока офсетов «на диапазон + на трансвертер» внутри группы + // (общая для S-метра и спектра). Возвращает нужную высоту группы. + function LayoutCalOffsets(const BandLbl: array of TLabel; + const BandEd: array of TFlatFloatSpinEdit; + XvtrHdr: TLabel; + const XvtrLbl: array of TLabel; + const XvtrEd: array of TFlatFloatSpinEdit; + Y0: Integer): Integer; + procedure OnCalDispFromSMClick(Sender: TObject); procedure CollectCalFromUI; procedure OnCalAnyChange(Sender: TObject); procedure BuildCATTab; @@ -2066,6 +2083,53 @@ begin FEdSMXvtr[i].Tag := i; end; + // ── Spectrum / waterfall ──────────────────────────────────────────────── + // Отдельная от S-метра калибровка (Display Cal Offset в Thetis): у display- + // тракта своё окно/детектор, поэтому уровень трассы и показания стрелки + // расходятся на пару дБ. Сдвиг двигает ВСЮ трассу (и шумовую полку, и + // сигналы) — это калибровка уровня, а не подавление шума. + FCalGrpDisp := MakeGroupPanel(FPageCalib, 'Spectrum / waterfall', + CAL_MARGIN, GTop, CAL_GRP_W, GRP3_H0); + Grp := FCalGrpDisp; + + FLblDispBandHdr := MakeLbl(Grp, + 'Offset (dB) added to spectrum/waterfall levels — per band (moves the whole ' + + 'trace incl. noise floor).', CAL_PAD, 30, 650); + + FBtnDispFromSM := TFlatButton.Create(Self); + FBtnDispFromSM.Parent := Grp; + FBtnDispFromSM.Caption := '= S-meter'; + FBtnDispFromSM.SetBounds(CAL_GRP_W - 130, 24, 112, BTN_H); + FBtnDispFromSM.OnClick := OnCalDispFromSMClick; + FBtnDispFromSM.ClrNorm := CLR_PANEL; + FBtnDispFromSM.ClrBorder := TColor($00404040); + FBtnDispFromSM.ClrHot := TColor($00303030); + FBtnDispFromSM.ClrActive := TColor($00003300); + FBtnDispFromSM.ClrText := CLR_TEXT; + FBtnDispFromSM.ClrTextAct := CLR_ACCENT; + FBtnDispFromSM.Font.Name := UI_FONT; + FBtnDispFromSM.Font.Size := 9; + FBtnDispFromSM.Hint := 'Copy the S-meter offsets into this table'; + FBtnDispFromSM.ShowHint := True; + + for i := 0 to CFG_BAND_COUNT - 1 do + begin + FLblDispBand[i] := MakeLbl(Grp, '', 0, 0, CAL_SM_LBL_W); + FEdDispBand[i] := MakeCalSpin(Grp, 0, 0, -30.0, 30.0, 0.1, 1, 0.0); + FEdDispBand[i].Tag := i; + end; + + FLblDispXvtrHdr := MakeLbl(Grp, 'Per transverter slot', CAL_PAD, 0, 300); + + for i := 0 to CFG_XVTR_COUNT - 1 do + begin + FLblDispXvtr[i] := MakeLbl(Grp, '', 0, 0, CAL_SM_LBL_W); + FLblDispXvtr[i].AutoSize := False; + FLblDispXvtr[i].ShowHint := True; + FEdDispXvtr[i] := MakeCalSpin(Grp, 0, 0, -30.0, 30.0, 0.1, 1, 0.0); + FEdDispXvtr[i].Tag := i; + end; + RelayoutCalTab; // стартовая раскладка (HF-план, трансвертеры скрыты) FLoading := False; @@ -2077,23 +2141,21 @@ procedure TSettingsForm.RelayoutCalTab; // датчики питания — свойства Alex-моста openHPSDR, у AD936x такой телеметрии // нет, поэтому обе группы прячутся, а S-метр поднимается наверх. var - i, r, c, X, Y, GTop, BandRows, XvtrRows, Slot, H: Integer; + i, r, c, X, Y, GTop, HSM, HDisp: Integer; Nm: string; begin // Гард: вкладка ещё строится (или LoadXvtrSettings пришёл раньше неё). if (FCalGrpDet = nil) or (FCalGrpVA = nil) or (FCalGrpSM = nil) - or (FLblSMXvtrHdr = nil) then Exit; + or (FCalGrpDisp = nil) or (FLblDispXvtrHdr = nil) then Exit; - // ---- строки диапазонов: позиция по индексу плана, пустые слоты скрыты ---- - BandRows := 0; + // ---- подписи/видимость строк Det. Cal. (своя сетка, уже с подсказкой) ---- for i := 0 to CFG_BAND_COUNT - 1 do begin Nm := BandName(i, FCalIsPluto); r := i div CAL_COLS; c := i mod CAL_COLS; - - X := CAL_PAD + c * (CAL_LBL_W + CAL_ED_W + 18); - Y := CAL_R1 + CAL_ROW_H + 46 + r * CAL_ROW_H; + X := CAL_PAD + c * (CAL_LBL_W + CAL_ED_W + 18); + Y := CAL_R1 + CAL_ROW_H + 46 + r * CAL_ROW_H; if FLblPADetCal[i] <> nil then begin FLblPADetCal[i].Caption := Nm + ':'; @@ -2105,51 +2167,13 @@ begin FEdPADetCal[i].SetBounds(X + CAL_LBL_W, Y, CAL_ED_W, BTN_H + 2); FEdPADetCal[i].Visible := Nm <> ''; end; - - X := CAL_PAD + c * (CAL_SM_LBL_W + CAL_ED_W + 14); - Y := 52 + r * CAL_ROW_H; - if FLblSMBand[i] <> nil then - begin - FLblSMBand[i].Caption := Nm + ':'; - FLblSMBand[i].SetBounds(X, Y + 5, CAL_SM_LBL_W, 18); - FLblSMBand[i].Visible := Nm <> ''; - end; - if FEdSMBand[i] <> nil then - begin - FEdSMBand[i].SetBounds(X + CAL_SM_LBL_W, Y, CAL_ED_W, BTN_H + 2); - FEdSMBand[i].Visible := Nm <> ''; - end; - if Nm <> '' then BandRows := r + 1; end; - // ---- строки трансвертеров: только включённые слоты, без дырок ---- - Y := 52 + BandRows * CAL_ROW_H + 6; - FLblSMXvtrHdr.SetBounds(CAL_PAD, Y, 300, 18); - Slot := 0; - for i := 0 to CFG_XVTR_COUNT - 1 do - begin - if FCalXvtrOn[i] then - begin - r := Slot div CAL_COLS; - c := Slot mod CAL_COLS; - X := CAL_PAD + c * (CAL_SM_LBL_W + CAL_ED_W + 14); - if FCalXvtrName[i] <> '' then Nm := FCalXvtrName[i] - else Nm := 'XVTR' + IntToStr(i); - FLblSMXvtr[i].Caption := Nm + ':'; - FLblSMXvtr[i].Hint := 'XVTR' + IntToStr(i) + ' (' + Nm + ')'; - FLblSMXvtr[i].SetBounds(X, Y + 24 + r * CAL_ROW_H + 5, CAL_SM_LBL_W, 18); - FEdSMXvtr[i].SetBounds(X + CAL_SM_LBL_W, Y + 24 + r * CAL_ROW_H, - CAL_ED_W, BTN_H + 2); - Inc(Slot); - end; - FLblSMXvtr[i].Visible := FCalXvtrOn[i]; - FEdSMXvtr[i].Visible := FCalXvtrOn[i]; - end; - XvtrRows := (Slot + CAL_COLS - 1) div CAL_COLS; - FLblSMXvtrHdr.Visible := Slot > 0; - - if Slot > 0 then H := Y + 24 + XvtrRows * CAL_ROW_H + 12 - else H := Y + 12; + // ---- таблицы офсетов (S-метр и спектр) — одинаковая геометрия ---- + HSM := LayoutCalOffsets(FLblSMBand, FEdSMBand, FLblSMXvtrHdr, + FLblSMXvtr, FEdSMXvtr, 52); + HDisp := LayoutCalOffsets(FLblDispBand, FEdDispBand, FLblDispXvtrHdr, + FLblDispXvtr, FEdDispXvtr, 52); // ---- вертикальная укладка групп ---- GTop := 86; @@ -2162,7 +2186,89 @@ begin FCalGrpVA.SetBounds(CAL_MARGIN, GTop, CAL_GRP_W, CAL_GRP2_H); GTop := GTop + CAL_GRP2_H + 14; end; - FCalGrpSM.SetBounds(CAL_MARGIN, GTop, CAL_GRP_W, H); + FCalGrpSM.SetBounds(CAL_MARGIN, GTop, CAL_GRP_W, HSM); + GTop := GTop + HSM + 14; + FCalGrpDisp.SetBounds(CAL_MARGIN, GTop, CAL_GRP_W, HDisp); +end; + +function TSettingsForm.LayoutCalOffsets(const BandLbl: array of TLabel; + const BandEd: array of TFlatFloatSpinEdit; XvtrHdr: TLabel; + const XvtrLbl: array of TLabel; const XvtrEd: array of TFlatFloatSpinEdit; + Y0: Integer): Integer; +// Строки диапазонов стоят по индексу плана (пустой слот просто скрыт), +// слоты трансвертеров — только включённые и без дырок. +var + i, r, c, X, Y, Slot, BandRows, XvtrRows: Integer; + Nm: string; +begin + BandRows := 0; + for i := 0 to CFG_BAND_COUNT - 1 do + begin + Nm := BandName(i, FCalIsPluto); + r := i div CAL_COLS; + c := i mod CAL_COLS; + X := CAL_PAD + c * (CAL_SM_LBL_W + CAL_ED_W + 14); + Y := Y0 + r * CAL_ROW_H; + if BandLbl[i] <> nil then + begin + BandLbl[i].Caption := Nm + ':'; + BandLbl[i].SetBounds(X, Y + 5, CAL_SM_LBL_W, 18); + BandLbl[i].Visible := Nm <> ''; + end; + if BandEd[i] <> nil then + begin + BandEd[i].SetBounds(X + CAL_SM_LBL_W, Y, CAL_ED_W, BTN_H + 2); + BandEd[i].Visible := Nm <> ''; + end; + if Nm <> '' then BandRows := r + 1; + end; + + Y := Y0 + BandRows * CAL_ROW_H + 6; + XvtrHdr.SetBounds(CAL_PAD, Y, 300, 18); + Slot := 0; + for i := 0 to CFG_XVTR_COUNT - 1 do + begin + if FCalXvtrOn[i] then + begin + r := Slot div CAL_COLS; + c := Slot mod CAL_COLS; + X := CAL_PAD + c * (CAL_SM_LBL_W + CAL_ED_W + 14); + if FCalXvtrName[i] <> '' then Nm := FCalXvtrName[i] + else Nm := 'XVTR' + IntToStr(i); + XvtrLbl[i].Caption := Nm + ':'; + XvtrLbl[i].Hint := 'XVTR' + IntToStr(i) + ' (' + Nm + ')'; + XvtrLbl[i].SetBounds(X, Y + 24 + r * CAL_ROW_H + 5, CAL_SM_LBL_W, 18); + XvtrEd[i].SetBounds(X + CAL_SM_LBL_W, Y + 24 + r * CAL_ROW_H, + CAL_ED_W, BTN_H + 2); + Inc(Slot); + end; + XvtrLbl[i].Visible := FCalXvtrOn[i]; + XvtrEd[i].Visible := FCalXvtrOn[i]; + end; + XvtrRows := (Slot + CAL_COLS - 1) div CAL_COLS; + XvtrHdr.Visible := Slot > 0; + + if Slot > 0 then Result := Y + 24 + XvtrRows * CAL_ROW_H + 12 + else Result := Y + 12; +end; + +procedure TSettingsForm.OnCalDispFromSMClick(Sender: TObject); +// Стартовая точка калибровки дисплея: те же цифры, что у стрелки, +// дальше доводятся руками по шумовой полке/эталонному генератору. +var i: Integer; +begin + FLoading := True; + try + for i := 0 to CFG_BAND_COUNT - 1 do + if (FEdDispBand[i] <> nil) and (FEdSMBand[i] <> nil) then + FEdDispBand[i].Value := FEdSMBand[i].Value; + for i := 0 to CFG_XVTR_COUNT - 1 do + if (FEdDispXvtr[i] <> nil) and (FEdSMXvtr[i] <> nil) then + FEdDispXvtr[i].Value := FEdSMXvtr[i].Value; + finally + FLoading := False; + end; + OnCalAnyChange(FBtnDispFromSM); end; procedure TSettingsForm.SetCalibrationBackend(IsPluto: Boolean); @@ -2177,11 +2283,15 @@ begin FCalConfig.PwrDetModel := FCmbPwrDetModel.ItemIndex; for i := 0 to CFG_BAND_COUNT - 1 do begin - if FEdPADetCal[i] <> nil then FCalConfig.PADetCal[i] := FEdPADetCal[i].Value; - if FEdSMBand[i] <> nil then FCalConfig.SMeterBandDB[i] := FEdSMBand[i].Value; + if FEdPADetCal[i] <> nil then FCalConfig.PADetCal[i] := FEdPADetCal[i].Value; + if FEdSMBand[i] <> nil then FCalConfig.SMeterBandDB[i] := FEdSMBand[i].Value; + if FEdDispBand[i] <> nil then FCalConfig.DispBandDB[i] := FEdDispBand[i].Value; end; for i := 0 to CFG_XVTR_COUNT - 1 do - if FEdSMXvtr[i] <> nil then FCalConfig.SMeterXvtrDB[i] := FEdSMXvtr[i].Value; + begin + if FEdSMXvtr[i] <> nil then FCalConfig.SMeterXvtrDB[i] := FEdSMXvtr[i].Value; + if FEdDispXvtr[i] <> nil then FCalConfig.DispXvtrDB[i] := FEdDispXvtr[i].Value; + end; FCalConfig.SupplyVoltCal := FEdSupplyVoltCal.Value; FCalConfig.AmpVoffMV := FEdAmpVoff.Value; FCalConfig.AmpSensMVA := FEdAmpSens.Value; @@ -2226,9 +2336,13 @@ begin begin if FEdPADetCal[i] <> nil then FEdPADetCal[i].Value := C.PADetCal[i]; if FEdSMBand[i] <> nil then FEdSMBand[i].Value := C.SMeterBandDB[i]; + if FEdDispBand[i] <> nil then FEdDispBand[i].Value := C.DispBandDB[i]; end; for i := 0 to CFG_XVTR_COUNT - 1 do - if FEdSMXvtr[i] <> nil then FEdSMXvtr[i].Value := C.SMeterXvtrDB[i]; + begin + if FEdSMXvtr[i] <> nil then FEdSMXvtr[i].Value := C.SMeterXvtrDB[i]; + if FEdDispXvtr[i] <> nil then FEdDispXvtr[i].Value := C.DispXvtrDB[i]; + end; FEdSupplyVoltCal.Value := C.SupplyVoltCal; FEdAmpVoff.Value := C.AmpVoffMV; FEdAmpSens.Value := C.AmpSensMVA; diff --git a/WDSPEngine.pas b/WDSPEngine.pas index b966605..5e8de55 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -191,6 +191,11 @@ type // SliceId — логический id (из RadioController), не WDSP-канал. TOnSliceAudio = procedure(SliceId: Integer; const Left, Right: array of Single; Count: Integer) of object; + // Калибровочный офсет спектра/водопада (дБ) для дисплея: PanId 0 = главный, + // 1..MAX_PANS-1 = доп. панадаптер. Спрашивается раз в кадр из display-потока: + // величина зависит от диапазона/трансвертера, о которых знает только + // TRadioController (аналог SMeterCalFor для стрелки). + TOnDispCalQuery = function(PanId: Integer): Double of object; // Пиксели спектра/водопада доп. пана — из display-потока (как FOnSpectrum/ // FOnWaterfall главного). Внутри колбэка НЕ звать slice/pan API движка. TOnPanPixels = procedure(PanId: Integer; const Pixels: array of Single; @@ -331,6 +336,7 @@ type FOnDemodAudio:TOnAudioReady; // pre-volume/mute tap for digital decoders FOnSpectrum: TOnSpectrumReady; FOnWaterfall: TOnWaterfallReady; + FOnDispCal: TOnDispCalQuery; FOnTXIQ: TOnTXIQReady; // --- Мультислайсы --- @@ -756,6 +762,8 @@ type property OnSliceAudio: TOnSliceAudio read FOnSliceAudio write FOnSliceAudio; property OnSliceDemodAudio: TOnSliceAudio read FOnSliceDemodAudio write FOnSliceDemodAudio; + // Офсет дисплея (дБ) на кадр — см. TOnDispCalQuery. nil = без коррекции. + property OnDispCal: TOnDispCalQuery read FOnDispCal write FOnDispCal; property OnSpectrum: TOnSpectrumReady read FOnSpectrum write FOnSpectrum; property OnWaterfall: TOnWaterfallReady read FOnWaterfall write FOnWaterfall; // Пиксели доп. панов (display-поток). UI вешает при появлении панов (3.3). @@ -3810,18 +3818,24 @@ var Flag: Integer; i, N, p: Integer; DispID: Integer; + Cal: Single; GotSpec, GotWf: Boolean; begin if not FInitialized or not FAnalyzerOpen then Exit; N := EnsureRange(FDisplayPixelCount, 1, SPECTRUM_PIXELS); DispID := FActiveDisplayID; // RX_DISP_ID или TX_DISP_ID, зависит от FTXActive + // Калибровка дисплея (дБ) — сдвиг всей трассы, спрашиваем раз в кадр. + // На TX-дисплее коррекция входного тракта не при чём. + if Assigned(FOnDispCal) and (DispID = RX_DISP_ID) then Cal := FOnDispCal(0) + else Cal := 0; + Flag := 0; GetPixels(DispID, 0, @PixBuf[0], @Flag); if Flag <> 0 then begin for i := 0 to N - 1 do - FSpectrumPixels[i] := PixBuf[i]; + FSpectrumPixels[i] := PixBuf[i] + Cal; if Assigned(FOnSpectrum) then FOnSpectrum(FSpectrumPixels, N); end; @@ -3831,7 +3845,7 @@ begin if Flag <> 0 then begin for i := 0 to N - 1 do - FWaterfallPixels[i] := PixBuf[i]; + FWaterfallPixels[i] := PixBuf[i] + Cal; if Assigned(FOnWaterfall) then FOnWaterfall(FWaterfallPixels, N); end; @@ -3858,6 +3872,18 @@ begin finally FSliceLock.Leave; end; + // Пан может стоять на своём диапазоне — офсет спрашиваем по его PanId. + if GotSpec or GotWf then + begin + if Assigned(FOnDispCal) then Cal := FOnDispCal(p) else Cal := 0; + if Cal <> 0 then + begin + if GotSpec then + for i := 0 to N - 1 do PixBuf[i] := PixBuf[i] + Cal; + if GotWf then + for i := 0 to N - 1 do WfBuf[i] := WfBuf[i] + Cal; + end; + end; if GotSpec and Assigned(FOnPanSpectrum) then FOnPanSpectrum(p, PixBuf, N); if GotWf and Assigned(FOnPanWaterfall) then From 6de8c00689817dc304ccb9ea233acb15b8cfe41e Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 21 Jul 2026 11:41:21 +0300 Subject: [PATCH 5/7] =?UTF-8?q?feature(rx):=20=D0=BF=D0=BB=D0=B0=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B0=D1=82=D1=82=D0=B5=D0=BD=D1=8E=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=200..31=20=D0=B4=D0=91=20=D0=B2=20UI,=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BC=D1=8F=D1=82=D1=8C=20=D0=BD=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=B8=D0=B0=D0=BF=D0=B0=D0=B7=D0=BE=D0=BD,=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=B5=D0=BD=D1=81=D0=B0=D1=86=D0=B8=D1=8F=20=D1=83?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BD=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Аттенюатор был только в web и только тремя ступенями (0/-10/-20), а его дБ никуда не возвращались — при ATT 20 дБ S-метр и спектр уезжали на 20 дБ, и свежая калибровка уровней была верна лишь при ATT=0. - RadioController: SetAtten(Db) 0..31 (как умеет железо) вместо индекса *10. AttenCalDB входит в SMeterCalFor и DispCalFor — стрелка, спектр, водопад и паны компенсируются, шкала dBm остаётся абсолютной (как Thetis/piHPSDR). У AD936x аттенюатора нет, там поправка 0 (усиление тракта — hardwaregain). - Память на диапазон/слот трансвертера по образцу RxGainMode/RxGainDb: TBandSettings.AttenDB (band 'atten_db') и TXvtrEntry.LastAttenDB ('last_atten_db'); восстановление и отправка в железо — в ApplyBandDSP, сбор — в MakeBandSettings/SaveCurrentBand, вход в слот — через B.AttenDB. SetAtten сразу кладёт значение в кэш диапазона/слота (JSON пишется общим сохранением, а не на каждый шаг ползунка — как у drive). - MainForm: ряд «ATT [ползунок] NNdB» в RX-блоке левой панели, отступы как у VOL; на Pluto ряд скрыт и RX-блок ужимается (LayoutLeftPanel двигает панели ниже). Внешние изменения приходят по rfAtten. - Web: select → ползунок 0..31 с цифрой, attn_idx -> attn_db, cmd attn {db:}. Co-Authored-By: Claude Opus 4.8 --- MainForm.pas | 70 +++++++++++++++++++++++++++++++++++++++++++-- RadioController.pas | 42 ++++++++++++++++++++++----- Settings.pas | 11 +++++++ WebPageHtml.pas | 8 +++--- WebServer.pas | 18 ++++++------ 5 files changed, 127 insertions(+), 22 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index eb1225f..394da30 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -347,6 +347,11 @@ type FPSPopup: TObject; // TPureSignalPopup (cast при использовании) PanelRXBlock: TPanel; + // Шаговый аттенюатор ADC (openHPSDR): плавные 0..31 дБ. На Pluto ряд + // скрыт — там усиление тракта живёт в блоке RF AGC. + LblAtt: TLabel; + LblAttVal: TLabel; + TrkAtt: TFlatSlider; BtnChannels: TFlatButton; FChannelsDropDown: TFlatDropDown; FChannelsForm: TObject; // TChannelsForm (cast при использовании) @@ -706,6 +711,8 @@ type procedure BtnAGCModeClick(Sender: TObject); procedure TrkAGCChange(Sender: TObject); procedure TrkRxGainChange(Sender: TObject); + procedure TrkAttChange(Sender: TObject); + procedure UpdateAttLabel; // (BtnPlutoGainClick объявлен выше) procedure MeterTimerTick(Sender: TObject); procedure SpectrumTimerTick(Sender: TObject); @@ -762,6 +769,13 @@ uses SettingsForm; {$R *.lfm} +const + // Высота RX-блока левой панели: с рядом ATT (openHPSDR) и без него (Pluto — + // там усиление тракта в блоке RF AGC). Переключает LayoutLeftPanel, он же + // двигает панели ниже. + RX_BLOCK_H = 96; + RX_BLOCK_H_ATT = 120; + function TMainForm.LeftPanelButtonWidth(PanelWidth, ColCount, ColIndex: Integer): Integer; var @@ -1505,6 +1519,12 @@ begin TrkVolume.Position := FController.ActiveVolume; // RX или TX-монитор rfRxGain: UpdateRxGainUI; + rfAtten: + if TrkAtt <> nil then + begin + TrkAtt.Position := FController.FAtten; + UpdateAttLabel; + end; rfDrive: begin TrkDrive.Position := FController.FDrivePercent; // слайдер ← drive% @@ -2028,7 +2048,7 @@ begin // RX block: CTUN + VOL + NR/NB/SNB/ANF/MUTE PanelRXBlock := TPanel.Create(Self); PanelRXBlock.Parent := PanelLeft; - PanelRXBlock.SetBounds(0, Y, LEFT_W, 96); + PanelRXBlock.SetBounds(0, Y, LEFT_W, RX_BLOCK_H_ATT); PanelRXBlock.BevelOuter := bvNone; MakeLbl(PanelRXBlock, 'RX', 4, 2); @@ -2084,7 +2104,29 @@ begin LeftPanelButtonLeft(LEFT_W, 5, 4), 70, LeftPanelButtonWidth(LEFT_W, 5, 4), BTN_SM, BtnMuteClick); - Inc(Y, 100); + // Шаговый аттенюатор ADC — плавный 0..31 дБ (железо openHPSDR так и умеет, + // ступени 0/10/20 были только в web-UI). Показания S-метра/спектра на эти + // дБ компенсируются в контроллере, шкала dBm остаётся абсолютной. + LblAtt := TLabel.Create(Self); + LblAtt.Parent := PanelRXBlock; LblAtt.Left := 4; LblAtt.Top := 106; + LblAtt.Caption := 'ATT'; LblAtt.Font.Color := CLR_TEXTDIM; + LblAtt.Font.Name := 'Courier New'; LblAtt.Font.Size := 7; + + TrkAtt := TFlatSlider.Create(Self); + TrkAtt.Parent := PanelRXBlock; TrkAtt.Left := 34; TrkAtt.Top := 102; + TrkAtt.Width := LEFT_W - 76; TrkAtt.Height := 16; + TrkAtt.Min := 0; TrkAtt.Max := 31; // ADC step attenuator, дБ + TrkAtt.Position := FController.FAtten; + TrkAtt.OnChange := TrkAttChange; + + LblAttVal := TLabel.Create(Self); + LblAttVal.Parent := PanelRXBlock; + LblAttVal.Left := LEFT_W - 38; LblAttVal.Top := 106; + LblAttVal.Font.Color := CLR_TEXT; LblAttVal.Font.Name := 'Courier New'; + LblAttVal.Font.Size := 7; + UpdateAttLabel; + + Inc(Y, RX_BLOCK_H_ATT + 4); // AGC controls PanelAGC := TPanel.Create(Self); @@ -7933,6 +7975,19 @@ begin FController.SetRxGain(TrkRxGain.Position); // переводит в manual end; +procedure TMainForm.TrkAttChange(Sender: TObject); +begin + if FSyncingFromController then Exit; + FController.SetAtten(TrkAtt.Position); + UpdateAttLabel; +end; + +procedure TMainForm.UpdateAttLabel; +begin + if LblAttVal <> nil then + LblAttVal.Caption := Format('%ddB', [FController.FAtten]); +end; + procedure TMainForm.BtnPlutoGainClick(Sender: TObject); begin if FSyncingFromController then Exit; @@ -7966,6 +8021,17 @@ procedure TMainForm.LayoutLeftPanel(Pluto: Boolean); var baseTop: Integer; begin if (PanelPlutoAGC = nil) or (PanelAGC = nil) or (PanelBands = nil) then Exit; + // Шаговый аттенюатор — только у openHPSDR; у AD936x усиление тракта в блоке + // RF AGC ниже, поэтому ряд ATT убираем и RX-блок ужимаем. + if (TrkAtt <> nil) and (LblAtt <> nil) and (LblAttVal <> nil) then + begin + TrkAtt.Visible := not Pluto; + LblAtt.Visible := not Pluto; + LblAttVal.Visible := not Pluto; + if Pluto then PanelRXBlock.Height := RX_BLOCK_H + else PanelRXBlock.Height := RX_BLOCK_H_ATT; + PanelAGC.Top := PanelRXBlock.Top + PanelRXBlock.Height + 4; + end; baseTop := PanelAGC.Top + PanelAGC.Height + 4; PanelPlutoAGC.Top := baseTop; PanelPlutoAGC.Visible := Pluto; diff --git a/RadioController.pas b/RadioController.pas index a5147c2..04c3c28 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -601,7 +601,7 @@ type procedure ToggleMute; procedure SetDrive(V: Integer); procedure DriveBy(Delta: Integer); - procedure SetAtten(Idx: Integer); + procedure SetAtten(Db: Integer); // ADC step attenuator, дБ (0..31) // ---- Мультислайсы (софт-fan-out) ---- // Создаёт слайс на TargetHz: заводит WDSP-канал в движке + свой аудио-выход @@ -635,6 +635,8 @@ type // Офсет спектра/водопада (дБ) для дисплея: PanId 0 = главный, 1..N — пан. // Колбэк движка (display-поток), см. TOnDispCalQuery. function DispCalFor(PanId: Integer): Double; + // Поправка на шаговый аттенюатор ADC (дБ) — входит в оба офсета выше. + function AttenCalDB: Double; procedure SetSliceDevice(Id, DevIndex: Integer; const DevName: string); function SliceCount: Integer; function GetSlice(Id: Integer; out S: TCtrlSlice): Boolean; @@ -2094,7 +2096,17 @@ end; function TRadioController.SMeterCalFor(VisibleHz: Double): Double; begin - Result := CalLookup(VisibleHz, FCal.SMeterBandDB, FCal.SMeterXvtrDB); + Result := CalLookup(VisibleHz, FCal.SMeterBandDB, FCal.SMeterXvtrDB) + + AttenCalDB; +end; + +function TRadioController.AttenCalDB: Double; +// Шаговый аттенюатор ADC режет вход — чтобы шкала dBm оставалась абсолютной, +// его дБ возвращаются в показания (так же делают Thetis и piHPSDR). У AD936x +// такого аттенюатора нет: там усиление тракта — hardwaregain. +begin + if IsPluto then Result := 0.0 + else Result := FAtten; end; function TRadioController.DispCalFor(PanId: Integer): Double; @@ -2105,7 +2117,7 @@ begin if (PanId > 0) and (PanId < MAX_PANS) then Hz := FPanFreqHz[PanId] else Hz := FCenterFreq; if Hz <= 0 then Exit(0.0); - Result := CalLookup(Hz, FCal.DispBandDB, FCal.DispXvtrDB); + Result := CalLookup(Hz, FCal.DispBandDB, FCal.DispXvtrDB) + AttenCalDB; end; procedure TRadioController.SetSliceDevice(Id, DevIndex: Integer; @@ -2808,6 +2820,7 @@ begin // Аппаратный AGC AD936x — на диапазон (усиление тракта разное на 2м и QO-100). Result.RxGainMode := FRxGainMode; Result.RxGainDb := FRxGainDb; + Result.AttenDB := FAtten; // шаговый аттенюатор ADC — тоже на диапазон // Waterfall AGC/NF — глобальные (не диапазонные) end; @@ -2846,6 +2859,7 @@ begin FXvtrSettings.Entries[i].LastPanSlider := FPanSlider; FXvtrSettings.Entries[i].LastRxGainMode := FRxGainMode; FXvtrSettings.Entries[i].LastRxGainDb := FRxGainDb; + FXvtrSettings.Entries[i].LastAttenDB := FAtten; end; FSettings.SaveXvtr(FDevMAC, FXvtrSettings); Exit; @@ -2975,6 +2989,11 @@ begin ApplyRxGainToBackend; Changed(rfRxGain); // UI: кнопки FAST/SLOW/HYB/MAN + ползунок + // --- Шаговый аттенюатор ADC (openHPSDR) — на диапазон/слот трансвертера. + FAtten := EnsureRange(B.AttenDB, 0, 31); + if Assigned(FNetwork) then FNetwork.SetStepAtten(Byte(FAtten)); + Changed(rfAtten); + // --- FM squelch / CTCSS / step / repeater --- FFMSQOn := B.FMSQOn; FFMSQLevel := B.FMSQLevel; @@ -3646,12 +3665,20 @@ begin else FDSPEngine.SetDriveLevel(EnsureRange(Pct, 0, 100) / 100.0); end; -procedure TRadioController.SetAtten(Idx: Integer); +procedure TRadioController.SetAtten(Db: Integer); +// Шаговый аттенюатор ADC — плавный 0..31 дБ (как в железе openHPSDR), а не +// три фиксированные ступени. Уровни S-метра/спектра компенсируются на ту же +// величину (CalLookup), поэтому шкала dBm остаётся абсолютной. begin - if Idx < 0 then Idx := 0 else if Idx > 2 then Idx := 2; - FAtten := Idx; - if Assigned(FNetwork) then FNetwork.SetStepAtten(FAtten * 10); + FAtten := EnsureRange(Db, 0, 31); + if Assigned(FNetwork) then FNetwork.SetStepAtten(Byte(FAtten)); Changed(rfAtten); + // Персист — вместе с диапазоном/слотом (SaveCurrentBand на выходе из них), + // писать JSON на каждый шаг ползунка незачем, как и у drive. + if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) then + FXvtrSettings.Entries[FCurrentXvtr].LastAttenDB := FAtten + else if (FCurrentBand >= 0) and (FCurrentBand < CFG_BAND_COUNT) then + FBandCache[FCurrentBand].AttenDB := FAtten; end; procedure TRadioController.SetBand(Idx: Integer); @@ -3742,6 +3769,7 @@ begin B.FMRptOffsetHz := E.LastFMRptOffsetHz; B.RxGainMode := E.LastRxGainMode; // hw-AGC слота (AD936x) B.RxGainDb := E.LastRxGainDb; + B.AttenDB := E.LastAttenDB; // аттенюатор ADC слота B.VfoA := Vis; B.VfoB := FVfoB; ApplyBandDSP(B); diff --git a/Settings.pas b/Settings.pas index bb96fda..74cb5fe 100644 --- a/Settings.pas +++ b/Settings.pas @@ -103,6 +103,9 @@ type // сильно разное на 2м и на QO-100, поэтому режим и manual-гейн per-band. RxGainMode: Integer; // 0=manual,1=fast,2=slow,3=hybrid RxGainDb: Integer; // manual gain, дБ (0..73) + // Шаговый аттенюатор ADC (openHPSDR) — на диапазон: на 40м ночью нужен, + // на 10м нет. 0..31 дБ. + AttenDB: Integer; end; // Персист панадаптеров и слайсов (секция "pans" под MAC). Восстанавливается @@ -295,6 +298,7 @@ type // Аппаратный AGC AD936x на слот трансвертера (у QO-100 своё усиление тракта). LastRxGainMode: Integer; // 0=manual,1=fast,2=slow,3=hybrid LastRxGainDb: Integer; // manual gain, дБ + LastAttenDB: Integer; // шаговый аттенюатор ADC слота, дБ (0..31) end; TXvtrSettings = record @@ -558,6 +562,7 @@ begin B.SpanHz := 192000; B.RxGainMode := 2; // slow_attack (как глобальный дефолт Pluto) B.RxGainDb := 40; + B.AttenDB := 0; B.FMSQOn := False; B.FMSQLevel := 30; B.CTCSSOn := False; @@ -944,6 +949,7 @@ begin Bands[i].FMRptOffsetHz := JD(BObj,'fmrpt_offset', Bands[i].FMRptOffsetHz); Bands[i].RxGainMode := EnsureRange(JI(BObj,'rx_gain_mode', Bands[i].RxGainMode), 0, 3); Bands[i].RxGainDb := EnsureRange(JI(BObj,'rx_gain_db', Bands[i].RxGainDb), 0, 73); + Bands[i].AttenDB := EnsureRange(JI(BObj,'atten_db', Bands[i].AttenDB), 0, 31); end; end; @@ -1060,6 +1066,7 @@ begin JW(O,'fmrpt_dir',B.FMRptDir); JW(O,'fmrpt_offset',B.FMRptOffsetHz); JW(O,'zoom_factor',B.ZoomFactor); JW(O,'pan_slider',B.PanSlider); JW(O,'rx_gain_mode',B.RxGainMode); JW(O,'rx_gain_db',B.RxGainDb); + JW(O,'atten_db',B.AttenDB); end; function TSettingsManager.LoadBand(const MAC: array of Byte; BandIdx: Integer; @@ -1091,6 +1098,7 @@ begin B.FMRptOffsetHz := JD(O,'fmrpt_offset', B.FMRptOffsetHz); B.RxGainMode := EnsureRange(JI(O,'rx_gain_mode', B.RxGainMode), 0, 3); B.RxGainDb := EnsureRange(JI(O,'rx_gain_db', B.RxGainDb), 0, 73); + B.AttenDB := EnsureRange(JI(O,'atten_db', B.AttenDB), 0, 31); B.ZoomFactor := EnsureRange(JD(O,'zoom_factor', B.ZoomFactor), 0.0, 1.0); B.PanSlider := EnsureRange(JD(O,'pan_slider', B.PanSlider), 0.0, 1.0); end; @@ -1617,6 +1625,7 @@ begin X.Entries[i].LastPanSlider := 0.5; X.Entries[i].LastRxGainMode := 2; // slow_attack X.Entries[i].LastRxGainDb := 40; + X.Entries[i].LastAttenDB := 0; end; // Шаблоны (пользователь должен сам Enable, чтобы трансверторы появились) X.Entries[0].ButtonText := '2m'; @@ -1698,6 +1707,7 @@ begin X.Entries[i].LastPanSlider := EnsureRange(JD(EObj, 'last_pan_slider', X.Entries[i].LastPanSlider), 0.0, 1.0); X.Entries[i].LastRxGainMode := EnsureRange(JI(EObj, 'last_rx_gain_mode', X.Entries[i].LastRxGainMode), 0, 3); X.Entries[i].LastRxGainDb := EnsureRange(JI(EObj, 'last_rx_gain_db', X.Entries[i].LastRxGainDb), 0, 73); + X.Entries[i].LastAttenDB := EnsureRange(JI(EObj, 'last_atten_db', X.Entries[i].LastAttenDB), 0, 31); // Валидация: Last freqs должны быть в [FreqBegin..FreqEnd] if (X.Entries[i].LastFreq < X.Entries[i].FreqBegin) or (X.Entries[i].LastFreq > X.Entries[i].FreqEnd) then @@ -1798,6 +1808,7 @@ begin JW(EObj, 'last_pan_slider', X.Entries[i].LastPanSlider); JW(EObj, 'last_rx_gain_mode', X.Entries[i].LastRxGainMode); JW(EObj, 'last_rx_gain_db', X.Entries[i].LastRxGainDb); + JW(EObj, 'last_atten_db', X.Entries[i].LastAttenDB); end; end; diff --git a/WebPageHtml.pas b/WebPageHtml.pas index 360d598..77b49b7 100644 --- a/WebPageHtml.pas +++ b/WebPageHtml.pas @@ -199,7 +199,7 @@ begin '' + '' + '' + - '' + + '
ATT0
' + '' + '' + '' + @@ -311,7 +311,7 @@ begin 'q("modGrpSel").onchange=function(){const grp=GRP_MAP[modGrp()];const defaultMode=grp[0];cur.mode=defaultMode;syncModGrp(defaultMode);pend("mode");ws2({cmd:"mode",mode:defaultMode});};' + 'q("modTglBtn").onclick=function(){audioKick();const next=modTglTarget();cur.mode=next;syncModGrp(next);pend("mode");ws2({cmd:"mode",mode:next});};' + 'q("agcSel").onchange=function(e){pend("agc");ws2({cmd:"agc",mode:+e.target.value});};' + - 'q("attSel").onchange=function(e){pend("attn");ws2({cmd:"attn",idx:+e.target.value});};' + + 'q("attSl").oninput=function(e){var v=+e.target.value;var lbl=q("attVal");if(lbl)lbl.textContent=v;pend("attn");ws2({cmd:"attn",db:v});};' + // FNP — фильтровый оверлей 'var FNP_N={ssb:["5.0k","4.4k","3.8k","3.3k","2.9k","2.7k","2.4k","2.1k","1.8k","1.0k"],' + @@ -732,7 +732,7 @@ begin 'function isPending(field){return !!(pendingCmds[field]&&pendingCmds[field]>Date.now());}' + // Маппинг: server JSON field → pend key - 'var PEND_MAP={vfo_a_hz:"freq",vfo_b_hz:"freq",nr:"nr",nr_mode:"nr",nb:"nb",nb_mode:"nb",snb:"snb",anf:"anf",running:"run",mode:"mode",agc_mode:"agc",transmitting:"mox",drive:"drive",attn_idx:"attn",tuning:"tun",filter_bw:"filter",mute:"mute",beacon_on:"beacon",rx_mute_on:"rxmute",rx_gain_mode:"rxgain",rx_gain_db:"rxgain"};' + + 'var PEND_MAP={vfo_a_hz:"freq",vfo_b_hz:"freq",nr:"nr",nr_mode:"nr",nb:"nb",nb_mode:"nb",snb:"snb",anf:"anf",running:"run",mode:"mode",agc_mode:"agc",transmitting:"mox",drive:"drive",attn_db:"attn",tuning:"tun",filter_bw:"filter",mute:"mute",beacon_on:"beacon",rx_mute_on:"rxmute",rx_gain_mode:"rxgain",rx_gain_db:"rxgain"};' + 'function applyState(msg){' + 'Object.keys(msg).forEach(function(k){var pk=PEND_MAP[k];if(!pk||!isPending(pk))cur[k]=msg[k];});' + @@ -786,7 +786,7 @@ begin 'if(!isPending("mute"))updMuteIc();' + 'if(!isPending("drive")){const ts=q("txSl");if(ts&&!ts.matches(":active")){ts.value=cur.drive||0;var tv=q("txVal");if(tv)tv.textContent=cur.drive||0;}}' + 'if(!isPending("mode")){syncModGrp(cur.mode||0);syncStepSel();}' + - 'if(!isPending("attn")){const at=q("attSel");if(at)at.value=cur.attn_idx||0;}' + + 'if(!isPending("attn")){const at=q("attSl");if(at&&!at.matches(":active")){at.value=cur.attn_db||0;var av=q("attVal");if(av)av.textContent=cur.attn_db||0;}}' + 'updBand(activeVfo==="A"?(cur.vfo_a_hz||0):(vfoB||0));updSpan(cur.span_hz||192000);' + 'paintSM(Number(cur.smeter_dbm||-130));' + 'updStatusBar();' + diff --git a/WebServer.pas b/WebServer.pas index 7c4f96a..ba8e098 100644 --- a/WebServer.pas +++ b/WebServer.pas @@ -296,7 +296,7 @@ type FDriveLevel: Integer; FOnFreqA: TWebCmdFreqA; FOnAttn: TWebCmdAttn; - FAttnIdx: Integer; + FAttnDB: Integer; // шаговый аттенюатор ADC, дБ (0..31) FOnTun: TWebCmdTun; FOnFMStep: TWebCmdFMStep; FTuning: Boolean; @@ -406,7 +406,7 @@ type CenterHz: Double; FilterIdx: Integer; VfoB: Double; ActiveVfo: Integer; Transmitting: Boolean; DriveLevel: Integer; - AttnIdx: Integer; Tuning: Boolean; Duplex: Boolean; + AttnDB: Integer; Tuning: Boolean; Duplex: Boolean; FwdW, SWRV, PAMaxPower: Double; const StatusText, BoardText, IPText, SupplyText, PLLText, RXText, TXText, SeqText: string); @@ -1572,10 +1572,10 @@ begin end else if Cmd = 'attn' then begin - Idx := JsonGetInt(Json, 'idx', FAttnIdx); + Idx := JsonGetInt(Json, 'db', FAttnDB); if Idx < 0 then Idx := 0; - if Idx > 2 then Idx := 2; - FStateLock.Enter; FAttnIdx := Idx; FStateLock.Leave; + if Idx > 31 then Idx := 31; + FStateLock.Enter; FAttnDB := Idx; FStateLock.Leave; if Assigned(FOnAttn) then FOnAttn(Idx); end else if Cmd = 'fm_step' then @@ -1778,7 +1778,7 @@ begin '"wf_agc":%s,"wf_nf":%s,"band_idx":%d,"smeter_dbm":%.1f,' + '"running":%s,"mute":%s,"ctun":%s,' + '"nr_mode":%d,"nr":%s,"nb_mode":%d,"nb":%s,"snb":%s,"anf":%s,"connected":%s,' + - '"transmitting":%s,"drive":%d,"attn_idx":%d,"tuning":%s,"dup":%s,' + + '"transmitting":%s,"drive":%d,"attn_db":%d,"tuning":%s,"dup":%s,' + '"fwd_w":%.1f,"swr":%.2f,"pa_max_power":%.0f,' + '"status_text":"%s","board_text":"%s","ip_text":"%s","supply_text":"%s",' + '"pll_text":"%s","rx_text":"%s","tx_text":"%s","seq_text":"%s",' + @@ -1809,7 +1809,7 @@ begin BoolToStr(FConnected, 'true', 'false'), BoolToStr(FTransmitting, 'true', 'false'), FDriveLevel, - FAttnIdx, + FAttnDB, BoolToStr(FTuning, 'true', 'false'), BoolToStr(FDuplex, 'true', 'false'), FFwdW, FSWR, FPAMaxPower, @@ -1889,7 +1889,7 @@ procedure TWebServer.PushSpectrum( CenterHz: Double; FilterIdx: Integer; VfoB: Double; ActiveVfo: Integer; Transmitting: Boolean; DriveLevel: Integer; - AttnIdx: Integer; Tuning: Boolean; Duplex: Boolean; + AttnDB: Integer; Tuning: Boolean; Duplex: Boolean; FwdW, SWRV, PAMaxPower: Double; const StatusText, BoardText, IPText, SupplyText, PLLText, RXText, TXText, SeqText: string); @@ -1926,7 +1926,7 @@ begin FActiveVfo := ActiveVfo; FTransmitting := Transmitting; FDriveLevel := DriveLevel; - FAttnIdx := AttnIdx; + FAttnDB := AttnDB; FTuning := Tuning; FDuplex := Duplex; FFwdW := FwdW; From a95292d2c0b43e40e9b539448f8431b4b74afeb5 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 21 Jul 2026 11:50:28 +0300 Subject: [PATCH 6/7] =?UTF-8?q?feature(pluto):=20=D0=BA=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D0=B5=D0=BD=D1=81=D0=B0=D1=86=D0=B8=D1=8F=20hardwaregain=20?= =?UTF-8?q?=D0=B2=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D1=8F=D1=85=20+=20?= =?UTF-8?q?=D0=B6=D0=B8=D0=B2=D0=BE=D0=B5=20=D1=83=D1=81=D0=B8=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B2=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B5=20?= =?UTF-8?q?RF=20AGC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На Pluto шкала dBm ничего не значила: усиление тракта 0..73 дБ входит в показания напрямую, а в режимах AGC железо крутит его само, и картинка «дышит» вместе с ним. - RadioBackend: виртуальный RxGainNowDb (TELEMETRY_NONE = регулируемого усиления нет, как у openHPSDR с его аттенюатором). - PlutoBackend: в MANUAL отдаёт FGainDb (точно, без опроса железа), в режимах AGC — hardwaregain из телеметрического потока (читается там же, где rssi, ~2 Гц, не блокируя UI). - RadioController: AttenCalDB -> FrontEndCalDB, общее понятие «регулируемое усиление входа»: openHPSDR +FAtten (аттенюатор режет), AD936x -hardwaregain (усиление поднимает). Постоянная часть тракта (LNB, кабель, конвертер) остаётся за таблицами калибровки на диапазон/трансвертер. - UI: в блоке RF AGC при режиме != MANUAL вместо надписи «AGC» — фактическое усиление («~38dB», тильда = измеренное), ползунок ездит сам. Видно, что цифра гуляет, а уровни на спектре стоят. Ограничения: FAST AGC крутит усиление быстрее опроса (2 Гц), для стабильной шкалы нужен MANUAL или SLOW; шумовая полка при смене усиления немного едет — это физика (input-referred NF), компенсацию проверять по сигналу. Co-Authored-By: Claude Opus 4.8 --- MainForm.pas | 14 ++++++++++++-- PlutoBackend.pas | 38 ++++++++++++++++++++++++++++++++++---- RadioBackend.pas | 10 ++++++++++ RadioController.pas | 38 ++++++++++++++++++++++++++++---------- 4 files changed, 84 insertions(+), 16 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index 394da30..1efe48d 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -8004,10 +8004,20 @@ begin for i := 0 to 3 do if BtnPlutoGain[i] <> nil then StyleButton(BtnPlutoGain[i], BtnPlutoGain[i].Tag = FController.FRxGainMode); - TrkRxGain.Position := FController.FRxGainDb; TrkRxGain.Enabled := (FController.FRxGainMode = 0); // активен только в manual if FController.FRxGainMode = 0 then - LblRxGain.Caption := Format('%ddB', [FController.FRxGainDb]) + begin + TrkRxGain.Position := FController.FRxGainDb; + LblRxGain.Caption := Format('%ddB', [FController.FRxGainDb]); + end + else if FController.FLastPlutoGainDb > TELEMETRY_NONE then + begin + // AGC: показываем ФАКТИЧЕСКОЕ усиление железа (опрос ~2 Гц) — ползунок + // ездит сам, тильда отличает измеренное от заданного. На это же значение + // компенсируются уровни (FrontEndCalDB), поэтому шкала dBm стоит. + TrkRxGain.Position := EnsureRange(Round(FController.FLastPlutoGainDb), 0, 73); + LblRxGain.Caption := Format('~%ddB', [Round(FController.FLastPlutoGainDb)]); + end else LblRxGain.Caption := 'AGC'; finally diff --git a/PlutoBackend.pas b/PlutoBackend.pas index 8e65373..040b949 100644 --- a/PlutoBackend.pas +++ b/PlutoBackend.pas @@ -109,11 +109,13 @@ type 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: Double): Boolean; + function PollTelemetryBlocking(out TempC, SocTempC, RSSIdB, + GainDb: Double): Boolean; // Низкоуровневые помощники применения атрибутов к ad9361-phy procedure ApplyRxLO(Hz: Double); @@ -174,6 +176,7 @@ type 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; end; // Стабильный 6-байтовый ключ (synthetic MAC) из serial/URI — для per-device @@ -413,6 +416,7 @@ begin 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-буфера запаса под джиттер продюсера/консьюмера. Окончательный @@ -1133,7 +1137,8 @@ begin LeaveCriticalSection(FTxLock); end; -function TPlutoBackend.PollTelemetryBlocking(out TempC, SocTempC, RSSIdB: Double): Boolean; +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) @@ -1149,6 +1154,7 @@ 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; @@ -1190,6 +1196,29 @@ 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; @@ -1244,7 +1273,7 @@ const POLL_MS = 500; // ~2 Гц: чаще незачем, каждый опрос ~30 мс USB-транзакций STEP_MS = 25; // мелкий шаг сна — чтобы Terminate отрабатывал быстро var - t, s, r: Double; + t, s, r, g: Double; ok: Boolean; Waited: Integer; begin @@ -1252,7 +1281,7 @@ begin begin if FBk.FConnected then begin - ok := FBk.PollTelemetryBlocking(t, s, r); + ok := FBk.PollTelemetryBlocking(t, s, r, g); if ok then begin EnterCriticalSection(FBk.FTeleLock); @@ -1260,6 +1289,7 @@ begin FBk.FTeleTemp := t; FBk.FTeleSoc := s; FBk.FTeleRssi := r; + FBk.FTeleGain := g; FBk.FTeleValid := True; finally LeaveCriticalSection(FBk.FTeleLock); diff --git a/RadioBackend.pas b/RadioBackend.pas index ad73241..744a03b 100644 --- a/RadioBackend.pas +++ b/RadioBackend.pas @@ -199,6 +199,11 @@ type // RSSI приёмника (дБ). HPSDR — нет такой (использует HP-status), возвращает False. // Недоступные значения = TELEMETRY_NONE. function ReadTelemetry(out TempC, SocTempC, RSSIdB: Double): Boolean; virtual; + // Текущее усиление приёмного тракта (дБ), если оно есть в железе и может + // меняться само: AD936x hardwaregain (в режимах AGC железо крутит его сам). + // Нужно для абсолютной шкалы уровней — контроллер вычитает его из показаний. + // HPSDR: усиления нет (есть аттенюатор), возвращает TELEMETRY_NONE. + function RxGainNowDb: Double; virtual; // ---- Свойства состояния (полиморфные через виртуальные геттеры) ---- property Connected: Boolean read GetConnected; @@ -300,6 +305,11 @@ begin Result := False; end; +function TRadioBackend.RxGainNowDb: Double; +begin + Result := TELEMETRY_NONE; +end; + procedure TRadioBackend.SetTxAtten(Db: Double); begin end; diff --git a/RadioController.pas b/RadioController.pas index 04c3c28..31da849 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -391,6 +391,9 @@ type // RSSI приёмника дБ. Опрашиваются ServicePlutoTelemetry с UI-таймера; // TELEMETRY_NONE = нет данных. FLastPlutoTempC, FLastPlutoSocTempC, FLastPlutoRSSIdB: Double; + // Фактическое усиление RX-тракта AD936x (дБ) — в режимах AGC его крутит + // железо. Снимается тем же опросом; UI показывает его в блоке RF AGC. + FLastPlutoGainDb: Double; FPlutoTeleTick: QWord; // GetTickCount64 последнего опроса (троттлинг) FPlutoTempBad: Integer; // подряд отброшенных выбросов температуры (анти-залипание) @@ -635,8 +638,9 @@ type // Офсет спектра/водопада (дБ) для дисплея: PanId 0 = главный, 1..N — пан. // Колбэк движка (display-поток), см. TOnDispCalQuery. function DispCalFor(PanId: Integer): Double; - // Поправка на шаговый аттенюатор ADC (дБ) — входит в оба офсета выше. - function AttenCalDB: Double; + // Поправка на регулируемое усиление входа (аттенюатор ADC у openHPSDR, + // hardwaregain у AD936x) — входит в оба офсета выше. + function FrontEndCalDB: Double; procedure SetSliceDevice(Id, DevIndex: Integer; const DevName: string); function SliceCount: Integer; function GetSlice(Id: Integer; out S: TCtrlSlice): Boolean; @@ -966,6 +970,7 @@ begin FLastSMeter := -130; FLastSWR := 1; FLastSupplyV := -1; FLastSupplyA := -1; FLastPlutoTempC := TELEMETRY_NONE; FLastPlutoSocTempC := TELEMETRY_NONE; FLastPlutoRSSIdB := TELEMETRY_NONE; + FLastPlutoGainDb := TELEMETRY_NONE; FPlutoTeleTick := 0; FPlutoTempBad := 0; FillChar(FDevMAC, SizeOf(FDevMAC), 0); end; @@ -2097,16 +2102,25 @@ end; function TRadioController.SMeterCalFor(VisibleHz: Double): Double; begin Result := CalLookup(VisibleHz, FCal.SMeterBandDB, FCal.SMeterXvtrDB) - + AttenCalDB; + + FrontEndCalDB; end; -function TRadioController.AttenCalDB: Double; -// Шаговый аттенюатор ADC режет вход — чтобы шкала dBm оставалась абсолютной, -// его дБ возвращаются в показания (так же делают Thetis и piHPSDR). У AD936x -// такого аттенюатора нет: там усиление тракта — hardwaregain. +function TRadioController.FrontEndCalDB: Double; +// Поправка на регулируемое усиление входного тракта — то, что меняется ручкой +// или само и не должно двигать абсолютную шкалу dBm. +// openHPSDR: шаговый аттенюатор ADC режет вход → его дБ возвращаем в +// показания (так же делают Thetis и piHPSDR). +// AD936x: hardwaregain поднимает уровень (а в режимах AGC железо крутит +// его само) → вычитаем текущее значение. +// Постоянная часть тракта (LNB, кабель, конвертер) остаётся за таблицами +// калибровки на диапазон/трансвертер. +var G: Double; begin - if IsPluto then Result := 0.0 - else Result := FAtten; + if not IsPluto then Exit(FAtten); + Result := 0.0; + if FNetwork = nil then Exit; + G := FNetwork.RxGainNowDb; + if G > TELEMETRY_NONE then Result := -G; end; function TRadioController.DispCalFor(PanId: Integer): Double; @@ -2117,7 +2131,7 @@ begin if (PanId > 0) and (PanId < MAX_PANS) then Hz := FPanFreqHz[PanId] else Hz := FCenterFreq; if Hz <= 0 then Exit(0.0); - Result := CalLookup(Hz, FCal.DispBandDB, FCal.DispXvtrDB) + AttenCalDB; + Result := CalLookup(Hz, FCal.DispBandDB, FCal.DispXvtrDB) + FrontEndCalDB; end; procedure TRadioController.SetSliceDevice(Id, DevIndex: Integer; @@ -5046,6 +5060,7 @@ begin FActiveDDC := 0; FLastPlutoTempC := TELEMETRY_NONE; FLastPlutoSocTempC := TELEMETRY_NONE; FLastPlutoRSSIdB := TELEMETRY_NONE; + FLastPlutoGainDb := TELEMETRY_NONE; FPlutoTeleTick := 0; FPlutoTempBad := 0; Changed(rfRunning); // безусловный сброс run-кнопки/MOX.Enabled/sleep (как старый хвост STOP) end; @@ -5504,6 +5519,9 @@ begin // RSSI быстро меняется с сигналом — без сглаживания; обновляем, только если валиден. if r > TELEMETRY_NONE then FLastPlutoRSSIdB := r; + // Фактическое усиление тракта (в MANUAL = заданное, в AGC — снятое с железа). + FLastPlutoGainDb := FNetwork.RxGainNowDb; + Changed(rfRxGain); // UI: живая цифра усиления в блоке RF AGC Changed(rfSupply); // поле телеметрии статус-бара (поле 3); web-адаптер тоже end; From d51f0fb614ad435eb9ddd226a6c9042f668c531e Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 21 Jul 2026 12:35:46 +0300 Subject: [PATCH 7/7] =?UTF-8?q?feature(calibration):=20=D1=87=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BA=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B1=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BE=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=B0=20(ppm)=20+=20TO=20Error=20=D0=B2=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D1=82=D0=B5=20QO-100?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пункт 3 плана. Ручные подгонки остаются: ppm работает ПОД ними, а не вместо — уход тракта где-то больше, где-то меньше. - Settings: TCalibration.FreqCalPPM (JSON cal_freq_ppm, +-100 ppm, per-device). Смысл знака: «опора выше номинала на N ppm», положительное значение поднимает показываемые частоты. - RadioBackend: виртуальный SetFreqCalPPM. - HPSDRNetwork: CalFreq(Hz) = Hz / (1 + ppm) в частотном слове — все четыре точки (главный DDC, DDC панов, feedback-DDC PureSignal, DUC передачи). Тактовый генератор радио менять нечем, поэтому правим запрос. - PlutoBackend: штатный xo_correction в ad9361-phy (device-attr биндинги добавлены в IIOBindings). Номинал XO снимается при коннекте с округлением до 100 кГц, чтобы наша же коррекция с прошлой сессии не накапливалась; после записи LO переписываются, иначе правка ждала бы следующей перестройки. Одна цифра лечит RX и TX на всех диапазонах. - QO-100: TXvtrEntry.TxLOError (JSON tx_lo_error) + поле «TO Error (Hz)» на странице QO-100. Сам транспондер-offset держим стандартным (8089.5 МГц), уход тракта правим поправкой; ActiveTXFreqHz учитывает её, подсказка внизу страницы считает TX-частоту уже с ней. - UI-фиксы вкладки Calibration: длинные подсказки переведены на несколько строк (WordWrap — TLabel по умолчанию режется по краю группы), заголовки таблиц укорочены и не заезжают под кнопку «= S-meter»; RelayoutCalTab сбрасывает прокрутку страницы перед перекладкой (SetBounds в TScrollBox задаёт координаты относительно прокрученного клиента). Co-Authored-By: Claude Opus 4.8 --- HPSDRNetwork.pas | 28 ++++++++++++++--- IIOBindings.pas | 10 ++++++ PlutoBackend.pas | 40 ++++++++++++++++++++++++ RadioBackend.pas | 7 +++++ RadioController.pas | 6 ++++ Settings.pas | 15 +++++++++ SettingsForm.pas | 75 ++++++++++++++++++++++++++++++++++++++++----- 7 files changed, 169 insertions(+), 12 deletions(-) diff --git a/HPSDRNetwork.pas b/HPSDRNetwork.pas index cc742c7..8de2900 100644 --- a/HPSDRNetwork.pas +++ b/HPSDRNetwork.pas @@ -156,6 +156,9 @@ type FMainDither: Boolean; FMainRandom: Boolean; + // Частотная калибровка опорника радио (ppm) — вносится в частотное слово + // (тактовый генератор радио менять нечем, поэтому правим запрос). + FFreqCalPPM: Double; // Текущее состояние для построения HP пакетов FCurrentRXFreq: Double; FCurrentTXFreq: Double; @@ -260,6 +263,9 @@ type procedure UpdateState(RXFreqHz, TXFreqHz: Double; DriveLevel: Byte; Transmitting, PAEnabled, AlexEnabled: Boolean); override; procedure SetStepAtten(dB: Byte); override; + procedure SetFreqCalPPM(P: Double); override; + // Частота с поправкой опорника — для частотного слова DDC/DUC. + function CalFreq(Hz: Double): Double; procedure SetAlexConfig(const A: TAlexSettings); override; // Включение/настройка XVTR-режима. Все параметры применяются мгновенно // (отправляется HP-пакет). Когда AEnable=False, XVTR-bit гасится, @@ -736,6 +742,7 @@ begin FWBUpdateRateMS := 70; FWBFrameCount := 0; FWBSeqValid := False; + FFreqCalPPM := 0.0; FCurrentRXFreq := 7100000; FCurrentTXFreq := 7100000; FCurrentDrive := 0; @@ -1693,6 +1700,19 @@ begin FAlexEnabled := AlexEnabled; end; +function THPSDRNetwork.CalFreq(Hz: Double): Double; +// Опора радио выше номинала на FFreqCalPPM ⇒ DDC/DUC уходят вверх на столько +// же. Компенсируем запрошенной частотой: просим ниже ровно на ppm. +begin + if FFreqCalPPM = 0 then Exit(Hz); + Result := Hz / (1.0 + FFreqCalPPM * 1.0E-6); +end; + +procedure THPSDRNetwork.SetFreqCalPPM(P: Double); +begin + FFreqCalPPM := P; +end; + procedure THPSDRNetwork.SetStepAtten(dB: Byte); begin if dB > 31 then dB := 31; @@ -1737,7 +1757,7 @@ begin DDCBase := DDCBaseIndex; // DDC RX frequency (bytes 9 + DDC*4) - Ph := FreqToPhaseWord(FCurrentRXFreq); + Ph := FreqToPhaseWord(CalFreq(FCurrentRXFreq)); Buf[9 + DDCBase*4] := (Ph shr 24) and $FF; Buf[10 + DDCBase*4] := (Ph shr 16) and $FF; Buf[11 + DDCBase*4] := (Ph shr 8) and $FF; @@ -1748,7 +1768,7 @@ begin for ddc := 0 to MAX_DDCS - 1 do if FPanDDCEnabled[ddc] then begin - Ph := FreqToPhaseWord(FPanDDCFreqHz[ddc]); + Ph := FreqToPhaseWord(CalFreq(FPanDDCFreqHz[ddc])); Buf[9 + ddc*4] := (Ph shr 24) and $FF; Buf[10 + ddc*4] := (Ph shr 16) and $FF; Buf[11 + ddc*4] := (Ph shr 8) and $FF; @@ -1759,7 +1779,7 @@ begin // DDCBase=2 ⇒ с частотным словом главного DDC (bytes 17..20) не пересекаемся. if FPSTXActive then begin - Ph := FreqToPhaseWord(FCurrentTXFreq); + Ph := FreqToPhaseWord(CalFreq(FCurrentTXFreq)); Buf[9] := (Ph shr 24) and $FF; Buf[10] := (Ph shr 16) and $FF; Buf[11] := (Ph shr 8) and $FF; @@ -1771,7 +1791,7 @@ begin end; // DUC TX frequency (bytes 329-332) - Ph := FreqToPhaseWord(FCurrentTXFreq); + Ph := FreqToPhaseWord(CalFreq(FCurrentTXFreq)); Buf[329] := (Ph shr 24) and $FF; Buf[330] := (Ph shr 16) and $FF; Buf[331] := (Ph shr 8) and $FF; diff --git a/IIOBindings.pas b/IIOBindings.pas index 78c5367..adb02c0 100644 --- a/IIOBindings.pas +++ b/IIOBindings.pas @@ -72,6 +72,14 @@ var iio_channel_attr_write_double: function(chn: Piio_channel; attr: PAnsiChar; val: cdouble): cint; cdecl; + // ---- Атрибуты устройства (не канальные) ---- + // ad9361-phy: 'xo_correction' — фактическая частота опорного XO в Гц, какой её + // считает драйвер. Правка = частотная калибровка всего чипа (RX и TX LO). + iio_device_attr_read_longlong: function(dev: Piio_device; attr: PAnsiChar; + out val: clonglong): cint; cdecl; + iio_device_attr_write_longlong: function(dev: Piio_device; attr: PAnsiChar; + val: clonglong): cint; cdecl; + // ---- Debug-атрибуты устройства (debugfs) ---- // Нужны для выбора антенного разъёма у AD936x в режиме 1R1T: // 'adi,1rx-1tx-mode-use-rx-num'/'-tx-num' (какой физический канал кормит тракт) @@ -185,6 +193,8 @@ begin 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_attr_read_longlong := Sym('iio_device_attr_read_longlong'); + iio_device_attr_write_longlong := Sym('iio_device_attr_write_longlong'); iio_device_debug_attr_read := Sym('iio_device_debug_attr_read'); iio_device_debug_attr_write := Sym('iio_device_debug_attr_write'); iio_device_debug_attr_write_longlong := Sym('iio_device_debug_attr_write_longlong'); diff --git a/PlutoBackend.pas b/PlutoBackend.pas index 040b949..681a24c 100644 --- a/PlutoBackend.pas +++ b/PlutoBackend.pas @@ -46,6 +46,8 @@ type 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 при ручном режиме @@ -118,6 +120,7 @@ type GainDb: Double): Boolean; // Низкоуровневые помощники применения атрибутов к ad9361-phy + procedure ApplyFreqCal; procedure ApplyRxLO(Hz: Double); procedure ApplyTxLO(Hz: Double); procedure SetTxLOPowerdown(PoweredOn: Boolean); // TX LO вкл/выкл (по PTT) @@ -177,6 +180,7 @@ type 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 @@ -410,6 +414,7 @@ begin 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; @@ -635,6 +640,7 @@ begin ApplyTxMux; ApplyGain(True); ApplyTxAtten; // TX заглушён (макс. аттенюация) до явного PTT + ApplyFreqCal; // частотная калибровка опорника (xo_correction) StartTelemetry; Result := True; end; @@ -656,6 +662,40 @@ 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 diff --git a/RadioBackend.pas b/RadioBackend.pas index 744a03b..57733cf 100644 --- a/RadioBackend.pas +++ b/RadioBackend.pas @@ -204,6 +204,9 @@ type // Нужно для абсолютной шкалы уровней — контроллер вычитает его из показаний. // HPSDR: усиления нет (есть аттенюатор), возвращает TELEMETRY_NONE. function RxGainNowDb: Double; virtual; + // Частотная калибровка опорника, ppm: «опора выше номинала на N ppm». + // HPSDR — масштаб частотного слова, AD936x — xo_correction в ad9361-phy. + procedure SetFreqCalPPM(P: Double); virtual; // ---- Свойства состояния (полиморфные через виртуальные геттеры) ---- property Connected: Boolean read GetConnected; @@ -310,6 +313,10 @@ begin Result := TELEMETRY_NONE; end; +procedure TRadioBackend.SetFreqCalPPM(P: Double); +begin +end; + procedure TRadioBackend.SetTxAtten(Db: Double); begin end; diff --git a/RadioController.pas b/RadioController.pas index 31da849..b91d18e 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -2295,7 +2295,9 @@ begin // QO-100 / split-LO: TX идёт на собственный uplink-LO (visible - TxLOOffset), // без LOError — дрейф LNB к передаче отношения не имеет. if FXvtrSettings.Entries[FCurrentXvtr].FullDuplexTx then + // TO Error: сам транспондер-offset держим стандартным, уход тракта — здесь. Result := VisibleHz - FXvtrSettings.Entries[FCurrentXvtr].TxLOOffset + + FXvtrSettings.Entries[FCurrentXvtr].TxLOError else // Обычный трансвертер: RX=TX LO (с калибровкой LOError) — как раньше. Result := VisibleHz @@ -3664,6 +3666,10 @@ begin // Санитизация: битый конфиг не должен дать деление на ноль/абсурдные ватты. FCal.PwrDetModel := EnsureRange(FCal.PwrDetModel, 0, 3); if FCal.AmpSensMVA <= 0 then FCal.AmpSensMVA := 120.0; + FCal.FreqCalPPM := EnsureRange(FCal.FreqCalPPM, -100.0, 100.0); + // Опорник: у HPSDR правим частотное слово, у AD936x — xo_correction. + // Ручные LOError/TO Error трансвертеров остаются сверху. + if Assigned(FNetwork) then FNetwork.SetFreqCalPPM(FCal.FreqCalPPM); // Офсет S-метра берётся из FCal на каждом чтении (SMeterCalFor) — применять // отдельно нечего: он зависит от диапазона/трансвертера, а не от движка. SaveGlobalSettings; // персист (гейт по FDevConnected внутри) diff --git a/Settings.pas b/Settings.pas index 74cb5fe..8b0ae1d 100644 --- a/Settings.pas +++ b/Settings.pas @@ -270,6 +270,10 @@ type // собственный у SDR). FullDuplexTx помечает слот как такой транспондер. FullDuplexTx: Boolean; // True — раздельный TX-LO (QO-100); авто display-duplex TxLOOffset: Double; // Hz, TX LO-offset (для QO-100 = транспондер-offset, ~8089.5 МГц) + // Hz, поправка к TX LO-offset (TO Error). Сам offset держим стандартным + // (8089.5 МГц у QO-100), а уход конкретного тракта правим здесь. + // Знак как у LOError: положительная — TX IF растёт. + TxLOError: Double; RXGain: Double; // dB, компенсация усиления RX тракта RXOnly: Boolean; // True — запрещает TX на этом трансверторе TXPower: Integer; // 0..100 % (drive level специально для XVTR) @@ -354,6 +358,12 @@ type // уровень отличается от показаний стрелки на пару дБ. DispBandDB: array[0..CFG_BAND_COUNT-1] of Double; DispXvtrDB: array[0..CFG_XVTR_COUNT-1] of Double; + // Частотная калибровка опорника, ppm (Frequency Calibration в Thetis). + // Смысл: «опора выше номинала на N ppm» ⇒ до правки сигналы показываются + // ниже истинной частоты; положительное значение поднимает показания. + // Действует на всё железо разом; ручные LOError/TO Error трансвертеров + // остаются сверху — тракт тракту рознь. + FreqCalPPM: Double; end; TGlobalSettings = record @@ -922,6 +932,7 @@ begin for i := 0 to CFG_XVTR_COUNT-1 do G.Cal.DispXvtrDB[i] := EnsureRange(JD(GObj,'cal_disp_xvtr_'+IntToStr(i), 0.0), -30.0, 30.0); + G.Cal.FreqCalPPM := EnsureRange(JD(GObj,'cal_freq_ppm', 0.0), -100.0, 100.0); for i := 0 to CFG_BAND_COUNT-1 do begin @@ -1047,6 +1058,7 @@ begin JW(O,'cal_disp_band_'+IntToStr(i),G.Cal.DispBandDB[i]); for i := 0 to CFG_XVTR_COUNT-1 do JW(O,'cal_disp_xvtr_'+IntToStr(i),G.Cal.DispXvtrDB[i]); + JW(O,'cal_freq_ppm',G.Cal.FreqCalPPM); end; procedure TSettingsManager.SaveBand(const MAC: array of Byte; BandIdx: Integer; @@ -1598,6 +1610,7 @@ begin X.Entries[i].LOError := 0.0; X.Entries[i].FullDuplexTx := False; X.Entries[i].TxLOOffset := 0.0; + X.Entries[i].TxLOError := 0.0; X.Entries[i].RXGain := 0.0; X.Entries[i].RXOnly := False; X.Entries[i].TXPower := 100; @@ -1680,6 +1693,7 @@ begin X.Entries[i].LOError := JD(EObj, 'lo_err', X.Entries[i].LOError); X.Entries[i].FullDuplexTx := JB(EObj, 'full_dup', X.Entries[i].FullDuplexTx); X.Entries[i].TxLOOffset := JD(EObj, 'tx_lo_off', X.Entries[i].TxLOOffset); + X.Entries[i].TxLOError := JD(EObj, 'tx_lo_error', X.Entries[i].TxLOError); X.Entries[i].RXGain := JD(EObj, 'rx_gain', X.Entries[i].RXGain); X.Entries[i].RXOnly := JB(EObj, 'rx_only', X.Entries[i].RXOnly); X.Entries[i].TXPower := EnsureRange(JI(EObj, 'tx_pwr', X.Entries[i].TXPower), 0, 100); @@ -1781,6 +1795,7 @@ begin JW(EObj, 'lo_err', X.Entries[i].LOError); JW(EObj, 'full_dup', X.Entries[i].FullDuplexTx); JW(EObj, 'tx_lo_off', X.Entries[i].TxLOOffset); + JW(EObj, 'tx_lo_error', X.Entries[i].TxLOError); JW(EObj, 'rx_gain', X.Entries[i].RXGain); JW(EObj, 'rx_only', X.Entries[i].RXOnly); JW(EObj, 'tx_pwr', X.Entries[i].TXPower); diff --git a/SettingsForm.pas b/SettingsForm.pas index 747aa29..0a7f1cc 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -183,6 +183,8 @@ type FLblDispBandHdr: TLabel; FLblDispXvtrHdr: TLabel; FBtnDispFromSM: TFlatButton; // «= S-meter» — копия таблицы + FCalGrpFreq: TPanel; // Reference frequency + FEdFreqCalPPM: TFlatFloatSpinEdit; // калибровка опорника, ppm FCalIsPluto: Boolean; // активное железо — AD936x FCalXvtrOn: array[0..CFG_XVTR_COUNT-1] of Boolean; // слот трансвертера включён FCalXvtrName: array[0..CFG_XVTR_COUNT-1] of string; // подпись слота @@ -316,6 +318,7 @@ type FQoLnbLO: TFlatFloatSpinEdit; // МГц — LNB LO (= LOOffset, RX) FQoLoErr: TFlatFloatSpinEdit; // Гц — LO Error (= LOError, живая калибровка LNB / beacon-lock) FQoTxLO: TFlatFloatSpinEdit; // МГц — транспондер-offset (= TxLOOffset) + FQoTxLOErr: TFlatFloatSpinEdit; // Гц — поправка offset'а (TO Error) FQoBegin: TFlatFloatSpinEdit; // МГц — downlink begin FQoEnd: TFlatFloatSpinEdit; // МГц — downlink end FQoRXGain: TFlatFloatSpinEdit; // dB @@ -1946,6 +1949,17 @@ procedure TSettingsForm.BuildCalibrationTab; const GRP3_H0 = 200; // стартовая высота S-метра, уточняется в RelayoutCalTab + // Длинные подсказки не влезают в ширину группы — переводим на несколько + // строк (TLabel по умолчанию AutoSize=True и режется по краю). + procedure WrapLbl(L: TLabel; AW, AH: Integer); + begin + if L = nil then Exit; + L.AutoSize := False; + L.WordWrap := True; + L.Width := AW; + L.Height := AH; + end; + function MakeCalSpin(AParent: TWinControl; ALeft, ATop: Integer; AMin, AMax, AInc: Double; ADec: Integer; ADefault: Double): TFlatFloatSpinEdit; @@ -2021,7 +2035,8 @@ begin Lbl := MakeLbl(Grp, 'FWD/REV multiplier per band: 1.00 = no correction, >1 if meter under-reads ' + '(e.g. 88 W shown at real 100 W -> 1.14). SWR is not affected.', - CAL_PAD, CAL_R1 + CAL_ROW_H + 10, 650); + CAL_PAD, CAL_R1 + CAL_ROW_H + 8, 650); + WrapLbl(Lbl, 650, 34); for i := 0 to CFG_BAND_COUNT - 1 do begin @@ -2054,6 +2069,7 @@ begin Lbl := MakeLbl(Grp, 'Defaults 360 mV / 120 mV/A (ACS712-style, Orion MkII)', CAL_PAD + CAL_VA_LBL_W + 10 + CAL_ED_W + 14, CAL_R1 + CAL_ROW_H + 6, 320); + WrapLbl(Lbl, 320, 34); // ── S-meter ───────────────────────────────────────────────────────────── GTop := GTop + CAL_GRP2_H + 14; @@ -2063,7 +2079,8 @@ begin FLblSMBandHdr := MakeLbl(Grp, 'Offset (dB) added to S-meter readings — per band (+ = meter reads higher).', - CAL_PAD, 30, 650); + CAL_PAD, 30, 540); + WrapLbl(FLblSMBandHdr, 540, 18); for i := 0 to CFG_BAND_COUNT - 1 do begin @@ -2093,8 +2110,9 @@ begin Grp := FCalGrpDisp; FLblDispBandHdr := MakeLbl(Grp, - 'Offset (dB) added to spectrum/waterfall levels — per band (moves the whole ' - + 'trace incl. noise floor).', CAL_PAD, 30, 650); + 'Offset (dB) added to spectrum/waterfall — per band (moves the whole trace).', + CAL_PAD, 30, 540); + WrapLbl(FLblDispBandHdr, 540, 18); FBtnDispFromSM := TFlatButton.Create(Self); FBtnDispFromSM.Parent := Grp; @@ -2130,6 +2148,27 @@ begin FEdDispXvtr[i].Tag := i; end; + // ── Reference frequency ───────────────────────────────────────────────── + // Одна поправка опорника на всё железо (Frequency Calibration в Thetis): + // у openHPSDR правится частотное слово DDC/DUC, у AD936x — xo_correction. + // Ручные LO Error / TO Error трансвертеров остаются сверху: тракт тракту + // рознь, и где-то уход больше, где-то меньше. + FCalGrpFreq := MakeGroupPanel(FPageCalib, 'Reference frequency', + CAL_MARGIN, GTop, CAL_GRP_W, CAL_GRP2_H); + Grp := FCalGrpFreq; + + Lbl := MakeLbl(Grp, 'Oscillator error (ppm)', CAL_PAD, CAL_R1 + 6, CAL_VA_LBL_W); + FEdFreqCalPPM := MakeCalSpin(Grp, CAL_PAD + CAL_VA_LBL_W + 10, CAL_R1, + -100.0, 100.0, 0.01, 3, 0.0); + + Lbl := MakeLbl(Grp, + 'Positive value raises displayed frequencies. Measure against a known ' + + 'reference (beacon, WWV, GPSDO): if a 10 MHz signal reads 10 Hz low, ' + + 'that is +1.0 ppm. Applies to RX and TX on every band; per-transverter ' + + 'LO Error / TO Error still apply on top.', + CAL_PAD, CAL_R1 + CAL_ROW_H + 6, 650); + WrapLbl(Lbl, 650, 52); + RelayoutCalTab; // стартовая раскладка (HF-план, трансвертеры скрыты) FLoading := False; @@ -2146,7 +2185,16 @@ var begin // Гард: вкладка ещё строится (или LoadXvtrSettings пришёл раньше неё). if (FCalGrpDet = nil) or (FCalGrpVA = nil) or (FCalGrpSM = nil) - or (FCalGrpDisp = nil) or (FLblDispXvtrHdr = nil) then Exit; + or (FCalGrpDisp = nil) or (FLblDispXvtrHdr = nil) + or (FCalGrpFreq = nil) then Exit; + + // TScrollBox двигает детей при прокрутке, и SetBounds ниже задаёт координаты + // ОТНОСИТЕЛЬНО прокрученного клиента. Если страницу успели прокрутить (у + // openHPSDR она длиннее на две группы), после перекладки прокрутка + // схлопывается — и группы остаются смещёнными вниз ровно на прежний скролл. + // Поэтому сначала возвращаем страницу в ноль. + FPageCalib.VertScrollBar.Position := 0; + FPageCalib.HorzScrollBar.Position := 0; // ---- подписи/видимость строк Det. Cal. (своя сетка, уже с подсказкой) ---- for i := 0 to CFG_BAND_COUNT - 1 do @@ -2189,6 +2237,8 @@ begin FCalGrpSM.SetBounds(CAL_MARGIN, GTop, CAL_GRP_W, HSM); GTop := GTop + HSM + 14; FCalGrpDisp.SetBounds(CAL_MARGIN, GTop, CAL_GRP_W, HDisp); + GTop := GTop + HDisp + 14; + FCalGrpFreq.SetBounds(CAL_MARGIN, GTop, CAL_GRP_W, CAL_GRP2_H); end; function TSettingsForm.LayoutCalOffsets(const BandLbl: array of TLabel; @@ -2292,6 +2342,7 @@ begin if FEdSMXvtr[i] <> nil then FCalConfig.SMeterXvtrDB[i] := FEdSMXvtr[i].Value; if FEdDispXvtr[i] <> nil then FCalConfig.DispXvtrDB[i] := FEdDispXvtr[i].Value; end; + FCalConfig.FreqCalPPM := FEdFreqCalPPM.Value; FCalConfig.SupplyVoltCal := FEdSupplyVoltCal.Value; FCalConfig.AmpVoffMV := FEdAmpVoff.Value; FCalConfig.AmpSensMVA := FEdAmpSens.Value; @@ -2343,6 +2394,7 @@ begin if FEdSMXvtr[i] <> nil then FEdSMXvtr[i].Value := C.SMeterXvtrDB[i]; if FEdDispXvtr[i] <> nil then FEdDispXvtr[i].Value := C.DispXvtrDB[i]; end; + FEdFreqCalPPM.Value := EnsureRange(C.FreqCalPPM, -100.0, 100.0); FEdSupplyVoltCal.Value := C.SupplyVoltCal; FEdAmpVoff.Value := C.AmpVoffMV; FEdAmpSens.Value := C.AmpSensMVA; @@ -4355,7 +4407,7 @@ begin L.Font.Name := UI_FONT; L.Font.Size := 9; L.Font.Color := CLR_TEXTDIM; L.WordWrap := True; - Grp := MakeGroupPanel(FPageQO, 'Transponder', MARGIN, 100, 560, GRP_TITLE_H + 8 * 30 + 16); + Grp := MakeGroupPanel(FPageQO, 'Transponder', MARGIN, 100, 560, GRP_TITLE_H + 9 * 30 + 16); Y := GRP_TITLE_H + 8; FQoEn := TFlatCheckBox.Create(Self); @@ -4377,6 +4429,11 @@ begin FQoTxLO := QoFloat(Y, 0.0, 50000.0, 0.001, 6); FQoTxLO.Value := 8089.5; + // TO Error (Гц): сам транспондер-offset держим стандартным (8089.5 МГц), + // а уход конкретного тракта (TCXO uplink-SDR, наводка LNB) правим здесь. + Inc(Y, 30); QoLabel('TO Error (Hz)', Y); + FQoTxLOErr := QoFloat(Y, -999999.0, 999999.0, 1.0, 0); + Inc(Y, 30); QoLabel('Downlink begin (MHz)', Y); FQoBegin := QoFloat(Y, 0.0, 50000.0, 0.001, 3); FQoBegin.Value := 10489.5; @@ -4398,7 +4455,7 @@ begin FQoInfo := TLabel.Create(Self); FQoInfo.Parent := FPageQO; - FQoInfo.SetBounds(MARGIN, 100 + GRP_TITLE_H + 8 * 30 + 28, 700, 52); + FQoInfo.SetBounds(MARGIN, 100 + GRP_TITLE_H + 9 * 30 + 28, 700, 52); FQoInfo.Font.Color := CLR_TEXTDIM; FQoInfo.Font.Name := 'Courier New'; FQoInfo.Font.Size := 9; FQoInfo.WordWrap := True; finally @@ -4412,7 +4469,7 @@ begin if FQoInfo = nil then Exit; dlMid := (FQoBegin.Value + FQoEnd.Value) / 2.0; // МГц rxIf := dlMid - FQoLnbLO.Value; // RX IF в SDR - txF := dlMid - FQoTxLO.Value; // TX uplink + txF := dlMid - FQoTxLO.Value + FQoTxLOErr.Value / 1.0e6; // TX uplink + TO Error FQoInfo.Caption := Format( 'At downlink %.3f MHz: RX IF = %.3f MHz TX = %.3f MHz', [dlMid, rxIf, txF]); @@ -4427,6 +4484,7 @@ begin FXvtrConfig.Entries[QO100_SLOT].LOOffset := FQoLnbLO.Value * 1.0e6; FXvtrConfig.Entries[QO100_SLOT].LOError := FQoLoErr.Value; FXvtrConfig.Entries[QO100_SLOT].TxLOOffset := FQoTxLO.Value * 1.0e6; + FXvtrConfig.Entries[QO100_SLOT].TxLOError := FQoTxLOErr.Value; FXvtrConfig.Entries[QO100_SLOT].FreqBegin := FQoBegin.Value * 1.0e6; FXvtrConfig.Entries[QO100_SLOT].FreqEnd := FQoEnd.Value * 1.0e6; FXvtrConfig.Entries[QO100_SLOT].RXGain := FQoRXGain.Value; @@ -4442,6 +4500,7 @@ begin FQoLnbLO.Value := E.LOOffset / 1.0e6; FQoLoErr.Value := E.LOError; FQoTxLO.Value := E.TxLOOffset / 1.0e6; + FQoTxLOErr.Value := E.TxLOError; FQoBegin.Value := E.FreqBegin / 1.0e6; FQoEnd.Value := E.FreqEnd / 1.0e6; FQoRXGain.Value := E.RXGain;