{
  Copyright (C)
  2026 - Uladzimir Karpenka, EW8BAK

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

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

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

unit Settings;
{
  Settings.pas — JSON-конфигурация HPSDR SDR приложения.
  Структура hpsdr_settings.json:
  { "AA:BB:CC:DD:EE:FF": { "global": {...}, "bands": {"5": {...}} } }
}
{$mode objfpc}{$H+}
interface
uses SysUtils, Classes, Math, fpJSON, jsonparser, jsonscanner, PlatformUtils,
  SerialPort, RadioModes;

const
  SETTINGS_FILE  = 'hpsdr_settings.json';
  CFG_BAND_COUNT = 12;
  CFG_XVTR_COUNT = 8;     // количество XVTR-слотов (трансверторов)
  OC_PIN_COUNT   = 7;     // количество Open Collector выходов (Penny/Alex)
  // TX pin action: определяет, при каком состоянии передачи пин активен.
  OCA_MOX             = 0;  // только обычный TX (не TUNE, не 2TON)
  OCA_TUNE             = 1;  // только TUNE
  OCA_TWOTONE          = 2;  // только 2TON
  OCA_MOX_TUNE         = 3;  // TX или TUNE
  OCA_MOX_TWOTONE      = 4;  // TX или 2TON
  OCA_TUNE_TWOTONE     = 5;  // TUNE или 2TON
  OCA_MOX_TUNE_TWOTONE = 6;  // любое из трёх (дефолт)
  CFG_MODE_MIN   = RadioModes.MODE_MIN;
  CFG_MODE_MAX   = RadioModes.MODE_MAX;
  CFG_PANS_MAX   = 4;     // потолок панадаптеров (пан 0 + доп.) — = WDSPEngine.MAX_PANS
  CFG_CAT_SLICE_COUNT = 6; // слотов доп. слайсов (B..G) — = WDSPEngine.MAX_SLICES
  CFG_CAT_SLICE_PORT0 = 19091; // порт слайса B по умолчанию (главный — 19090)
  CW_MSG_COUNT = 8;   // ячеек памяти телеграфных сообщений (F1..F8)
  TXPROF_MAX     = 16;    // потолок именованных TX-профилей на устройство
  QO100_SLOT     = CFG_XVTR_COUNT - 1;  // последний слот зарезервирован под QO-100
                                        // (split-LO транспондер; настраивается на
                                        // отдельной странице, не в общей XVTR-таблице)
  // PA gain defaults (dB) for HERMES/HPSDR hardware.
  // order: 160m,80m,60m,40m,30m,20m,17m,15m,12m,10m,6m
  // 12-й слот — пустой в HF-плане (используется только 12-местным VHF/UHF-планом
  // Pluto, где частота/режим приходят из плана BoardUtils, а не отсюда).
  CFG_PA_BAND_CAL_DEFAULT: array[0..CFG_BAND_COUNT-1] of Double = (
    41.0, 41.2, 41.3, 41.3, 41.0, 40.5, 39.9, 38.8, 38.8, 38.8, 38.8, 38.8);
  CFG_VHF_CAL_DEFAULT = 56.2; // dB, default for all XVTR slots
  CFG_BAND_DEFAULT_FREQ: array[0..CFG_BAND_COUNT-1] of Double = (
    1900000, 3750000, 5357000, 7100000, 10125000,
    14200000, 18120000, 21200000, 24940000, 28500000, 50150000, 50150000);
  CFG_BAND_DEFAULT_MODE: array[0..CFG_BAND_COUNT-1] of Integer = (
    0, 0, 0, 0, 1,  1, 1, 1, 1, 1, 1, 1);

  // Полосы фильтров по типу модуляции (Гц) + дефолтные индексы. Нужны
  // TRadioController для headless-выбора фильтра при смене режима; UI-подписи
  // (FILT_*_NAMES) остаются в MainForm. NAMES-массивы там используют FILT_COUNT.
  FILT_COUNT = 10;
  FILT_SSB_BW: array[0..FILT_COUNT-1] of Integer =
    (5000, 4400, 3800, 3300, 2900, 2700, 2400, 2100, 1800, 1000);
  FILT_SSB_DEF = 5;   // 2.7k
  FILT_CW_BW:  array[0..FILT_COUNT-1] of Integer =
    (1000, 800, 600, 500, 400, 250, 150, 100, 50, 25);
  FILT_CW_DEF = 3;    // 500
  FILT_DSB_BW: array[0..FILT_COUNT-1] of Integer =
    (16000, 12000, 10000, 8000, 6600, 5200, 4000, 3100, 2900, 2400);
  FILT_DSB_DEF = 3;   // 8.0k
  FILT_AM_BW:  array[0..FILT_COUNT-1] of Integer =
    (20000, 18000, 16000, 12000, 10000, 9000, 8000, 7000, 6000, 5000);
  FILT_AM_DEF = 4;    // 10k
  // DIGI: полоса считается от нуля (кромки 0..BW), поэтому таблица своя —
  // SSB-полосы отмерены от среза на 100 Гц и здесь не подходят.
  FILT_DIGI_BW: array[0..FILT_COUNT-1] of Integer =
    (3000, 2700, 2400, 2100, 1800, 1500, 1200, 900, 600, 300);
  FILT_DIGI_DEF = 0;  // 3.0k
  FILT_FM_COUNT = 2;
  FILT_FM_BW:  array[0..1] of Integer = (11000, 16000);
  FILT_FM_DEV: array[0..1] of Double  = (2500.0, 5000.0);
  FILT_FM_DEF = 0;    // NFM
  // WFM: вещательный канал. 200 кГц требует SampleRate 384k — при 192k
  // WDSPEngine.SetFilter сам зажмёт кромки по Найквисту dsp_rate.
  FILT_WFM_COUNT = 3;
  FILT_WFM_BW: array[0..2] of Integer = (200000, 160000, 120000);
  FILT_WFM_DEF = 1;   // 160k — влезает и в 192k

  // FM RAW: narrow, flat discriminator output for external digital decoders.
  // These are RF channel widths; baseband stays flat at 20..7000 Hz.
  FILT_RAW_COUNT = 5;
  FILT_RAW_BW: array[0..FILT_RAW_COUNT-1] of Integer =
    (6250, 10000, 12500, 20000, 25000);
  FILT_RAW_DEF = 2;   // 12.5 kHz
  RAW_FM_DEV_DEFAULT = 2500;
  RAW_FM_DEV_MIN     = 500;
  RAW_FM_DEV_MAX     = 10000;

  // DMR Tier II: 12.5 kHz RF channel, 4FSK nominal deviation +/-1.944 kHz.
  FILT_DMR_BW  = 12500;
  DMR_FM_DEV   = 2500;

  // UI-подписи пресетов. Раньше жили в MainForm — переехали сюда, потому что
  // из них строится заводская таблица фильтров (DefaultFilterSet).
  FILT_SSB_NAMES: array[0..FILT_COUNT-1] of string =
    ('5.0k','4.4k','3.8k','3.3k','2.9k','2.7k','2.4k','2.1k','1.8k','1.0k');
  FILT_CW_NAMES: array[0..FILT_COUNT-1] of string =
    ('1.0k','800','600','500','400','250','150','100','50','25');
  FILT_DSB_NAMES: array[0..FILT_COUNT-1] of string =
    ('16k','12k','10k','8.0k','6.6k','5.2k','4.0k','3.1k','2.9k','2.4k');
  FILT_AM_NAMES: array[0..FILT_COUNT-1] of string =
    ('20k','18k','16k','12k','10k','9.0k','8.0k','7.0k','6.0k','5.0k');
  FILT_DIGI_NAMES: array[0..FILT_COUNT-1] of string =
    ('3.0k','2.7k','2.4k','2.1k','1.8k','1.5k','1.2k','900','600','300');
  FILT_FM_NAMES: array[0..1] of string  = ('NFM', 'FM');
  FILT_WFM_NAMES: array[0..FILT_WFM_COUNT-1] of string = ('200k', '160k', '120k');
  FILT_RAW_NAMES: array[0..FILT_RAW_COUNT-1] of string =
    ('6.25k', '10k', '12.5k', '20k', '25k');

  // --- Редактируемая таблица фильтров -------------------------------------
  // 10 пресетов + VAR1/VAR2 на режим. Кромки
  // ЗНАКОВЫЕ и хранятся ПО РЕЖИМУ: у LSB своя строка (-2800..-100), у USB
  // своя — поэтому при применении фильтра конвертировать боковую больше не
  // нужно, и таблица знака в проекте остаётся ровно одна (FilterEdgesFromBW,
  // ниже) — только для постройки заводских дефолтов.
  FILT_VAR1  = FILT_COUNT;       // 10
  FILT_VAR2  = FILT_COUNT + 1;   // 11
  FILT_SLOTS = FILT_COUNT + 2;   // 12 — сетка кнопок 2 ряда по 6
  // Минимальная ширина полосы: ниже неё фильтр звенит, а перетаскивание края
  // мышью легко схлопнуло бы полосу в ноль.
  FILT_MIN_WIDTH = 50;

type
  TFilterSlot = record
    Name:   string;
    Lo, Hi: Integer;    // знаковые кромки в Гц относительно несущей
  end;
  TFilterSet   = array[0..FILT_SLOTS-1] of TFilterSlot;      // на режим
  TFilterTable = array[0..CFG_MODE_MAX] of TFilterSet;       // по режиму

  TBandSettings = record
    VfoA:      Double;
    VfoB:      Double;
    Mode:      Integer;
    FilterIdx: Integer;
    // FilterIdx — слот в таблице фильтров режима (см. TFilterSet); сами
    // кромки живут в таблице, а не здесь, поэтому диапазон помнит только
    // выбор. FilterBW остался легаси-полем для чтения старых конфигов.
    FilterBW:  Integer;
    AGCMode:   Integer;
    AGCTop:    Integer;
    CTun:      Boolean;
    CenterHz:  Double;       // DDC-центр при CTun (восстановление offset VFO в полосе)
    SpanHz:    Double;
    FMSQOn:       Boolean;
    FMSQLevel:    Integer;   // 0..100 → WDSP threshold 0.0..0.5
    CTCSSOn:      Boolean;
    CTCSSToneIdx: Integer;   // 0..37
    FMStepOn:     Boolean;
    FMStepIdx:    Integer;   // 0..3 (6.25/12.5/20/25 kHz)
    FMRptDir:     Integer;   // RPT_NONE/RPT_MINUS/RPT_PLUS
    FMRptOffsetHz: Double;   // Hz, 0 = use band default
    ZoomFactor:   Double;    // зум спектра/водопада 0.0..1.0 (0.0 = без зума)
    PanSlider:    Double;    // положение окна зума 0..1 (0.5 = центр)
    // Аппаратный AGC AD936x (Pluto/LibreSDR) — на диапазон: усиление тракта
    // сильно разное на 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). Восстанавливается
  // один раз при выходе в Run. Слайс = приёмный WDSP-канал внутри пана; частоты
  // абсолютные (видимые). См. [[project_slices_plan]].
  // Привязка «режим → индекс TX-профиля» (-1 = не переключать). Такая таблица
  // есть у каждого флага: у главного в TTXProfileList, у слайса в TSliceCfg.
  TTXProfModeTable = array[0..CFG_MODE_MAX] of Integer;

  TSliceCfg = record
    TargetHz:  Double;    // абсолютная видимая частота слайса
    Mode:      Integer;
    FilterLo:  Integer;
    FilterHi:  Integer;
    AGC:       Integer;   // Ord(TWDSPAGCMode)
    Volume:    Double;    // 0..1
    Muted:     Boolean;
    RxMuteOnTx: Boolean;  // True = слайс молчит на передаче (self-monitor выкл)
    FMSQOn:    Boolean;
    FMSQLevel: Integer;
    DevName:   string;    // имя PortAudio-устройства вывода ('' = default)
    InDevName: string;    // имя устройства ВВОДА слайса ('' = общий вход)
    // TX-профиль слайса ПО РЕЖИМУ: -1 = какой активен, тот и звучит. Таблица,
    // а не одно значение: слайс на FT8 и слайс голосом помнят каждый своё.
    TXProfByMode: TTXProfModeTable;
  end;

  TPanCfg = record
    Active:     Boolean;               // пан 1..N существует (пан 0 всегда есть)
    FreqHz:     Double;                // видимый центр DDC (только паны N)
    RateKHz:    Integer;
    ADC:        Integer;
    Zoom:       Double;                // 0..1
    PanSlider:  Double;                // 0..1
    StackShare: Double;                // доля высоты в стеке панов
    ColShare:   Double;                // доля ширины в грид-раскладке
    SplitRatio: Double;                // доля высоты спектра (сплиттер спектр/водопад)
    // Per-pan дисплей (3.6): override глобального рендера. HasDisplay=False ⇒
    // пан наследует глобальный дефолт (поля игнорируются).
    HasDisplay: Boolean;
    DPalette:   Integer;               // 0=Classic/1=Inferno/2=Turbo
    DGamma:     Double;
    DSpecRef:   Double;                // верх шкалы спектра, dBm
    DSpecRange: Double;                // диапазон шкалы, dB
    DWfAGC:     Boolean;               // авто-уровень водопада
    DWfOffset:  Double;                // яркость водопада при AGC, dB
    DWfHigh:    Double;                // ручной верх водопада, dBm
    DWfLow:     Double;                // ручной низ водопада, dBm
    Slices:     array of TSliceCfg;
  end;

  TPansConfig = record
    GridMode:     Boolean;             // грид-раскладка (▦) vs вертикальный стек (▤)
    GridRowShare: Double;              // доля высоты пана 0 в гриде
    Pans: array[0..CFG_PANS_MAX-1] of TPanCfg;  // [0]=главный (только Slices)
  end;

  // TX-настройки per-device. Хранятся в JSON-секции "tx" под MAC,
  // отдельно от global/bands. Применяются к WDSP через SetTX* методы.
  TTXSettings = record
    // Radio mic options (DUC Specific byte 50/51)
    MicLineIn:        Boolean;   // bit0: 1=Line In, 0=Mic In
    MicBoost:         Boolean;   // bit1: +20 dB preamp (только когда Mic In)
    MicPTTEnabled:    Boolean;   // bit2: Mic PTT enable (Orion)
    MicTipRing:       Boolean;   // bit3: False=Tip, True=Ring (Orion/OrionMkII)
    MicBias:          Boolean;   // bit4: электретный +5V bias (Orion)
    LineInGainDB:     Double;    // byte51: -34.5..+12 dB (шаг 1.5; только когда Line In)
    // Mic gain (dB, применяется в WDSP PanelGain1)
    MicGainDB:        Double;    // -10..+30
    // ATT on TX — ослабление RX-ADC во время передачи (защита от утечки TX).
    // DUC Specific bytes 57/58/59 (StepAtten 0/1/2), default 31 dB.
    AttOnTX:          Integer;   // 0..31 dB
    // Tune (кнопка TUN) — генерация тонального сигнала через WDSP PostGen.
    // Уровень — отдельный от слайдера Drive, проходит через ту же
    // per-band калибровку. Defaults: level=10 %, freq=750 Hz.
    TUNLevel:         Integer;   // 0..100 (% от полной шкалы Drive)
    TUNFreq:          Integer;   // Hz, обычно 600..1500 для SSB tune
    // TX bandpass filter
    FilterLow:        Integer;   // Hz, для SSB обычно 100
    FilterHigh:       Integer;   // Hz, для SSB обычно 2800
    FilterNC:         Integer;   // FIR length (1024/2048/4096)
    FilterMP:         Integer;   // 0=Linear Phase, 1=Low Latency
    FilterWindow:     Integer;   // 0=Rect..6=BH7
    // Compressor
    CompressorOn:     Boolean;
    CompressorGain:   Double;    // 0..20 dB
    // Leveler (TX AGC)
    LevelerOn:        Boolean;
    LevelerTop:       Double;    // 0..10 dB
    LevelerDecay:     Integer;   // ms
    // ALC (Auto Level Control)
    ALCOn:            Boolean;
    ALCMaxGain:       Double;    // -10..+10 dB
    ALCDecay:         Integer;   // ms
    // Phase Rotator
    PhaseRotOn:       Boolean;
    PhaseRotStages:   Integer;   // 4/6/8
    PhaseRotFreq:     Double;    // Hz, обычно 338
    // EQ TX (10-band: [0]=preamp, [1..10]=bands)
    EQOn:             Boolean;
    EQNumBands:       Integer;   // 3 или 10
    EQGains:          array[0..10] of Double;  // dB, ±15
    EQFreqs:          array[0..10] of Double;  // Hz
    // AM modulator
    AMCarrierLevel:   Double;    // 0..1
    // FM modulator
    FMDeviation:      Double;    // Hz, 2500..5000
    FMLowCut:         Integer;   // AF filter low
    FMHighCut:        Integer;   // AF filter high
    FMEmphPosition:   Integer;   // 0=PreEQ, 1=PostEQ
    // CTCSS
    CTCSSOn:          Boolean;
    CTCSSFreq:        Double;    // Hz
    // TX Display (отдельный analyzer для TX-спектра/водопада)
    TXFFTSize:        Integer;   // 4096..262144
    TXWindowType:     Integer;   // 0..6
    TXSpecDetector:   Integer;   // 0=Peak,1=Rosenfell,2=Average,3=Sample,4=RMS
    TXSpecAvgMode:    Integer;   // 0=None,1=Recursive,2=TimeWindow,3=LogRecursive
    TXSpecAvgTimeMS:  Double;
    TXWfDetector:     Integer;
    TXWfAvgMode:      Integer;
    TXWfAvgTimeMS:    Double;
    // TX Grid задаётся отдельно от RX и использует собственные границы.
    TXSpecRefLevel:   Double;    // dBm reference (top of scale), e.g. 0
    TXSpecRange:      Double;    // dB range, e.g. 80
    TXSpecGridStep:   Double;    // grid step in dB, e.g. 10
    // PureSignal (WDSP calcc, протокол 2)
    PSMoxDelay:       Double;    // с — задержка после MOX до сбора feedback (0.2)
    PSLoopDelay:      Double;    // с — пауза между итерациями калибровки (0)
    PSTXDelayNs:      Integer;   // нс — компенсация задержки TX-тракта (150)
    PSRelaxPtol:      Boolean;   // ослабленный допуск фазы (ptol 0.4 вместо 0.8)
    PSAutoAtten:      Boolean;   // авто-подстройка AttOnTX по уровню feedback
    PSPin:            Boolean;   // pin mode (True)
    PSMap:            Boolean;   // map mode (True)
    PSStabilize:      Boolean;   // stabilize (False)
    PSIntsIdx:        Integer;   // 0=16/256, 1=8/512, 2=4/1024
    PSHWPeak:         Double;    // SetPk (0.2899 для P2, 0.6121 Saturn)
    PSTTFreq1:        Integer;   // Гц — тон 1 двухтональника (700)
    PSTTFreq2:        Integer;   // Гц — тон 2 двухтональника (1900)
    // Калибровка фидбек-тракта: на Orion MkII цель 152
    // перегружает входные каскады ADC → IMD-«юбка», оптимум FB≈22 при ATT≈10дБ.
    PSFBTarget:       Integer;   // цель уровня feedback info[4]; 0 = авто по плате
                                 // (Orion MkII → 22, иначе 152)
    PSOutlierSigma:   Double;    // σ outlier-фильтра calcc; <0 = авто по плате
                                 // (Orion MkII → 5.0, иначе выкл.), 0 = выкл.
  end;

  // ---- CW ------------------------------------------------------------------
  // Телеграф в openHPSDR P2 делает ПРОШИВКА: точки/тире формирует кейер в FPGA,
  // мы только отдаём ему параметры (DUC Specific байты 5..13, 17). Поэтому
  // почти всё здесь — свойство устройства, а не «звука», и живёт отдельно от
  // TTXSettings/TTXProfile (профиль голоса телеграф не переключает).
  // Раскладка байта 5 задана константами HPSDRProtocol.CW_*.
  TCWSettings = record
    Pitch:           Integer;   // Гц, тон приёма/сайдтона, default 600
    // Память сообщений для программной передачи (окно CW Messages, F1..F8).
    Messages:        array[0..CW_MSG_COUNT-1] of string[63];
    // Манипуляция разрешена вообще (False = телеграф без кейера: MOX + голосом
    // ключа нет). ВМЕСТЕ с LocalKeyer образует одну развилку «кто рисует точки»,
    // в UI это один список: прошивка / программа / выкл.
    FWKeyer:         Boolean;
    KeyerMode:       Integer;   // 0=Straight, 1=Iambic A, 2=Iambic B
    ReverseKeys:     Boolean;   // поменять точку и тире местами
    StrictSpacing:   Boolean;   // строгие межзнаковые интервалы
    Speed:           Integer;   // WPM, 5..60
    Weight:          Integer;   // 33..66 (50 = точка:пауза 1:1)
    BreakIn:         Boolean;   // прошивка сама поднимает PTT на нажатие ключа
    HangTimeMS:      Integer;   // сколько держать TX после последнего элемента
    RampMS:          Integer;   // форма фронта посылки, default 9 мс
    RFDelayMS:       Integer;   // задержка РЧ после PTT (byte 13, реле/усилитель)
    SidetoneHW:      Boolean;   // тон в наушники САМОГО трансивера (байт 5/6)
    SidetoneHWLevel: Integer;   // 0..127
    // Программный сайдтон — свой генератор в аудиотракте PC. Нужен тем, кто
    // слушает через звуковую карту: аппаратный тон туда физически не попадает
    // (он подмешивается в наушники трансивера, а не в IQ-поток).
    // ★Точен там, где момент нажатия знает САМ PC: программная передача текста
    // (CWX) и прямой ключ. При иамбике таймингом владеет FPGA, и PC видит лишь
    // состояние лепестков манипулятора — там верен только аппаратный тон.
    SidetoneSW:      Boolean;
    SidetoneSWLevel: Integer;   // 0..100 % от полной шкалы аудио
    // ---- Локальный (программный) кейер --------------------------------------
    // Манипуляцию формирует САМ EWSDR: генератор рисует несущую с огибающей
    // прямо в поток IQ (CWKeyer.pas). Для Pluto/LibreSDR это единственный
    // возможный путь — кейера в железе там нет, и выбор «прошивка» вырождается
    // туда сам; для openHPSDR это опция, по умолчанию выключенная (тайминги в
    // FPGA точнее любой программы).
    LocalKeyer:      Boolean;
    // Сдвиг несущей манипуляции от гетеродина. 0 = авто: у openHPSDR 0 (DUC —
    // цифровой, утекать нечему), у zero-IF AD936x — CW_AUTO_OFFSET_HZ, иначе
    // утечка LO сидит РОВНО на рабочей частоте и слышна между посылками.
    CarrierOffsetHz: Integer;
    // Манипулятор на модемных линиях COM/USB-serial (DTR/RTS питают ключ,
    // CTS/DSR/DCD/RI читаются как лепестки).
    KeyPortEnabled:  Boolean;
    KeyPort:         string[63];
    KeyDotLine:      Integer;   // 0=CTS, 1=DSR, 2=DCD, 3=RI
    KeyDashLine:     Integer;
    KeyInvert:       Boolean;
    KeyPowerDTR:     Boolean;
    KeyPowerRTS:     Boolean;
    // ---- Окно-терминал: набор с клавиатуры + декодер приёма ----------------
    Decoder:         Boolean;   // разбирать принимаемый телеграф в текст
    DecoderStrip:    Boolean;   // бегущая лента расшифровки под спектром
    KbdKey:          Boolean;   // Ctrl в окне терминала работает манипулятором
  end;

  // ---- TX-профили (именованные снимки «как я звучу») ----------------------
  // Профиль хранит ТОЛЬКО то, что определяет звук в эфире: маршрутизацию
  // микрофонного входа, усиление, кромки TX-фильтра, динамическую обработку,
  // эквалайзер, уровень несущей AM и мощность. Всё остальное из TTXSettings
  // (FIR-длина/фаза/окно фильтра, ATT on TX, FM/CTCSS, параметры анализатора
  // и сетки, калибровка PureSignal) — свойство устройства, от профиля НЕ
  // зависит: переключение профиля не должно перерисовывать спектр, менять
  // задержку тракта или ломать калибровку PS.
  // Профиль не содержит параметры VAC и DSP-буферов, которых в EWSDR нет.
  TTXProfile = record
    Name:             string[31];
    // Микрофонный вход (гарнитура / микшер-ESSB — физически разные входы)
    MicLineIn:        Boolean;
    MicBoost:         Boolean;
    MicPTTEnabled:    Boolean;
    MicTipRing:       Boolean;
    MicBias:          Boolean;
    LineInGainDB:     Double;
    MicGainDB:        Double;
    // Кромки TX-фильтра (FIR-длина/фаза/окно остаются у устройства)
    FilterLow:        Integer;
    FilterHigh:       Integer;
    // Динамическая обработка
    CompressorOn:     Boolean;
    CompressorGain:   Double;
    LevelerOn:        Boolean;
    LevelerTop:       Double;
    LevelerDecay:     Integer;
    ALCOn:            Boolean;
    ALCMaxGain:       Double;
    ALCDecay:         Integer;
    PhaseRotOn:       Boolean;
    PhaseRotStages:   Integer;
    PhaseRotFreq:     Double;
    // Эквалайзер
    EQOn:             Boolean;
    EQNumBands:       Integer;
    EQGains:          array[0..10] of Double;
    EQFreqs:          array[0..10] of Double;
    // Модуляция AM (несущая — часть «звука», в отличие от девиации FM)
    AMCarrierLevel:   Double;
    // Мощность Power/Tune_Power хранится в профиле.
    DrivePercent:     Integer;   // 0..100
    TUNLevel:         Integer;   // 0..100
  end;

  TTXProfileList = record
    Count:     Integer;
    ActiveIdx: Integer;
    // Привязка профиля к ГЛАВНОМУ приёмнику (слайс A) ПО РЕЖИМУ; -1 = ничего
    // не переключать. У слайсов B..G своя такая же в TSliceCfg.TXProfByMode.
    // Таблица главного одна на устройство — общая для КВ и трансвертера
    // (решение пользователя); у слайсов она сама разъезжается по контекстам,
    // потому что их записи хранятся отдельно для КВ и трансвертера.
    MainByMode: TTXProfModeTable;
    Items:     array[0..TXPROF_MAX-1] of TTXProfile;
  end;

  // Web-сервер — глобальные настройки (не привязаны к устройству, секция "web").
  // DX-кластер: телнет-соединение + вид спотов на панадаптере.
  // Хранится глобально (секция "dxcluster"), не per-device: кластер и позывной
  // от железа не зависят.
  TDXClusterSettings = record
    Enabled:    Boolean;  // подключаться при старте
    ShowSpots:  Boolean;  // рисовать оверлей на спектре
    Host:       string;
    Port:       Integer;  // 1..65535
    Login:      string;   // позывной для логина в кластер (он же «свой» в оверлее)
    Password:   string;   // редкий кластер спрашивает — обычно пусто
    PostLogin:  string;   // команды после логина (по строке; сюда же set/filter)
    TTLMinutes: Integer;  // сколько держать спот (он же горизонт затухания)
    MaxRows:    Integer;  // рядов лесенки подписей (1..DXSPOT_MAX_ROWS)
    // Фильтр по модам. Пустая маска = показывать все (в т.ч. неопознанные).
    // Биты соответствуют TDXMode: 1=CW,2=SSB,3=DIGI,4=FT8,5=FT4,6=RTTY,7=PSK,
    // 8=FM,9=SSTV; бит 0 = споты без распознанной моды.
    ModeMask:   LongWord;
  end;

  TWebSettings = record
    Enabled:  Boolean;
    Port:     Integer;  // 1..65535
    BindAddr: string;   // e.g. '0.0.0.0' (все интерфейсы)
    User:     string;
    Pass:     string;
    // Потолок точек в кадре спектра/водопада для веб-клиента (1024/2048/4096).
    // Реально уходит min(этот потолок, разрешение анализатора = ширина
    // панадаптера). Трафик линеен: 4096 точек — это ~650 КБ/с на клиента
    // (два потока по 20 кадров/с), 1024 — ~160 КБ/с.
    SpecPixels: Integer;
  end;

  // TCI-сервер поверх WebSocket. Авторизации в
  // протоколе нет, поэтому умолчание слушает только петлю: логгеры и цифровые
  // программы обычно живут на том же ПК, а наружу порт открывается осознанно.
  TTCISettings = record
    Enabled:  Boolean;
    Port:     Integer;  // 1..65535, по умолчанию 40001
    BindAddr: string;   // '127.0.0.1' или '0.0.0.0'
    // ★Единственный каталог, куда TCI пишет записи линейного выхода
    // (LINE_OUT_RECORDER_SAVE). Пусто — <каталог конфигурации>/records.
    // Имя файла клиент выбирает сам, но КАТАЛОГ из его строки не берётся
    // никогда: авторизации в протоколе нет, и полный путь из сети означал бы
    // запись в любой доступный процессу файл (см. TCIRecordPath).
    RecordDir: string;
  end;

  // Transverter (XVTR) entry — один трансвертер.
  // Видимая частота VFO в [FreqBegin..FreqEnd] транслируется в IF-частоту,
  // которую слышит трансивер: f_IF = f_visible - LOOffset + LOError.
  // Примеры: 2m XVTR с LO=116 MHz → visible 144.200 ⇒ IF 28.200 MHz.
  TXvtrEntry = record
    Enabled:    Boolean;
    ButtonText: string[15];  // подпись на band-кнопке (например, '2m', '70cm')
    FreqBegin:  Double;      // Hz
    FreqEnd:    Double;      // Hz
    LOOffset:   Double;      // Hz, отнимается от видимой частоты для получения IF (RX)
    LOError:    Double;      // Hz, корректировка LO (положительная — IF растёт) — RX/LNB дрейф
    // QO-100 / split-LO транспондер: RX и TX идут на РАЗНЫХ LO. RX = visible-LOOffset
    // (+LOError, дрейф LNB по маяку). TX = visible-TxLOOffset (без LOError — uplink-LO
    // собственный у 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)
    DisablePA:  Boolean;     // True — отключить внутренний PA при TX (XVTR имеет свой)
    RXAntenna:  Byte;        // 0=по-умолчанию(основной band), 1=ANT1, 2=ANT2, 3=ANT3
    LastFreq:         Double;   // Hz — последняя tuned visible частота VFO A (BandStack)
    LastFreqB:        Double;   // Hz — последняя tuned visible частота VFO B
    LastMode:         Integer;  // Mode (LSB=0,USB=1,…FM=5)
    LastFilterIdx:    Integer;  // слот фильтра 0..FILT_SLOTS-1 (10/11 = VAR1/VAR2)
    LastFMSQOn:       Boolean;
    LastFMSQLevel:    Integer;
    LastCTCSSOn:      Boolean;
    LastCTCSSToneIdx: Integer;
    LastFMStepOn:     Boolean;
    LastFMStepIdx:    Integer;
    LastCTun:         Boolean;
    LastCenterHz:     Double;   // Hz — DDC-центр при CTun (восстановление offset VFO)
    LastAGCMode:      Integer;
    LastAGCTop:       Integer;
    LastFMRptDir:       Integer;
    LastFMRptOffsetHz:  Double;
    LastCTCSSAutoActive:  Boolean;  // CTCSS был включён каналом (авто)
    LastFMRptAutoActive:  Boolean;  // RPT был включён каналом (авто)
    LastZoomFactor:       Double;   // зум спектра/водопада 0.0..1.0 (0.0 = без зума)
    LastPanSlider:        Double;   // положение окна зума 0..1 (0.5 = центр)
    // Аппаратный 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
    Entries: array[0..CFG_XVTR_COUNT-1] of TXvtrEntry;
    // Use XVTR TUN power — кнопка TUN использует XVTR-specific power.
    UseXVTRTunePower: Boolean;
  end;

  // Alex (Antenna + Filter board) settings per-device.
  // Хранятся в JSON-секции "alex" под MAC.
  // Безопасные дефолты: ANT1 для всех диапазонов, вторичный вход выключен.
  // Антенные разъёмы AD936x (Pluto/LibreSDR): какой физический вход/выход
  // используется на каждом диапазоне. 0 = RX1/TX1, 1 = RX2/TX2.
  // Диапазоны — VU-план BoardUtils (12 слотов), плюс слоты трансвертеров.
  TAD936xAnt = record
    RxAnt:     array[0..CFG_BAND_COUNT-1] of Byte;
    TxAnt:     array[0..CFG_BAND_COUNT-1] of Byte;
    XvtrRxAnt: array[0..CFG_XVTR_COUNT-1] of Byte;
    XvtrTxAnt: array[0..CFG_XVTR_COUNT-1] of Byte;
  end;

  TAlexSettings = record
    // Per-band (11 bands: 0=160m..10=6m)
    RxAnt:   array[0..CFG_BAND_COUNT-1] of Byte;    // 1..3 (ANT1/2/3)
    TxAnt:   array[0..CFG_BAND_COUNT-1] of Byte;    // 1..3 (ANT1/2/3)
    // Вторичный RX вход: 0=выкл, 1=BYPS(Ext1+Bypass), 2=EXT1(Ext2+Bypass), 3=XVTR(XvtrDDC+Bypass)
    RxOnly:  array[0..CFG_BAND_COUNT-1] of Byte;
    DoNotTx: array[0..CFG_BAND_COUNT-1] of Boolean; // программный запрет TX на диапазоне
    // Глобальные флаги в режиме TX
    RxBypassOnTx: Boolean;   // бит 11 (Bypass relay) во время TX
    Ext1OnTx:     Boolean;   // бит 9 (Ext1) + бит 11 во время TX
    GndBpf2OnTx:  Boolean;   // заземление BPF2/RX2 во время TX (Alex1 бит 8, ANAN-8000DLE)
    EnableXvtrHf: Boolean;   // режим XVTR HF
  end;

  // OC Control (Open Collector выходы Penny/Alex, byte 1401 HP packet).
  // Только openHPSDR — у AD936x (Pluto/LibreSDR) такого выхода нет.
  // Хранятся в JSON-секции "oc" под MAC. Поддерживается сокращённый набор:
  // без PA-override/hot-switching внешнего PA (в EWSDR нет такой концепции
  // на панели) и без split-pins VFO A/B (у EWSDR мультислайсы).
  // HF-группа — 11 диапазонов (0=160m..10=6m, как в TAlexSettings).
  // VHF-группа — 8 слотов XVTR по внутренней нумерации EWSDR.
  TOCPinActions = array[0..OC_PIN_COUNT-1] of Byte;   // OCA_* на пин

  TOCSettings = record
    Enabled:    Boolean;
    RxMaskHF:   array[0..CFG_BAND_COUNT-1] of Byte;   // 7-бит маска, биты 0..6 = пины 1..7
    TxMaskHF:   array[0..CFG_BAND_COUNT-1] of Byte;
    ActionHF:   TOCPinActions;                        // для HF TX-маски
    RxMaskXvtr: array[0..CFG_XVTR_COUNT-1] of Byte;
    TxMaskXvtr: array[0..CFG_XVTR_COUNT-1] of Byte;
    ActionXvtr: TOCPinActions;                        // для XVTR TX-маски
  end;

  // Калибровки (вкладка Calibration): детектор мощности/КСВ Alex-моста,
  // напряжение/ток питания, S-метр. Модель детектора задаёт константы моста
  // (Vref, bridge voltage и ADC offset).
  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;   // сдвиг нуля датчика тока, мВ
    AmpSensMVA:     Double;   // чувствительность датчика тока, мВ/А
    // Офсет 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;
    // Офсет спектра/водопада, дБ — та же разбивка. Отдельно от S-метра (как
    // Display Cal Offset: у display-тракта своё окно/детектор, и его
    // уровень отличается от показаний стрелки на пару дБ.
    DispBandDB:     array[0..CFG_BAND_COUNT-1] of Double;
    DispXvtrDB:     array[0..CFG_XVTR_COUNT-1] of Double;
    // Частотная калибровка опорника, ppm.
    // Смысл: «опора выше номинала на N ppm» ⇒ до правки сигналы показываются
    // ниже истинной частоты; положительное значение поднимает показания.
    // Действует на всё железо разом; ручные LOError/TO Error трансвертеров
    // остаются сверху — тракт тракту рознь.
    FreqCalPPM:     Double;
  end;

  TGlobalSettings = record
    Volume:           Integer;
    TxMonVolume:      Integer;   // громкость self-monitor RX-аудио на TX (RX MUTE off)
    DriveLevel:       Integer;
    ActiveVfo:        Integer;
    NRMode:           Integer;
    NBMode:           Integer;
    SNBEnabled:       Boolean;
    ANFEnabled:       Boolean;
    AGCSlope:         Integer;
    AGCHangThreshold: Integer;
    WfAGCEnabled:     Boolean;
    WfNFEnabled:      Boolean;
    LastBand:         Integer;
    LastXvtr:         Integer;   // -1 = HF, 0..CFG_XVTR_COUNT-1 = активный трансвертер при выходе
    SampleRate:       Integer;   // глобальный — один для всех диапазонов
    RxGainMode:       Integer;   // Pluto: 0=manual,1=fast,2=slow,3=hybrid
    RxGainDb:         Integer;   // Pluto: manual gain дБ
    WindowLeft:       Integer;
    WindowTop:        Integer;
    WindowWidth:      Integer;
    WindowHeight:     Integer;
    // --- Display settings ---
    FFTSize:          Integer;   // 4096..262144
    WindowType:       Integer;   // 0=Rect,1=BH4,2=Hann,3=FlatTop,4=Hamming,5=Kaiser,6=BH7
    SpecDetector:     Integer;   // 0=Peak,1=Rosenfell,2=Average,3=Sample,4=RMS
    SpecAvgMode:      Integer;   // 0=None,1=Recursive,2=TimeWindow,3=LogRecursive
    SpecAvgTimeMS:    Double;    // averaging time in milliseconds
    WfDetector:       Integer;   // waterfall detector
    WfAvgMode:        Integer;   // waterfall average mode
    WfAvgTimeMS:      Double;    // waterfall averaging time in milliseconds
    WfManualHigh:     Double;    // manual waterfall high threshold
    WfManualLow:      Double;    // manual waterfall low threshold
    WfAGCOffset:      Double;    // AGC offset in dB
    WfPalette:        Integer;   // 0=Classic, 1=Inferno, 2=Turbo
    WfGamma:          Double;    // контраст палитры (0.3..3.0, 1.0 = линейно)
    DisplayPixels:    Integer;   // потолок точек анализатора (1024/2048/4096)
    WfSpeedDiv:       Integer;   // делитель скорости водопада (1/2/4/8, max-hold)
    SpecMSAA:         Integer;   // MSAA GL-спектра (1=off, 2, 4, 8)
    SpecRefLevel:     Double;    // dBm reference (top of scale), e.g. -20
    SpecRange:        Double;    // dB range, e.g. 110 (bottom = ref - range)
    SpecGridStep:     Double;    // grid step in dB, e.g. 10
    // --- Лупа наведения на маяк QO-100 (карточка BEACON TUNING) ---
    // Центр хранится СДВИГОМ от опорной частоты маяка, а не абсолютом: так
    // окно переживает и правку опорной частоты, и калибровку LOError.
    BeaconZoomSpanHz:   Double;  // ширина окна лупы, Гц
    BeaconZoomOffsetHz: Double;  // центр окна = BeaconRefHz + этот сдвиг
    BeaconZoomFollow:   Boolean; // держать наведение декодера в окне
    BeaconZoomAuto:     Boolean; // автопоиск маяка в окне лупы
    // --- Audio settings ---
    AudioOutDevice:    string;   // PortAudio output device name ('' = default)
    AudioSampleRate:   Integer;  // PortAudio stream sample rate (e.g. 48000)
    AudioInDevice:     string;   // PortAudio input device name for TX mic ('' = none)
    SendAudioToRadio:  Boolean;  // True = send RX audio stream to radio (built-in speaker)
    RawFMDeviation:    Integer;  // Hz, flat RAW FM discriminator scale
    // --- Visibility settings ---
    ShowSpectrum:      Boolean;  // True = draw spectrum on main form
    ShowWaterfall:     Boolean;  // True = draw waterfall on main form
    ShowWideband:      Boolean;  // True = draw raw ADC wideband pane on main form
    WidebandFill:      Boolean;  // True = fill wideband spectrum with gradient
    SpectrumFill:      Boolean;  // True = fill main spectrum under the curve with gradient
    DisplayDuplex:     Boolean;  // DUP: при TX показывать RX-водопад, TX-фильтр overlay'ем
    RxMuteOnTx:        Boolean;  // QO-100 self-monitor: True=RX-аудио глушится на TX (умолч.)
    // --- Display FPS ---
    DisplayFPS:        Integer;  // spectrum/waterfall timer fps (1..100)
    // --- Theme ---
    LightTheme:        Boolean;  // False = dark (default), True = light
    // --- VFO display digits ---
    FreqMhzDigits:     Integer;  // minimum MHz digit count: 3=999MHz, 4=9.999GHz, 5=99.999GHz
    // --- PA (Power Amplifier) settings ---
    PAMaxPower:        Double;   // максимальная выходная мощность, Вт (5..200)
    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
    // --- CAT (Computer Aided Transceiver) settings ---
    // До 4 последовательных портов (Linux: /dev/ttyS0, Windows: COM1 и т.д.)
    CATSerialEnabled:  array[0..3] of Boolean;
    CATSerialPort:     array[0..3] of string;   // имя порта ОС
    CATSerialBaud:     array[0..3] of Integer;  // скорость: 1200..115200
    CATSerialDataBits: array[0..3] of Integer;  // 7 или 8
    CATSerialStopBits: array[0..3] of Integer;  // 1 или 2
    CATSerialParity:   array[0..3] of Integer;  // 0=None, 1=Odd, 2=Even
    CATSerialAndromeda:array[0..3] of Boolean;  // порт — передняя панель Andromeda/G2
    // TCP CAT сервер (один, многоклиентский) — главный приёмник (слайс A)
    CATTcpEnabled:     Boolean;
    CATTcpPort:        Integer;  // порт, по умолчанию 19090
    // Слайс-CAT: свой TCP-порт на каждый ДОПОЛНИТЕЛЬНЫЙ слайс (B, C, …).
    // Индекс = слот слайса (0 = слайс B), см. CFG_CAT_SLICE_COUNT.
    CATSliceEnabled:   array[0..CFG_CAT_SLICE_COUNT-1] of Boolean;
    CATSlicePort:      array[0..CFG_CAT_SLICE_COUNT-1] of Integer; // 19091, 19092, …
    // Auto TX: команда TX по CAT-порту слайса сама назначает этот слайс
    // источником передачи.
    CATSliceAutoTX:    array[0..CFG_CAT_SLICE_COUNT-1] of Boolean;
    // Аудио слота слайса: имена PortAudio-устройств ('' = общий выход
    // приложения / общий вход). Имена, а не индексы: порядок перечисления
    // устройств меняется от запуска к запуску.
    CATSliceOutDev:    array[0..CFG_CAT_SLICE_COUNT-1] of string;
    CATSliceInDev:     array[0..CFG_CAT_SLICE_COUNT-1] of string;
  end;

  TSettingsManager = class
  private
    FFilePath: string;
    FRoot:     TJSONObject;
    function  EnsureObj(P: TJSONObject; const K: string): TJSONObject;
    function  GetDevObj(const M: string): TJSONObject;
    function  GetGlobalObj(D: TJSONObject): TJSONObject;
    function  GetBandObj(D: TJSONObject; Idx: Integer): TJSONObject;
    function  JI(O: TJSONObject; const K: string; Def: Integer): Integer;
    function  JD(O: TJSONObject; const K: string; Def: Double):  Double;
    function  JB(O: TJSONObject; const K: string; Def: Boolean): Boolean;
    function  JS(O: TJSONObject; const K: string; const Def: string): string;
    procedure JW(O: TJSONObject; const K: string; V: Integer); overload;
    procedure JW(O: TJSONObject; const K: string; V: Double);  overload;
    procedure JW(O: TJSONObject; const K: string; V: Boolean); overload;
    procedure JWS(O: TJSONObject; const K: string; const V: string);
    procedure MigrateXvtrTemplate(var X: TXvtrSettings;
      const AName: string; ABegin, AEnd, ALO: Double;
      AFullDup: Boolean = False; ATxLO: Double = 0.0);
  public
    constructor Create(const FilePath: string = '');
    destructor  Destroy; override;
    procedure Load;
    procedure Save;
    function  LoadDevice(const MAC: array of Byte;
                         out G: TGlobalSettings;
                         var Bands: array of TBandSettings): Boolean;
    procedure SaveGlobal(const MAC: array of Byte; const G: TGlobalSettings);
    procedure SaveBand(const MAC: array of Byte; BandIdx: Integer;
                       const B: TBandSettings);
    function  LoadBand(const MAC: array of Byte; BandIdx: Integer;
                       out B: TBandSettings): Boolean;
    // TX settings per-device (JSON-секция "tx" под MAC).
    // LoadTX возвращает True если устройство уже зарегистрировано в JSON;
    // в любом случае T заполняется (дефолтами или сохранёнными значениями).
    function  LoadTX(const MAC: array of Byte; out T: TTXSettings): Boolean;
    procedure SaveTX(const MAC: array of Byte; const T: TTXSettings);
    // CW settings per-device (JSON-секция "cw" под MAC).
    class procedure DefaultCW(out C: TCWSettings);
    function  LoadCW(const MAC: array of Byte; out C: TCWSettings): Boolean;
    procedure SaveCW(const MAC: array of Byte; const C: TCWSettings);
    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);
    procedure LoadWindowBounds(out L, T, W, H, DPI: Integer; out Maximized: Boolean);
    procedure SaveStartupPreview(VfoA, VfoB: Double; SampleRate: Integer);
    function  LoadStartupPreview(out VfoA, VfoB: Double; out SampleRate: Integer): Boolean;
    procedure SaveAudioBufferSize(BufferSize: Integer);
    function  LoadAudioBufferSize: Integer;
    // CAT settings — не привязаны к MAC, хранятся в корне JSON (доступны до подключения)
    procedure SaveCATSettings(const G: TGlobalSettings);
    procedure LoadCATSettings(var G: TGlobalSettings);
    // Alex (antenna) settings per-device (JSON-секция "alex" под MAC).
    class procedure DefaultAnt936x(out A: TAD936xAnt);
    function  LoadAnt936x(const MAC: array of Byte; out A: TAD936xAnt): Boolean;
    procedure SaveAnt936x(const MAC: array of Byte; const A: TAD936xAnt);
    class procedure DefaultAlex(out A: TAlexSettings);
    function  LoadAlex(const MAC: array of Byte; out A: TAlexSettings): Boolean;
    procedure SaveAlex(const MAC: array of Byte; const A: TAlexSettings);
    // OC Control settings per-device (JSON-секция "oc" под MAC).
    class procedure DefaultOC(out O: TOCSettings);
    function  LoadOC(const MAC: array of Byte; out O: TOCSettings): Boolean;
    procedure SaveOC(const MAC: array of Byte; const O: TOCSettings);
    // XVTR (Transverter) settings per-device (JSON-секция "xvtr" под MAC).
    class procedure DefaultXvtr(out X: TXvtrSettings);
    function  LoadXvtr(const MAC: array of Byte; out X: TXvtrSettings): Boolean;
    procedure SaveXvtr(const MAC: array of Byte; const X: TXvtrSettings);
    // Панадаптеры + слайсы per-device (JSON-секция "pans" под MAC). Ctx —
    // ключ контекста ('hf' или 'xvtr_N'): КВ и каждый трансвертер раздельно.
    class procedure DefaultPans(out P: TPansConfig);
    function  LoadPans(const MAC: array of Byte; const Ctx: string; out P: TPansConfig): Boolean;
    procedure SavePans(const MAC: array of Byte; const Ctx: string; const P: TPansConfig);
    // Web-сервер — глобальные настройки (секция "web" в корне JSON).
    class procedure DefaultDXCluster(out D: TDXClusterSettings);
    procedure LoadDXClusterSettings(out D: TDXClusterSettings);
    procedure SaveDXClusterSettings(const D: TDXClusterSettings);
    class procedure DefaultWeb(out W: TWebSettings);
    procedure LoadWebSettings(out W: TWebSettings);
    procedure SaveWebSettings(const W: TWebSettings);
    // TCI-сервер — глобальные настройки (секция "tci" в корне JSON).
    class procedure DefaultTCI(out T: TTCISettings);
    procedure LoadTCISettings(out T: TTCISettings);
    procedure SaveTCISettings(const T: TTCISettings);
    // Тема — глобальная настройка, не привязана к устройству
    procedure SaveTheme(ALightTheme: Boolean);
    function  LoadTheme: Boolean;
    // Таблица фильтров per-device (JSON-секция "filters" под MAC). Пишутся
    // только режимы, отличающиеся от заводских, — конфиг не пухнет.
    function  LoadFilters(const MAC: array of Byte; out T: TFilterTable): Boolean;
    procedure SaveFilters(const MAC: array of Byte; const T: TFilterTable);
    // TX-профили per-device (JSON-секция "tx_profiles" под MAC). LoadTXProfiles
    // возвращает False, если секции ещё нет: вызывающий строит заводской набор
    // из текущих TX-настроек (DefaultTXProfiles) — миграция старого конфига.
    function  LoadTXProfiles(const MAC: array of Byte; out L: TTXProfileList): Boolean;
    procedure SaveTXProfiles(const MAC: array of Byte; const L: TTXProfileList);
  end;

// ---- TX-профили ------------------------------------------------------------
// Снимок «звуковых» полей живых настроек в профиль (имя не трогается) и
// обратное применение. Единственные две точки, знающие состав профиля, —
// добавляя поле в TTXProfile, правь обе.
procedure TXProfileCapture(const T: TTXSettings; DrivePct: Integer; var P: TTXProfile);
procedure TXProfileApply(const P: TTXProfile; var T: TTXSettings; var DrivePct: Integer);
// Заводской набор профилей. Base — текущие настройки устройства: из них
// наследуются микрофонный вход, мощность и всё, что профиль не переопределяет
// (иначе переключение на «ESSB» сбрасывало бы Mic In/Line In и drive).
procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer;
                            out L: TTXProfileList);
// Таблица «режим → профиль» ↔ JSON-массив. Все элементы вне 0..TXPROF_MAX-1
// читаются как -1 («не переключать»), поэтому чужой/старый конфиг безопасен.
procedure ClearTXProfModeTable(var T: TTXProfModeTable);
function  ModeTableToJSON(const T: TTXProfModeTable): TJSONArray;
procedure JSONToModeTable(Node: TJSONData; var T: TTXProfModeTable);

// Сколько пресетов реально есть у режима (VAR1/VAR2 идут сверх этого числа).
function FilterPresetCount(Mode: Integer): Integer;
// Заводской индекс фильтра режима.
function FilterDefIdx(Mode: Integer): Integer;
// Единственная в проекте таблица «магнитуда полосы → знаковые кромки».
// Нужна только для постройки заводских дефолтов и для чтения старых
// конфигов, где хранилась одна ширина.
procedure FilterEdgesFromBW(Mode, BW: Integer; out Lo, Hi: Integer);
// Обратное преобразование для легаси-читателей FFilterBW. У SSB/DIGI «полоса»
// исторически = внешняя кромка (2.7k при кромках 100..2700), у остальных
// режимов — полная ширина; здесь это воспроизведено один в один.
function FilterBWFromEdges(Mode, Lo, Hi: Integer): Integer;
function DefaultFilterSet(Mode: Integer): TFilterSet;
procedure DefaultFilterTable(out T: TFilterTable);

implementation

function FilterPresetCount(Mode: Integer): Integer;
begin
  case Mode of
    MODE_FM:    Result := FILT_FM_COUNT;
    MODE_WFM:   Result := FILT_WFM_COUNT;
    MODE_DMR:   Result := 1;
    MODE_FMRAW: Result := FILT_RAW_COUNT;
  else          Result := FILT_COUNT;
  end;
end;

function FilterDefIdx(Mode: Integer): Integer;
begin
  case Mode of
    MODE_LSB, MODE_USB:   Result := FILT_SSB_DEF;
    MODE_DIGL, MODE_DIGU: Result := FILT_DIGI_DEF;
    MODE_DSB:             Result := FILT_DSB_DEF;
    MODE_CWL, MODE_CWU:   Result := FILT_CW_DEF;
    MODE_FM:              Result := FILT_FM_DEF;
    MODE_WFM:             Result := FILT_WFM_DEF;
    MODE_DMR:             Result := 0;
    MODE_FMRAW:           Result := FILT_RAW_DEF;
  else                    Result := FILT_AM_DEF;
  end;
end;

procedure FilterEdgesFromBW(Mode, BW: Integer; out Lo, Hi: Integer);
var Half: Integer;
begin
  Half := BW div 2;
  case Mode of
    MODE_LSB:  begin Lo := -BW;  Hi := -100; end;
    MODE_USB:  begin Lo := 100;  Hi := BW;   end;
    // DIGI: полоса считается от нуля — поднесущие цифровых видов начинаются
    // низко, срез на 100 Гц как в SSB съел бы их.
    MODE_DIGL: begin Lo := -BW;  Hi := 0;    end;
    MODE_DIGU: begin Lo := 0;    Hi := BW;   end;
  else  Lo := -Half; Hi := Half;            // DSB/CW/FM/AM/SAM/WFM
  end;
end;

function FilterBWFromEdges(Mode, Lo, Hi: Integer): Integer;
begin
  case Mode of
    MODE_LSB, MODE_USB, MODE_DIGL, MODE_DIGU:
      Result := Max(Abs(Lo), Abs(Hi));
  else
      Result := Hi - Lo;
  end;
end;

function DefaultFilterSet(Mode: Integer): TFilterSet;
var i, n, bw: Integer;
begin
  n := FilterPresetCount(Mode);
  for i := 0 to FILT_SLOTS - 1 do
  begin
    Result[i].Name := '';
    Result[i].Lo   := 0;
    Result[i].Hi   := 0;
  end;
  bw := 0;
  for i := 0 to n - 1 do
  begin
    case Mode of
      MODE_LSB, MODE_USB:   begin bw := FILT_SSB_BW[i];  Result[i].Name := FILT_SSB_NAMES[i];  end;
      MODE_DIGL, MODE_DIGU: begin bw := FILT_DIGI_BW[i]; Result[i].Name := FILT_DIGI_NAMES[i]; end;
      MODE_DSB:             begin bw := FILT_DSB_BW[i];  Result[i].Name := FILT_DSB_NAMES[i];  end;
      MODE_CWL, MODE_CWU:   begin bw := FILT_CW_BW[i];   Result[i].Name := FILT_CW_NAMES[i];   end;
      MODE_FM:              begin bw := FILT_FM_BW[i];   Result[i].Name := FILT_FM_NAMES[i];   end;
      MODE_WFM:             begin bw := FILT_WFM_BW[i];  Result[i].Name := FILT_WFM_NAMES[i];  end;
      MODE_DMR:             begin bw := FILT_DMR_BW;     Result[i].Name := '12.5k';            end;
      MODE_FMRAW:           begin bw := FILT_RAW_BW[i];  Result[i].Name := FILT_RAW_NAMES[i];  end;
    else                    begin bw := FILT_AM_BW[i];   Result[i].Name := FILT_AM_NAMES[i];   end;
    end;
    FilterEdgesFromBW(Mode, bw, Result[i].Lo, Result[i].Hi);
  end;
  // VAR1/VAR2 стартуют копией заводского пресета режима — «чистый лист»,
  // который не сдвигает звук, пока его не тронули.
  Result[FILT_VAR1]      := Result[FilterDefIdx(Mode)];
  Result[FILT_VAR1].Name := 'VAR1';
  Result[FILT_VAR2]      := Result[FilterDefIdx(Mode)];
  Result[FILT_VAR2].Name := 'VAR2';
end;

procedure DefaultFilterTable(out T: TFilterTable);
var m: Integer;
begin
  for m := 0 to CFG_MODE_MAX do T[m] := DefaultFilterSet(m);
end;

function LegacyAverageTimeMS(FPS, AvgMode, AvgCount: Integer;
  Backmult, DefaultMS: Double): Double;
var
  FPSv: Integer;
begin
  if AvgMode <= 0 then
    Exit(0.0);

  FPSv := EnsureRange(FPS, 1, 100);
  if (Backmult > 0.0) and (Backmult < 1.0) then
    Result := -1000.0 / (FPSv * Ln(Backmult))
  else
    Result := (1000.0 * Max(1, AvgCount)) / FPSv;

  if IsNan(Result) or IsInfinite(Result) or (Result <= 0.0) then
    Result := DefaultMS;

  Result := EnsureRange(Result, 10.0, 5000.0);
end;

class function TSettingsManager.MacToStr(const MAC: array of Byte): string;
begin
  Result := Format('%02X:%02X:%02X:%02X:%02X:%02X',
    [MAC[0],MAC[1],MAC[2],MAC[3],MAC[4],MAC[5]]);
end;

class procedure TSettingsManager.DefaultBand(BandIdx: Integer; out B: TBandSettings);
begin
  FillChar(B, SizeOf(B), 0);
  B.VfoA      := CFG_BAND_DEFAULT_FREQ[BandIdx];
  B.VfoB      := CFG_BAND_DEFAULT_FREQ[BandIdx];
  B.Mode      := CFG_BAND_DEFAULT_MODE[BandIdx];
  B.FilterIdx := 5;
  B.FilterBW  := 2700;
  B.AGCMode   := 1;
  B.AGCTop    := 90;
  B.CTun      := False;
  B.CenterHz  := 0;          // 0 = нет сохранённого центра → центрировать на VFO
  B.SpanHz    := 192000;
  B.RxGainMode := 2;   // slow_attack (как глобальный дефолт Pluto)
  B.RxGainDb   := 40;
  B.AttenDB    := 0;
  B.FMSQOn       := False;
  B.FMSQLevel    := 30;
  B.CTCSSOn      := False;
  B.CTCSSToneIdx := 0;
  B.FMStepOn     := True;
  B.FMStepIdx    := 3;   // 25 kHz
  B.FMRptDir      := 0;
  B.FMRptOffsetHz := 0.0;
  B.ZoomFactor    := 0.0;
  B.PanSlider     := 0.5;
end;

class procedure TSettingsManager.DefaultGlobal(out G: TGlobalSettings);
var i: Integer;
begin
  FillChar(G, SizeOf(G), 0);
  G.Volume := 70; G.TxMonVolume := 40; G.DriveLevel := 50; G.ActiveVfo := 0;
  G.NRMode := 0; G.NBMode := 0; G.SNBEnabled := False; G.ANFEnabled := False;
  G.AGCSlope := 0; G.AGCHangThreshold := 100; G.LastBand := 5;
  G.LastXvtr := -1;
  G.WfAGCEnabled := True;
  G.WfNFEnabled := False;
  G.SampleRate := 192000;
  G.RxGainMode := 2; G.RxGainDb := 40;
  G.PAMaxPower := 100.0;
  G.PlutoTxMaxAttDb := -10.0;
  for i := 0 to CFG_BAND_COUNT-1 do G.PABandCal[i] := CFG_PA_BAND_CAL_DEFAULT[i];
  for i := 0 to CFG_XVTR_COUNT-1 do G.VHFBandCal[i] := CFG_VHF_CAL_DEFAULT;
  // Display defaults
  G.FFTSize      := 131072;
  G.WindowType   := 2;     // Hann
  G.SpecDetector := 0;     // Peak
  G.SpecAvgMode  := 3;     // Log Recursive
  G.SpecAvgTimeMS := 30.0;
  G.WfDetector   := 0;     // Peak
  G.WfAvgMode    := 3;     // Log Recursive
  G.WfAvgTimeMS  := 120.0;
  G.WfManualHigh := -80.0;
  G.WfManualLow  := -130.0;
  G.WfAGCOffset  := 0.0;
  G.WfPalette    := 0;      // Classic
  G.WfGamma      := 1.0;
  G.DisplayPixels := 4096;  // 1:1 до 4K-ширины
  G.WfSpeedDiv   := 1;      // полная скорость
  G.SpecMSAA     := 4;      // сглаживание кривой спектра
  G.SpecRefLevel := -20.0;
  G.SpecRange    := 110.0;
  G.SpecGridStep := 10.0;
  G.BeaconZoomSpanHz   := 40000.0;
  G.BeaconZoomOffsetHz := 0.0;
  G.BeaconZoomFollow   := True;
  G.BeaconZoomAuto     := False;
  G.AudioOutDevice   := '';
  G.AudioSampleRate  := 48000;
  G.AudioInDevice    := '';
  G.SendAudioToRadio := False;
  G.RawFMDeviation   := RAW_FM_DEV_DEFAULT;
  G.ShowSpectrum     := True;
  G.ShowWaterfall    := True;
  G.ShowWideband     := False;
  G.WidebandFill     := False;
  G.SpectrumFill     := True;
  G.DisplayDuplex    := False;
  G.RxMuteOnTx       := True;
  G.DisplayFPS       := 60;
  G.FreqMhzDigits    := 3;
  // CAT defaults
  for i := 0 to 3 do begin
    G.CATSerialEnabled[i]  := False;
    G.CATSerialPort[i]     := SerDefaultPortName(i);
    G.CATSerialBaud[i]     := 9600;
    G.CATSerialDataBits[i] := 8;
    G.CATSerialStopBits[i] := 1;
    G.CATSerialParity[i]   := 0; // None
    G.CATSerialAndromeda[i]:= False;
  end;
  G.CATTcpEnabled := False;
  G.CATTcpPort    := 19090;
  for i := 0 to CFG_CAT_SLICE_COUNT-1 do begin
    G.CATSliceEnabled[i] := False;
    G.CATSlicePort[i]    := CFG_CAT_SLICE_PORT0 + i;
    G.CATSliceAutoTX[i]  := False;
    G.CATSliceOutDev[i]  := '';
    G.CATSliceInDev[i]   := '';
  end;
  // 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;
  // SMeterBandDB/SMeterXvtrDB — нули после FillChar (без коррекции).
end;

constructor TSettingsManager.Create(const FilePath: string);
begin
  inherited Create;
  if FilePath = '' then
    FFilePath := GetAppCfgDir + SETTINGS_FILE
  else
    FFilePath := FilePath;
  FRoot     := TJSONObject.Create;
end;

destructor TSettingsManager.Destroy;
begin
  FRoot.Free;
  inherited;
end;

procedure TSettingsManager.Load;
var F: TFileStream; P: TJSONParser; D: TJSONData;
begin
  if not FileExists(FFilePath) then Exit;
  try
    F := TFileStream.Create(FFilePath, fmOpenRead or fmShareDenyNone);
    try
      P := TJSONParser.Create(F, [joUTF8]);
      try
        D := P.Parse;
        if (D <> nil) and (D is TJSONObject) then
        begin FRoot.Free; FRoot := TJSONObject(D); end
        else
          D.Free;
      finally P.Free; end;
    finally F.Free; end;
  except
    FreeAndNil(FRoot);
    FRoot := TJSONObject.Create;
  end;
end;

procedure TSettingsManager.Save;
var
  S: string;
  F: TFileStream;
  Buf: TBytes;
begin
  try
    S   := FRoot.FormatJSON([], 2);
    Buf := TEncoding.UTF8.GetBytes(S);
    F   := TFileStream.Create(FFilePath, fmCreate);
    try
      if Length(Buf) > 0 then
        F.WriteBuffer(Buf[0], Length(Buf));
    finally F.Free; end;
  except end;
end;

function TSettingsManager.EnsureObj(P: TJSONObject; const K: string): TJSONObject;
var D: TJSONData; Idx: Integer;
begin
  D := P.Find(K);
  if (D <> nil) and (D is TJSONObject) then
    Result := TJSONObject(D)
  else
  begin
    if D <> nil then begin Idx := P.IndexOfName(K); if Idx >= 0 then P.Delete(Idx); end;
    Result := TJSONObject.Create;
    P.Add(K, Result);
  end;
end;

function TSettingsManager.GetDevObj(const M: string): TJSONObject;
begin Result := EnsureObj(FRoot, M); end;

function TSettingsManager.GetGlobalObj(D: TJSONObject): TJSONObject;
begin Result := EnsureObj(D, 'global'); end;

function TSettingsManager.GetBandObj(D: TJSONObject; Idx: Integer): TJSONObject;
begin Result := EnsureObj(EnsureObj(D, 'bands'), IntToStr(Idx)); end;

function TSettingsManager.JI(O: TJSONObject; const K: string; Def: Integer): Integer;
var D: TJSONData;
begin D := O.Find(K); if D<>nil then try Result:=D.AsInteger; except Result:=Def; end else Result:=Def; end;

function TSettingsManager.JD(O: TJSONObject; const K: string; Def: Double): Double;
var D: TJSONData;
begin D := O.Find(K); if D<>nil then try Result:=D.AsFloat; except Result:=Def; end else Result:=Def; end;

function TSettingsManager.JB(O: TJSONObject; const K: string; Def: Boolean): Boolean;
var D: TJSONData;
begin D := O.Find(K); if D<>nil then try Result:=D.AsBoolean; except Result:=Def; end else Result:=Def; end;

procedure TSettingsManager.JW(O: TJSONObject; const K: string; V: Integer);
var Idx: Integer;
begin
  Idx := O.IndexOfName(K); if Idx >= 0 then O.Delete(Idx);
  O.Add(K, V);
end;

procedure TSettingsManager.JW(O: TJSONObject; const K: string; V: Double);
var Idx: Integer;
begin
  Idx := O.IndexOfName(K); if Idx >= 0 then O.Delete(Idx);
  O.Add(K, V);
end;

procedure TSettingsManager.JW(O: TJSONObject; const K: string; V: Boolean);
var Idx: Integer;
begin
  Idx := O.IndexOfName(K); if Idx >= 0 then O.Delete(Idx);
  O.Add(K, V);
end;

function TSettingsManager.JS(O: TJSONObject; const K: string; const Def: string): string;
var D: TJSONData;
begin D := O.Find(K); if D<>nil then try Result:=D.AsString; except Result:=Def; end else Result:=Def; end;

procedure TSettingsManager.JWS(O: TJSONObject; const K: string; const V: string);
var Idx: Integer;
begin
  Idx := O.IndexOfName(K); if Idx >= 0 then O.Delete(Idx);
  O.Add(K, V);
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; SMLegacy: Double;
begin
  MacStr := MacToStr(MAC);
  Result := FRoot.Find(MacStr) <> nil;
  DevObj := GetDevObj(MacStr);
  GObj   := GetGlobalObj(DevObj);

  G.Volume           := JI(GObj,'volume',70);
  G.TxMonVolume      := JI(GObj,'tx_mon_volume',40);
  G.DriveLevel       := JI(GObj,'drive_level',50);
  G.ActiveVfo        := JI(GObj,'active_vfo',0);
  G.NRMode           := JI(GObj,'nr_mode',Ord(JB(GObj,'nr_enabled',False)));
  G.NBMode           := JI(GObj,'nb_mode',Ord(JB(GObj,'nb_enabled',False)));
  G.SNBEnabled       := JB(GObj,'snb_enabled',False);
  G.ANFEnabled       := JB(GObj,'anf_enabled',False);
  G.AGCSlope         := JI(GObj,'agc_slope',0);
  G.AGCHangThreshold := JI(GObj,'agc_hang_threshold',100);
  G.WfAGCEnabled     := JB(GObj,'wf_agc_enabled',True);
  G.WfNFEnabled      := JB(GObj,'wf_nf_enabled',False);
  G.LastBand         := JI(GObj,'last_band',5);
  G.LastXvtr         := JI(GObj,'last_xvtr',-1);
  G.SampleRate       := JI(GObj,'sample_rate',192000);
  G.RxGainMode       := JI(GObj,'rx_gain_mode',2);
  G.RxGainDb         := JI(GObj,'rx_gain_db',40);
  // Display settings
  G.FFTSize      := JI(GObj,'fft_size',131072);
  G.WindowType   := JI(GObj,'win_type',2);
  G.SpecDetector := JI(GObj,'spec_detector',0);
  G.SpecAvgMode  := JI(GObj,'spec_avg_mode',3);
  if GObj.Find('spec_avg_time_ms') <> nil then
    G.SpecAvgTimeMS := JD(GObj,'spec_avg_time_ms',30.0)
  else if (GObj.Find('spec_avg_count') <> nil) or
          (GObj.Find('spec_backmult') <> nil) then
  begin
    G.SpecAvgTimeMS := LegacyAverageTimeMS(
      JI(GObj,'display_fps',60),
      G.SpecAvgMode,
      JI(GObj,'spec_avg_count',1),
      JD(GObj,'spec_backmult',0.0),
      30.0);
  end
  else
    G.SpecAvgTimeMS := 30.0;
  G.WfDetector   := JI(GObj,'wf_detector',0);
  G.WfAvgMode    := JI(GObj,'wf_avg_mode',3);
  if GObj.Find('wf_avg_time_ms') <> nil then
    G.WfAvgTimeMS := JD(GObj,'wf_avg_time_ms',120.0)
  else if (GObj.Find('wf_avg_count') <> nil) or
          (GObj.Find('wf_backmult') <> nil) then
  begin
    G.WfAvgTimeMS := LegacyAverageTimeMS(
      JI(GObj,'display_fps',60),
      G.WfAvgMode,
      JI(GObj,'wf_avg_count',2),
      JD(GObj,'wf_backmult',0.25),
      120.0);
  end
  else
    G.WfAvgTimeMS := 120.0;
  G.WfManualHigh := JD(GObj,'wf_manual_high',-80.0);
  G.WfManualLow  := JD(GObj,'wf_manual_low',-130.0);
  G.WfAGCOffset  := JD(GObj,'wf_agc_offset',0.0);
  G.WfPalette    := JI(GObj,'wf_palette',0);
  G.WfGamma      := JD(GObj,'wf_gamma',1.0);
  G.DisplayPixels := JI(GObj,'display_pixels',4096);
  G.WfSpeedDiv   := JI(GObj,'wf_speed_div',1);
  G.SpecMSAA     := JI(GObj,'spec_msaa',4);
  G.SpecRefLevel := JD(GObj,'spec_ref_level',-20.0);
  G.SpecRange    := JD(GObj,'spec_range',110.0);
  G.SpecGridStep := JD(GObj,'spec_grid_step',10.0);
  G.BeaconZoomSpanHz   := JD(GObj,'beacon_zoom_span',40000.0);
  G.BeaconZoomOffsetHz := JD(GObj,'beacon_zoom_offset',0.0);
  G.BeaconZoomFollow   := JB(GObj,'beacon_zoom_follow',True);
  G.BeaconZoomAuto     := JB(GObj,'beacon_zoom_auto',False);
  // Audio settings
  G.AudioOutDevice  := JS(GObj,'audio_out_device','');
  G.AudioSampleRate := JI(GObj,'audio_sample_rate',48000);
  G.AudioInDevice   := JS(GObj,'audio_in_device','');
  G.SendAudioToRadio := JB(GObj,'send_audio_to_radio',False);
  G.RawFMDeviation := EnsureRange(
    JI(GObj,'raw_fm_deviation',RAW_FM_DEV_DEFAULT),
    RAW_FM_DEV_MIN, RAW_FM_DEV_MAX);
  // Visibility settings
  G.ShowSpectrum  := JB(GObj,'show_spectrum',True);
  G.ShowWaterfall := JB(GObj,'show_waterfall',True);
  G.ShowWideband  := JB(GObj,'show_wideband',False);
  G.WidebandFill  := JB(GObj,'wideband_fill',False);
  G.SpectrumFill  := JB(GObj,'spectrum_fill',True);
  G.DisplayDuplex := JB(GObj,'display_duplex',False);
  G.RxMuteOnTx    := JB(GObj,'rx_mute_on_tx',True);
  G.DisplayFPS    := JI(GObj,'display_fps',60);
  G.LightTheme    := JB(GObj,'light_theme',False);
  G.FreqMhzDigits := EnsureRange(JI(GObj,'freq_mhz_digits',3), 3, 5);
  // PA settings
  G.PAMaxPower := JD(GObj,'pa_max_power',100.0);
  G.PlutoTxMaxAttDb := JD(GObj,'pluto_tx_max_att',-10.0);
  for i := 0 to CFG_BAND_COUNT-1 do
    G.PABandCal[i] := JD(GObj,'pa_band_cal_'+IntToStr(i), CFG_PA_BAND_CAL_DEFAULT[i]);
  for i := 0 to CFG_XVTR_COUNT-1 do
    G.VHFBandCal[i] := JD(GObj,'vhf_cal_'+IntToStr(i), CFG_VHF_CAL_DEFAULT);
  // CAT serial settings
  for i := 0 to 3 do begin
    G.CATSerialEnabled[i]  := JB(GObj,'cat_serial_en_'+IntToStr(i), False);
    G.CATSerialPort[i]     := JS(GObj,'cat_serial_port_'+IntToStr(i), SerDefaultPortName(i));
    G.CATSerialBaud[i]     := JI(GObj,'cat_serial_baud_'+IntToStr(i), 9600);
    G.CATSerialDataBits[i] := JI(GObj,'cat_serial_dbits_'+IntToStr(i), 8);
    G.CATSerialStopBits[i] := JI(GObj,'cat_serial_sbits_'+IntToStr(i), 1);
    G.CATSerialParity[i]   := JI(GObj,'cat_serial_parity_'+IntToStr(i), 0);
    G.CATSerialAndromeda[i]:= JB(GObj,'cat_serial_androm_'+IntToStr(i), False);
  end;
  for i := 0 to CFG_CAT_SLICE_COUNT-1 do begin
    G.CATSliceEnabled[i] := JB(GObj,'cat_slice_en_'    +IntToStr(i), False);
    G.CATSlicePort[i]    := JI(GObj,'cat_slice_port_'  +IntToStr(i), CFG_CAT_SLICE_PORT0 + i);
    G.CATSliceAutoTX[i]  := JB(GObj,'cat_slice_autotx_'+IntToStr(i), False);
    G.CATSliceOutDev[i]  := JS(GObj,'cat_slice_out_'   +IntToStr(i), '');
    G.CATSliceInDev[i]   := JS(GObj,'cat_slice_in_'    +IntToStr(i), '');
  end;
  G.CATTcpEnabled := JB(GObj,'cat_tcp_enabled', False);
  G.CATTcpPort    := JI(GObj,'cat_tcp_port', 19090);
  // 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);
  // 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);
  // Спектр/водопад: своя таблица офсетов (дефолт 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);
  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
    DefaultBand(i, Bands[i]);
    BObj := GetBandObj(DevObj, i);
    Bands[i].VfoA      := JD(BObj,'vfo_a',     Bands[i].VfoA);
    Bands[i].VfoB      := JD(BObj,'vfo_b',     Bands[i].VfoB);
    Bands[i].Mode      := EnsureRange(JI(BObj,'mode', Bands[i].Mode), CFG_MODE_MIN, CFG_MODE_MAX);
    Bands[i].FilterIdx := JI(BObj,'filter_idx', Bands[i].FilterIdx);
    Bands[i].FilterBW  := JI(BObj,'filter_bw',  Bands[i].FilterBW);
    Bands[i].AGCMode   := JI(BObj,'agc_mode',   Bands[i].AGCMode);
    Bands[i].AGCTop    := JI(BObj,'agc_top',    Bands[i].AGCTop);
    Bands[i].CTun      := JB(BObj,'ctun',        Bands[i].CTun);
    Bands[i].CenterHz  := JD(BObj,'center_hz',   Bands[i].CenterHz);
    Bands[i].SpanHz    := JD(BObj,'span_hz',     Bands[i].SpanHz);
    // FM squelch / CTCSS / step / repeater — как в LoadBand (раньше пропускались
    // здесь → на старте сбрасывались в дефолт, хотя SaveBand их пишет).
    Bands[i].FMSQOn       := JB(BObj,'fmsq_on',    Bands[i].FMSQOn);
    Bands[i].FMSQLevel    := JI(BObj,'fmsq_level', Bands[i].FMSQLevel);
    Bands[i].CTCSSOn      := JB(BObj,'ctcss_on',   Bands[i].CTCSSOn);
    Bands[i].CTCSSToneIdx := EnsureRange(JI(BObj,'ctcss_idx', Bands[i].CTCSSToneIdx), 0, 37);
    Bands[i].FMStepOn     := JB(BObj,'fmstep_on',  Bands[i].FMStepOn);
    Bands[i].FMStepIdx    := EnsureRange(JI(BObj,'fmstep_idx', Bands[i].FMStepIdx), 0, 3);
    Bands[i].FMRptDir      := EnsureRange(JI(BObj,'fmrpt_dir', Bands[i].FMRptDir), 0, 2);
    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;

procedure TSettingsManager.SaveGlobal(const MAC: array of Byte;
                                       const G: TGlobalSettings);
var O: TJSONObject; i: Integer;
begin
  O := GetGlobalObj(GetDevObj(MacToStr(MAC)));
  JW(O,'volume',G.Volume); JW(O,'tx_mon_volume',G.TxMonVolume); JW(O,'drive_level',G.DriveLevel);
  JW(O,'active_vfo',G.ActiveVfo);
  JW(O,'nr_mode',G.NRMode); JW(O,'nb_mode',G.NBMode);
  JW(O,'nr_enabled',G.NRMode <> 0); JW(O,'nb_enabled',G.NBMode <> 0);
  JW(O,'snb_enabled',G.SNBEnabled);
  JW(O,'anf_enabled',G.ANFEnabled);
  JW(O,'agc_slope',G.AGCSlope);
  JW(O,'agc_hang_threshold',G.AGCHangThreshold);
  JW(O,'wf_agc_enabled',G.WfAGCEnabled);
  JW(O,'wf_nf_enabled',G.WfNFEnabled);
  JW(O,'last_band',G.LastBand);
  JW(O,'last_xvtr',G.LastXvtr);
  JW(O,'sample_rate',G.SampleRate);
  JW(O,'rx_gain_mode',G.RxGainMode);
  JW(O,'rx_gain_db',G.RxGainDb);
  // Display settings
  JW(O,'fft_size',G.FFTSize);
  JW(O,'win_type',G.WindowType);
  JW(O,'spec_detector',G.SpecDetector);
  JW(O,'spec_avg_mode',G.SpecAvgMode);
  JW(O,'spec_avg_time_ms',G.SpecAvgTimeMS);
  JW(O,'wf_detector',G.WfDetector);
  JW(O,'wf_avg_mode',G.WfAvgMode);
  JW(O,'wf_avg_time_ms',G.WfAvgTimeMS);
  JW(O,'wf_manual_high',G.WfManualHigh);
  JW(O,'wf_manual_low',G.WfManualLow);
  JW(O,'wf_agc_offset',G.WfAGCOffset);
  JW(O,'wf_palette',G.WfPalette);
  JW(O,'wf_gamma',G.WfGamma);
  JW(O,'display_pixels',G.DisplayPixels);
  JW(O,'wf_speed_div',G.WfSpeedDiv);
  JW(O,'spec_msaa',G.SpecMSAA);
  JW(O,'spec_ref_level',G.SpecRefLevel);
  JW(O,'spec_range',G.SpecRange);
  JW(O,'spec_grid_step',G.SpecGridStep);
  JW(O,'beacon_zoom_span',G.BeaconZoomSpanHz);
  JW(O,'beacon_zoom_offset',G.BeaconZoomOffsetHz);
  JW(O,'beacon_zoom_follow',G.BeaconZoomFollow);
  JW(O,'beacon_zoom_auto',G.BeaconZoomAuto);
  // Audio settings
  JWS(O,'audio_out_device',G.AudioOutDevice);
  JW(O,'audio_sample_rate',G.AudioSampleRate);
  JWS(O,'audio_in_device',G.AudioInDevice);
  JW(O,'send_audio_to_radio',G.SendAudioToRadio);
  JW(O,'raw_fm_deviation',G.RawFMDeviation);
  // Visibility settings
  JW(O,'show_spectrum',G.ShowSpectrum);
  JW(O,'show_waterfall',G.ShowWaterfall);
  JW(O,'show_wideband',G.ShowWideband);
  JW(O,'wideband_fill',G.WidebandFill);
  JW(O,'spectrum_fill',G.SpectrumFill);
  JW(O,'display_duplex',G.DisplayDuplex);
  JW(O,'rx_mute_on_tx',G.RxMuteOnTx);
  JW(O,'display_fps',G.DisplayFPS);
  JW(O,'light_theme',G.LightTheme);
  JW(O,'freq_mhz_digits',G.FreqMhzDigits);
  // PA settings
  JW(O,'pa_max_power',G.PAMaxPower);
  JW(O,'pluto_tx_max_att',G.PlutoTxMaxAttDb);
  for i := 0 to CFG_BAND_COUNT-1 do
    JW(O,'pa_band_cal_'+IntToStr(i),G.PABandCal[i]);
  for i := 0 to CFG_XVTR_COUNT-1 do
    JW(O,'vhf_cal_'+IntToStr(i),G.VHFBandCal[i]);
  // CAT serial settings
  for i := 0 to 3 do begin
    JW(O,'cat_serial_en_'+IntToStr(i),   G.CATSerialEnabled[i]);
    JWS(O,'cat_serial_port_'+IntToStr(i),G.CATSerialPort[i]);
    JW(O,'cat_serial_baud_'+IntToStr(i), G.CATSerialBaud[i]);
    JW(O,'cat_serial_dbits_'+IntToStr(i),G.CATSerialDataBits[i]);
    JW(O,'cat_serial_sbits_'+IntToStr(i),G.CATSerialStopBits[i]);
    JW(O,'cat_serial_parity_'+IntToStr(i),G.CATSerialParity[i]);
    JW(O,'cat_serial_androm_'+IntToStr(i),G.CATSerialAndromeda[i]);
  end;
  for i := 0 to CFG_CAT_SLICE_COUNT-1 do begin
    JW(O,'cat_slice_en_'    +IntToStr(i), G.CATSliceEnabled[i]);
    JW(O,'cat_slice_port_'  +IntToStr(i), G.CATSlicePort[i]);
    JW(O,'cat_slice_autotx_'+IntToStr(i), G.CATSliceAutoTX[i]);
    JWS(O,'cat_slice_out_'  +IntToStr(i), G.CATSliceOutDev[i]);
    JWS(O,'cat_slice_in_'   +IntToStr(i), G.CATSliceInDev[i]);
  end;
  JW(O,'cat_tcp_enabled',G.CATTcpEnabled);
  JW(O,'cat_tcp_port',G.CATTcpPort);
  // 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);
  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]);
  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]);
  JW(O,'cal_freq_ppm',G.Cal.FreqCalPPM);
end;

procedure TSettingsManager.SaveBand(const MAC: array of Byte; BandIdx: Integer;
                                     const B: TBandSettings);
var O: TJSONObject;
begin
  if (BandIdx < 0) or (BandIdx >= CFG_BAND_COUNT) then Exit;
  O := GetBandObj(GetDevObj(MacToStr(MAC)), BandIdx);
  JW(O,'vfo_a',B.VfoA); JW(O,'vfo_b',B.VfoB);
  JW(O,'mode',B.Mode);
  JW(O,'filter_idx',B.FilterIdx); JW(O,'filter_bw',B.FilterBW);
  JW(O,'agc_mode',B.AGCMode); JW(O,'agc_top',B.AGCTop);
  JW(O,'ctun',B.CTun); JW(O,'center_hz',B.CenterHz); JW(O,'span_hz',B.SpanHz);
  JW(O,'fmsq_on',B.FMSQOn); JW(O,'fmsq_level',B.FMSQLevel);
  JW(O,'ctcss_on',B.CTCSSOn); JW(O,'ctcss_idx',B.CTCSSToneIdx);
  JW(O,'fmstep_on',B.FMStepOn); JW(O,'fmstep_idx',B.FMStepIdx);
  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;
                                    out B: TBandSettings): Boolean;
var MacStr: string; O: TJSONObject;
begin
  DefaultBand(BandIdx, B);
  MacStr := MacToStr(MAC);
  Result := FRoot.Find(MacStr) <> nil;
  if not Result then Exit;
  O := GetBandObj(GetDevObj(MacStr), BandIdx);
  B.VfoA      := JD(O,'vfo_a',     B.VfoA);
  B.VfoB      := JD(O,'vfo_b',     B.VfoB);
  B.Mode      := EnsureRange(JI(O,'mode', B.Mode), CFG_MODE_MIN, CFG_MODE_MAX);
  B.FilterIdx := JI(O,'filter_idx', B.FilterIdx);
  B.FilterBW  := JI(O,'filter_bw',  B.FilterBW);
  B.AGCMode   := JI(O,'agc_mode',   B.AGCMode);
  B.AGCTop    := JI(O,'agc_top',    B.AGCTop);
  B.CTun      := JB(O,'ctun',        B.CTun);
  B.CenterHz  := JD(O,'center_hz',   B.CenterHz);
  B.SpanHz    := JD(O,'span_hz',     B.SpanHz);
  B.FMSQOn       := JB(O,'fmsq_on',    B.FMSQOn);
  B.FMSQLevel    := JI(O,'fmsq_level', B.FMSQLevel);
  B.CTCSSOn      := JB(O,'ctcss_on',   B.CTCSSOn);
  B.CTCSSToneIdx := EnsureRange(JI(O,'ctcss_idx', B.CTCSSToneIdx), 0, 37);
  B.FMStepOn     := JB(O,'fmstep_on',  B.FMStepOn);
  B.FMStepIdx    := EnsureRange(JI(O,'fmstep_idx', B.FMStepIdx), 0, 3);
  B.FMRptDir      := EnsureRange(JI(O,'fmrpt_dir', B.FMRptDir), 0, 2);
  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;

procedure TSettingsManager.SaveWindowBounds(L, T, W, H, DPI: Integer; Maximized: Boolean);
var O: TJSONObject;
begin
  O := EnsureObj(FRoot, 'window');
  JW(O, 'left',      L);
  JW(O, 'top',       T);
  JW(O, 'width',     W);
  JW(O, 'height',    H);
  JW(O, 'dpi',       DPI);
  JW(O, 'maximized', Maximized);
end;

procedure TSettingsManager.LoadWindowBounds(out L, T, W, H, DPI: Integer; out Maximized: Boolean);
var O: TJSONObject;
begin
  L := 80; T := 80; W := 1400; H := 900; DPI := 0; Maximized := False;
  if FRoot.Find('window') = nil then Exit;
  O := EnsureObj(FRoot, 'window');
  L         := JI(O, 'left',      80);
  T         := JI(O, 'top',       80);
  W         := JI(O, 'width',     1400);
  H         := JI(O, 'height',    900);
  DPI       := JI(O, 'dpi',       0);
  Maximized := JB(O, 'maximized', False);
end;

procedure TSettingsManager.SaveStartupPreview(VfoA, VfoB: Double; SampleRate: Integer);
var O: TJSONObject;
begin
  O := EnsureObj(FRoot, 'startup');
  JW(O, 'vfo_a', VfoA);
  JW(O, 'vfo_b', VfoB);
  JW(O, 'sample_rate', SampleRate);
end;

function TSettingsManager.LoadStartupPreview(out VfoA, VfoB: Double; out SampleRate: Integer): Boolean;
var O: TJSONObject;
begin
  Result := FRoot.Find('startup') <> nil;
  if not Result then Exit;

  O := EnsureObj(FRoot, 'startup');
  VfoA := JD(O, 'vfo_a', 14200000);
  VfoB := JD(O, 'vfo_b', 7100000);
  SampleRate := JI(O, 'sample_rate', 192000);
end;

procedure TSettingsManager.SaveAudioBufferSize(BufferSize: Integer);
var O: TJSONObject;
begin
  if BufferSize <= 128 then BufferSize := 128
  else if BufferSize <= 256 then BufferSize := 256
  else BufferSize := 512;
  O := EnsureObj(FRoot, 'audio');
  JW(O, 'output_buffer_size', BufferSize);
end;

function TSettingsManager.LoadAudioBufferSize: Integer;
var O: TJSONObject;
begin
  if FRoot.Find('audio') = nil then Exit(128);
  O := EnsureObj(FRoot, 'audio');
  Result := JI(O, 'output_buffer_size', 128);
  if Result <= 128 then Result := 128
  else if Result <= 256 then Result := 256
  else Result := 512;
end;

procedure TSettingsManager.SaveCATSettings(const G: TGlobalSettings);
var O: TJSONObject; i: Integer;
begin
  O := EnsureObj(FRoot, 'cat');
  JW(O, 'tcp_en',   G.CATTcpEnabled);
  JW(O, 'tcp_port', G.CATTcpPort);
  for i := 0 to 3 do
  begin
    JW(O,  'ser_en_'    +IntToStr(i), G.CATSerialEnabled[i]);
    JWS(O, 'ser_port_'  +IntToStr(i), G.CATSerialPort[i]);
    JW(O,  'ser_baud_'  +IntToStr(i), G.CATSerialBaud[i]);
    JW(O,  'ser_dbits_' +IntToStr(i), G.CATSerialDataBits[i]);
    JW(O,  'ser_sbits_' +IntToStr(i), G.CATSerialStopBits[i]);
    JW(O,  'ser_parity_'+IntToStr(i), G.CATSerialParity[i]);
    JW(O,  'ser_androm_'+IntToStr(i), G.CATSerialAndromeda[i]);
  end;
  for i := 0 to CFG_CAT_SLICE_COUNT-1 do
  begin
    JW(O, 'slice_en_'    +IntToStr(i), G.CATSliceEnabled[i]);
    JW(O, 'slice_port_'  +IntToStr(i), G.CATSlicePort[i]);
    JW(O, 'slice_autotx_'+IntToStr(i), G.CATSliceAutoTX[i]);
    JWS(O,'slice_out_'   +IntToStr(i), G.CATSliceOutDev[i]);
    JWS(O,'slice_in_'    +IntToStr(i), G.CATSliceInDev[i]);
  end;
end;

procedure TSettingsManager.LoadCATSettings(var G: TGlobalSettings);
var O: TJSONObject; i: Integer;
begin
  if FRoot.Find('cat') = nil then Exit;
  O := EnsureObj(FRoot, 'cat');
  G.CATTcpEnabled := JB(O, 'tcp_en',   False);
  G.CATTcpPort    := JI(O, 'tcp_port', 19090);
  for i := 0 to 3 do
  begin
    G.CATSerialEnabled[i]  := JB(O, 'ser_en_'    +IntToStr(i), False);
    G.CATSerialPort[i]     := JS(O, 'ser_port_'  +IntToStr(i), SerDefaultPortName(i));
    G.CATSerialBaud[i]     := JI(O, 'ser_baud_'  +IntToStr(i), 9600);
    G.CATSerialDataBits[i] := JI(O, 'ser_dbits_' +IntToStr(i), 8);
    G.CATSerialStopBits[i] := JI(O, 'ser_sbits_' +IntToStr(i), 1);
    G.CATSerialParity[i]   := JI(O, 'ser_parity_'+IntToStr(i), 0);
    G.CATSerialAndromeda[i]:= JB(O, 'ser_androm_'+IntToStr(i), False);
  end;
  for i := 0 to CFG_CAT_SLICE_COUNT-1 do
  begin
    G.CATSliceEnabled[i] := JB(O, 'slice_en_'    +IntToStr(i), False);
    G.CATSlicePort[i]    := JI(O, 'slice_port_'  +IntToStr(i), CFG_CAT_SLICE_PORT0 + i);
    G.CATSliceAutoTX[i]  := JB(O, 'slice_autotx_'+IntToStr(i), False);
    G.CATSliceOutDev[i]  := JS(O, 'slice_out_'   +IntToStr(i), '');
    G.CATSliceInDev[i]   := JS(O, 'slice_in_'    +IntToStr(i), '');
  end;
end;

const
  // Заводская 10-полосная голосовая сетка EQ (Hz). [0]=слот преампа (частота
  // не используется), [1..10]=узлы кривой. Подобрано под голос, а не под
  // музыку: больше точек в 100..3500 Hz. Нужна и DefaultTX, и заводским
  // профилям (сброс тембра в ноль), поэтому живёт на уровне модуля.
  DEF_EQ_FREQS: array[0..10] of Double = (
    0, 100, 200, 400, 700, 1100, 1500, 2000, 2500, 3000, 3500);

// ---------------------------------------------------------------------------
// TX settings (per-device, JSON-секция "tx" под MAC)
// ---------------------------------------------------------------------------

class procedure TSettingsManager.DefaultTX(out T: TTXSettings);
var
  i: Integer;
begin
  FillChar(T, SizeOf(T), 0);
  T.MicLineIn     := False;
  T.MicBoost      := False;
  T.MicPTTEnabled := False;
  T.MicTipRing    := False;   // Tip (mic at Tip, PTT at Ring)
  T.MicBias       := True;
  T.LineInGainDB  := 0.0;    // 0 dB
  T.MicGainDB     := 10.0;
  // ATT on TX — default 31 dB (полная защита RX-ADC во время передачи)
  T.AttOnTX       := 31;
  // Tune defaults
  T.TUNLevel      := 10;
  T.TUNFreq       := 750;
  // SSB-фильтр по умолчанию
  T.FilterLow     := 200;
  T.FilterHigh    := 3100;
  T.FilterNC      := 1024;
  T.FilterMP      := 1;     // Low Latency
  T.FilterWindow  := 1;     // BH4 / Blackman-Harris
  // Compressor — выкл по умолчанию
  T.CompressorOn  := False;
  T.CompressorGain:= 5.0;
  // Leveler включён с типичными начальными значениями.
  T.LevelerOn     := True;
  T.LevelerTop    := 5.0;
  T.LevelerDecay  := 500;
  // ALC — вкл всегда (защита от перегруза DAC)
  T.ALCOn         := True;
  T.ALCMaxGain    := 0.0;
  T.ALCDecay      := 10;
  // Phase Rotator — выкл
  T.PhaseRotOn    := False;
  T.PhaseRotStages:= 8;
  T.PhaseRotFreq  := 338.0;
  // EQ — выкл, нулевые гейны
  T.EQOn          := False;
  T.EQNumBands    := 10;
  for i := 0 to 10 do
  begin
    T.EQGains[i] := 0.0;
    T.EQFreqs[i] := DEF_EQ_FREQS[i];
  end;
  // AM
  T.AMCarrierLevel := 0.5;
  // FM
  T.FMDeviation    := 5000.0;
  T.FMLowCut       := 300;
  T.FMHighCut      := 3000;
  T.FMEmphPosition := 1;    // PostEQ
  // CTCSS
  T.CTCSSOn        := False;
  T.CTCSSFreq      := 100.0;
  // TX Display defaults — те же что у RX (см. DefaultGlobal)
  T.TXFFTSize       := 16384;    // меньше чем RX — TX-полоса узкая, FFT не нужен большой
  T.TXWindowType    := 2;        // Hann
  T.TXSpecDetector  := 0;        // Peak
  T.TXSpecAvgMode   := 3;        // Log Recursive
  T.TXSpecAvgTimeMS := 30.0;
  T.TXWfDetector    := 0;
  T.TXWfAvgMode     := 3;
  T.TXWfAvgTimeMS   := 120.0;
  // TX Grid — потолок 0 dBm (TX-уровни выше RX), 80 dB динамики, шаг 10 dB
  T.TXSpecRefLevel  := 0.0;
  T.TXSpecRange     := 80.0;
  T.TXSpecGridStep  := 10.0;
  // PureSignal — значения по умолчанию для протокола 2.
  T.PSMoxDelay      := 0.2;
  T.PSLoopDelay     := 0.0;
  T.PSTXDelayNs     := 150;
  T.PSRelaxPtol     := False;
  T.PSAutoAtten     := True;
  T.PSPin           := True;
  T.PSMap           := True;
  T.PSStabilize     := False;
  T.PSIntsIdx       := 0;       // 16 ints / 256 spi
  T.PSHWPeak        := 0.2899;
  T.PSTTFreq1       := 700;
  T.PSTTFreq2       := 1900;
  T.PSFBTarget      := 0;       // авто по плате
  T.PSOutlierSigma  := -1.0;    // авто по плате
end;

function TSettingsManager.LoadTX(const MAC: array of Byte;
                                  out T: TTXSettings): Boolean;
var MacStr: string; DevObj, O: TJSONObject; i: Integer;
begin
  DefaultTX(T);
  MacStr := MacToStr(MAC);
  Result := FRoot.Find(MacStr) <> nil;
  if not Result then Exit;
  DevObj := GetDevObj(MacStr);
  // Если секции "tx" ещё нет — оставляем дефолты, не материализуем пустую секцию
  if DevObj.Find('tx') = nil then Exit;
  O := EnsureObj(DevObj, 'tx');

  T.MicLineIn      := JB(O,'mic_line_in',     T.MicLineIn);
  T.MicBoost       := JB(O,'mic_boost',       T.MicBoost);
  T.MicPTTEnabled  := JB(O,'mic_ptt_enabled', T.MicPTTEnabled);
  T.MicTipRing     := JB(O,'mic_tip_ring',    T.MicTipRing);
  T.MicBias        := JB(O,'mic_bias',        T.MicBias);
  T.LineInGainDB   := EnsureRange(JD(O,'line_in_gain_db', T.LineInGainDB), -34.5, 12.0);
  T.MicGainDB      := JD(O,'mic_gain_db',     T.MicGainDB);
  T.AttOnTX        := EnsureRange(JI(O,'att_on_tx', T.AttOnTX), 0, 31);
  T.TUNLevel       := EnsureRange(JI(O,'tun_level', T.TUNLevel), 0, 100);
  T.TUNFreq        := EnsureRange(JI(O,'tun_freq',  T.TUNFreq),  100, 5000);
  T.FilterLow      := JI(O,'filter_low',      T.FilterLow);
  T.FilterHigh     := JI(O,'filter_high',     T.FilterHigh);
  T.FilterNC       := JI(O,'filter_nc',       T.FilterNC);
  T.FilterMP       := JI(O,'filter_mp',       T.FilterMP);
  T.FilterWindow   := JI(O,'filter_window',   T.FilterWindow);
  T.CompressorOn   := JB(O,'compressor_on',   T.CompressorOn);
  T.CompressorGain := JD(O,'compressor_gain', T.CompressorGain);
  T.LevelerOn      := JB(O,'leveler_on',      T.LevelerOn);
  T.LevelerTop     := JD(O,'leveler_top',     T.LevelerTop);
  T.LevelerDecay   := JI(O,'leveler_decay',   T.LevelerDecay);
  T.ALCOn          := JB(O,'alc_on',          T.ALCOn);
  T.ALCMaxGain     := JD(O,'alc_max_gain',    T.ALCMaxGain);
  T.ALCDecay       := JI(O,'alc_decay',       T.ALCDecay);
  T.PhaseRotOn     := JB(O,'phase_rot_on',    T.PhaseRotOn);
  T.PhaseRotStages := JI(O,'phase_rot_stages',T.PhaseRotStages);
  T.PhaseRotFreq   := JD(O,'phase_rot_freq',  T.PhaseRotFreq);
  T.EQOn           := JB(O,'eq_on',           T.EQOn);
  T.EQNumBands     := JI(O,'eq_num_bands',    T.EQNumBands);
  for i := 0 to 10 do
  begin
    T.EQGains[i] := JD(O,'eq_g_'+IntToStr(i), T.EQGains[i]);
    T.EQFreqs[i] := JD(O,'eq_f_'+IntToStr(i), T.EQFreqs[i]);
  end;
  T.AMCarrierLevel := JD(O,'am_carrier_level',T.AMCarrierLevel);
  T.FMDeviation    := JD(O,'fm_deviation',    T.FMDeviation);
  T.FMLowCut       := JI(O,'fm_low_cut',      T.FMLowCut);
  T.FMHighCut      := JI(O,'fm_high_cut',     T.FMHighCut);
  T.FMEmphPosition := JI(O,'fm_emph_position',T.FMEmphPosition);
  T.CTCSSOn        := JB(O,'ctcss_on',        T.CTCSSOn);
  T.CTCSSFreq      := JD(O,'ctcss_freq',      T.CTCSSFreq);
  T.TXFFTSize       := JI(O,'tx_fft_size',       T.TXFFTSize);
  T.TXWindowType    := JI(O,'tx_win_type',       T.TXWindowType);
  T.TXSpecDetector  := JI(O,'tx_spec_detector',  T.TXSpecDetector);
  T.TXSpecAvgMode   := JI(O,'tx_spec_avg_mode',  T.TXSpecAvgMode);
  T.TXSpecAvgTimeMS := JD(O,'tx_spec_avg_time_ms', T.TXSpecAvgTimeMS);
  T.TXWfDetector    := JI(O,'tx_wf_detector',    T.TXWfDetector);
  T.TXWfAvgMode     := JI(O,'tx_wf_avg_mode',    T.TXWfAvgMode);
  T.TXWfAvgTimeMS   := JD(O,'tx_wf_avg_time_ms', T.TXWfAvgTimeMS);
  T.TXSpecRefLevel  := JD(O,'tx_spec_ref_level', T.TXSpecRefLevel);
  T.TXSpecRange     := JD(O,'tx_spec_range',     T.TXSpecRange);
  T.TXSpecGridStep  := JD(O,'tx_spec_grid_step', T.TXSpecGridStep);
  // PureSignal
  T.PSMoxDelay      := JD(O,'ps_mox_delay',    T.PSMoxDelay);
  T.PSLoopDelay     := JD(O,'ps_loop_delay',   T.PSLoopDelay);
  T.PSTXDelayNs     := JI(O,'ps_tx_delay_ns',  T.PSTXDelayNs);
  T.PSRelaxPtol     := JB(O,'ps_relax_ptol',   T.PSRelaxPtol);
  T.PSAutoAtten     := JB(O,'ps_auto_atten',   T.PSAutoAtten);
  T.PSPin           := JB(O,'ps_pin',          T.PSPin);
  T.PSMap           := JB(O,'ps_map',          T.PSMap);
  T.PSStabilize     := JB(O,'ps_stabilize',    T.PSStabilize);
  T.PSIntsIdx       := EnsureRange(JI(O,'ps_ints_idx', T.PSIntsIdx), 0, 2);
  T.PSHWPeak        := JD(O,'ps_hw_peak',      T.PSHWPeak);
  T.PSTTFreq1       := JI(O,'ps_tt_freq1',     T.PSTTFreq1);
  T.PSTTFreq2       := JI(O,'ps_tt_freq2',     T.PSTTFreq2);
  T.PSFBTarget      := EnsureRange(JI(O,'ps_fb_target', T.PSFBTarget), 0, 256);
  T.PSOutlierSigma  := JD(O,'ps_outlier_sigma', T.PSOutlierSigma);
end;

class procedure TSettingsManager.DefaultCW(out C: TCWSettings);
begin
  FillChar(C, SizeOf(C), 0);
  C.Pitch           := 600;    // default CW pitch
  C.FWKeyer         := True;   // тайминги в FPGA — джиттер PC не влияет
  C.KeyerMode       := 2;      // Iambic B
  C.ReverseKeys     := False;
  C.StrictSpacing   := False;
  C.Speed           := 20;
  C.Weight          := 50;
  C.BreakIn         := True;
  C.HangTimeMS      := 300;
  C.RampMS          := 9;      // default CW edge length
  C.RFDelayMS       := 0;
  C.SidetoneHW      := True;
  C.SidetoneHWLevel := 50;
  C.SidetoneSW      := False;
  C.SidetoneSWLevel := 30;
  C.LocalKeyer      := False;  // у openHPSDR тайминги в FPGA; Pluto включит сам
  C.CarrierOffsetHz := 0;      // 0 = авто (по типу бэкенда)
  C.KeyPortEnabled  := False;
  C.KeyPort         := '';
  C.KeyDotLine      := 0;      // CTS
  C.KeyDashLine     := 1;      // DSR
  C.KeyInvert       := False;
  C.KeyPowerDTR     := True;   // типовая распайка: ключ питается от DTR/RTS
  C.KeyPowerRTS     := True;
  C.Decoder         := True;   // окно терминала без декодера бессмысленно
  C.DecoderStrip    := False;  // лента под спектром — по желанию
  C.KbdKey          := False;  // Ctrl-манипулятор включают осознанно
  // Заводские заготовки: типовой вызов, ответ и стандартные концовки.
  C.Messages[0] := 'CQ CQ DE ';
  C.Messages[1] := 'DE ';
  C.Messages[2] := 'UR RST 599 599 ';
  C.Messages[3] := 'TU 73 GL';
  C.Messages[4] := '?';
  C.Messages[5] := 'AGN';
  C.Messages[6] := 'QRZ?';
  C.Messages[7] := 'TEST';
end;

function TSettingsManager.LoadCW(const MAC: array of Byte;
                                  out C: TCWSettings): Boolean;
var MacStr: string; DevObj, O: TJSONObject; i: Integer;
begin
  DefaultCW(C);
  MacStr := MacToStr(MAC);
  Result := FRoot.Find(MacStr) <> nil;
  if not Result then Exit;
  DevObj := GetDevObj(MacStr);
  if DevObj.Find('cw') = nil then Exit;   // нет секции — остаются дефолты
  O := EnsureObj(DevObj, 'cw');
  C.Pitch           := EnsureRange(JI(O,'pitch',        C.Pitch),        200, 1200);
  C.FWKeyer         := JB(O,'fw_keyer',      C.FWKeyer);
  C.KeyerMode       := EnsureRange(JI(O,'keyer_mode',   C.KeyerMode),    0, 2);
  C.ReverseKeys     := JB(O,'reverse_keys',  C.ReverseKeys);
  C.StrictSpacing   := JB(O,'strict_spacing',C.StrictSpacing);
  C.Speed           := EnsureRange(JI(O,'speed',        C.Speed),        5, 60);
  C.Weight          := EnsureRange(JI(O,'weight',       C.Weight),       33, 66);
  C.BreakIn         := JB(O,'break_in',      C.BreakIn);
  C.HangTimeMS      := EnsureRange(JI(O,'hang_time_ms', C.HangTimeMS),   0, 2000);
  C.RampMS          := EnsureRange(JI(O,'ramp_ms',      C.RampMS),       0, 20);
  C.RFDelayMS       := EnsureRange(JI(O,'rf_delay_ms',  C.RFDelayMS),    0, 255);
  C.SidetoneHW      := JB(O,'sidetone_hw',   C.SidetoneHW);
  C.SidetoneHWLevel := EnsureRange(JI(O,'sidetone_hw_level', C.SidetoneHWLevel), 0, 127);
  C.SidetoneSW      := JB(O,'sidetone_sw',   C.SidetoneSW);
  C.SidetoneSWLevel := EnsureRange(JI(O,'sidetone_sw_level', C.SidetoneSWLevel), 0, 100);
  C.LocalKeyer      := JB(O,'local_keyer',   C.LocalKeyer);
  C.CarrierOffsetHz := EnsureRange(JI(O,'carrier_offset_hz', C.CarrierOffsetHz), -1, 48000);
  C.KeyPortEnabled  := JB(O,'key_port_on',   C.KeyPortEnabled);
  C.KeyPort         := Copy(JS(O,'key_port', C.KeyPort), 1, 63);
  C.KeyDotLine      := EnsureRange(JI(O,'key_dot_line',  C.KeyDotLine),  0, 3);
  C.KeyDashLine     := EnsureRange(JI(O,'key_dash_line', C.KeyDashLine), 0, 3);
  C.KeyInvert       := JB(O,'key_invert',    C.KeyInvert);
  C.KeyPowerDTR     := JB(O,'key_power_dtr', C.KeyPowerDTR);
  C.KeyPowerRTS     := JB(O,'key_power_rts', C.KeyPowerRTS);
  C.Decoder         := JB(O,'decoder',       C.Decoder);
  C.DecoderStrip    := JB(O,'decoder_strip', C.DecoderStrip);
  C.KbdKey          := JB(O,'kbd_key',       C.KbdKey);
  for i := 0 to CW_MSG_COUNT - 1 do
    C.Messages[i] := Copy(JS(O, 'msg_' + IntToStr(i), C.Messages[i]), 1, 63);
end;

procedure TSettingsManager.SaveCW(const MAC: array of Byte;
                                   const C: TCWSettings);
var O: TJSONObject; i: Integer;
begin
  O := EnsureObj(GetDevObj(MacToStr(MAC)), 'cw');
  JW(O,'pitch',             C.Pitch);
  JW(O,'fw_keyer',          C.FWKeyer);
  JW(O,'keyer_mode',        C.KeyerMode);
  JW(O,'reverse_keys',      C.ReverseKeys);
  JW(O,'strict_spacing',    C.StrictSpacing);
  JW(O,'speed',             C.Speed);
  JW(O,'weight',            C.Weight);
  JW(O,'break_in',          C.BreakIn);
  JW(O,'hang_time_ms',      C.HangTimeMS);
  JW(O,'ramp_ms',           C.RampMS);
  JW(O,'rf_delay_ms',       C.RFDelayMS);
  JW(O,'sidetone_hw',       C.SidetoneHW);
  JW(O,'sidetone_hw_level', C.SidetoneHWLevel);
  JW(O,'sidetone_sw',       C.SidetoneSW);
  JW(O,'sidetone_sw_level', C.SidetoneSWLevel);
  JW(O,'local_keyer',       C.LocalKeyer);
  JW(O,'carrier_offset_hz', C.CarrierOffsetHz);
  JW(O,'key_port_on',       C.KeyPortEnabled);
  JWS(O,'key_port',         C.KeyPort);
  JW(O,'key_dot_line',      C.KeyDotLine);
  JW(O,'key_dash_line',     C.KeyDashLine);
  JW(O,'key_invert',        C.KeyInvert);
  JW(O,'key_power_dtr',     C.KeyPowerDTR);
  JW(O,'key_power_rts',     C.KeyPowerRTS);
  JW(O,'decoder',           C.Decoder);
  JW(O,'decoder_strip',     C.DecoderStrip);
  JW(O,'kbd_key',           C.KbdKey);
  for i := 0 to CW_MSG_COUNT - 1 do
    JWS(O, 'msg_' + IntToStr(i), C.Messages[i]);
end;

procedure TSettingsManager.SaveTX(const MAC: array of Byte;
                                   const T: TTXSettings);
var O: TJSONObject; i: Integer;
begin
  O := EnsureObj(GetDevObj(MacToStr(MAC)), 'tx');
  JW(O,'mic_line_in',      T.MicLineIn);
  JW(O,'mic_boost',        T.MicBoost);
  JW(O,'mic_ptt_enabled',  T.MicPTTEnabled);
  JW(O,'mic_tip_ring',     T.MicTipRing);
  JW(O,'mic_bias',         T.MicBias);
  JW(O,'line_in_gain_db',  T.LineInGainDB);
  JW(O,'mic_gain_db',      T.MicGainDB);
  JW(O,'att_on_tx',       T.AttOnTX);
  JW(O,'tun_level',       T.TUNLevel);
  JW(O,'tun_freq',        T.TUNFreq);
  JW(O,'filter_low',      T.FilterLow);
  JW(O,'filter_high',     T.FilterHigh);
  JW(O,'filter_nc',       T.FilterNC);
  JW(O,'filter_mp',       T.FilterMP);
  JW(O,'filter_window',   T.FilterWindow);
  JW(O,'compressor_on',   T.CompressorOn);
  JW(O,'compressor_gain', T.CompressorGain);
  JW(O,'leveler_on',      T.LevelerOn);
  JW(O,'leveler_top',     T.LevelerTop);
  JW(O,'leveler_decay',   T.LevelerDecay);
  JW(O,'alc_on',          T.ALCOn);
  JW(O,'alc_max_gain',    T.ALCMaxGain);
  JW(O,'alc_decay',       T.ALCDecay);
  JW(O,'phase_rot_on',    T.PhaseRotOn);
  JW(O,'phase_rot_stages',T.PhaseRotStages);
  JW(O,'phase_rot_freq',  T.PhaseRotFreq);
  JW(O,'eq_on',           T.EQOn);
  JW(O,'eq_num_bands',    T.EQNumBands);
  for i := 0 to 10 do
  begin
    JW(O,'eq_g_'+IntToStr(i), T.EQGains[i]);
    JW(O,'eq_f_'+IntToStr(i), T.EQFreqs[i]);
  end;
  JW(O,'am_carrier_level',T.AMCarrierLevel);
  JW(O,'fm_deviation',    T.FMDeviation);
  JW(O,'fm_low_cut',      T.FMLowCut);
  JW(O,'fm_high_cut',     T.FMHighCut);
  JW(O,'fm_emph_position',T.FMEmphPosition);
  JW(O,'ctcss_on',        T.CTCSSOn);
  JW(O,'ctcss_freq',      T.CTCSSFreq);
  JW(O,'tx_fft_size',       T.TXFFTSize);
  JW(O,'tx_win_type',       T.TXWindowType);
  JW(O,'tx_spec_detector',  T.TXSpecDetector);
  JW(O,'tx_spec_avg_mode',  T.TXSpecAvgMode);
  JW(O,'tx_spec_avg_time_ms', T.TXSpecAvgTimeMS);
  JW(O,'tx_wf_detector',    T.TXWfDetector);
  JW(O,'tx_wf_avg_mode',    T.TXWfAvgMode);
  JW(O,'tx_wf_avg_time_ms', T.TXWfAvgTimeMS);
  JW(O,'tx_spec_ref_level', T.TXSpecRefLevel);
  JW(O,'tx_spec_range',     T.TXSpecRange);
  JW(O,'tx_spec_grid_step', T.TXSpecGridStep);
  // PureSignal
  JW(O,'ps_mox_delay',    T.PSMoxDelay);
  JW(O,'ps_loop_delay',   T.PSLoopDelay);
  JW(O,'ps_tx_delay_ns',  T.PSTXDelayNs);
  JW(O,'ps_relax_ptol',   T.PSRelaxPtol);
  JW(O,'ps_auto_atten',   T.PSAutoAtten);
  JW(O,'ps_pin',          T.PSPin);
  JW(O,'ps_map',          T.PSMap);
  JW(O,'ps_stabilize',    T.PSStabilize);
  JW(O,'ps_ints_idx',     T.PSIntsIdx);
  JW(O,'ps_hw_peak',      T.PSHWPeak);
  JW(O,'ps_tt_freq1',     T.PSTTFreq1);
  JW(O,'ps_tt_freq2',     T.PSTTFreq2);
  JW(O,'ps_fb_target',    T.PSFBTarget);
  JW(O,'ps_outlier_sigma',T.PSOutlierSigma);
end;

// ---------------------------------------------------------------------------
// TX-профили: снимок/применение + заводской набор + персист
// ---------------------------------------------------------------------------

procedure TXProfileCapture(const T: TTXSettings; DrivePct: Integer; var P: TTXProfile);
var i: Integer;
begin
  P.MicLineIn      := T.MicLineIn;
  P.MicBoost       := T.MicBoost;
  P.MicPTTEnabled  := T.MicPTTEnabled;
  P.MicTipRing     := T.MicTipRing;
  P.MicBias        := T.MicBias;
  P.LineInGainDB   := T.LineInGainDB;
  P.MicGainDB      := T.MicGainDB;
  P.FilterLow      := T.FilterLow;
  P.FilterHigh     := T.FilterHigh;
  P.CompressorOn   := T.CompressorOn;
  P.CompressorGain := T.CompressorGain;
  P.LevelerOn      := T.LevelerOn;
  P.LevelerTop     := T.LevelerTop;
  P.LevelerDecay   := T.LevelerDecay;
  P.ALCOn          := T.ALCOn;
  P.ALCMaxGain     := T.ALCMaxGain;
  P.ALCDecay       := T.ALCDecay;
  P.PhaseRotOn     := T.PhaseRotOn;
  P.PhaseRotStages := T.PhaseRotStages;
  P.PhaseRotFreq   := T.PhaseRotFreq;
  P.EQOn           := T.EQOn;
  P.EQNumBands     := T.EQNumBands;
  for i := 0 to 10 do
  begin
    P.EQGains[i] := T.EQGains[i];
    P.EQFreqs[i] := T.EQFreqs[i];
  end;
  P.AMCarrierLevel := T.AMCarrierLevel;
  P.DrivePercent   := EnsureRange(DrivePct, 0, 100);
  P.TUNLevel       := EnsureRange(T.TUNLevel, 0, 100);
end;

procedure TXProfileApply(const P: TTXProfile; var T: TTXSettings; var DrivePct: Integer);
var i: Integer;
begin
  T.MicLineIn      := P.MicLineIn;
  T.MicBoost       := P.MicBoost;
  T.MicPTTEnabled  := P.MicPTTEnabled;
  T.MicTipRing     := P.MicTipRing;
  T.MicBias        := P.MicBias;
  T.LineInGainDB   := EnsureRange(P.LineInGainDB, -34.5, 12.0);
  T.MicGainDB      := P.MicGainDB;
  T.FilterLow      := P.FilterLow;
  T.FilterHigh     := P.FilterHigh;
  T.CompressorOn   := P.CompressorOn;
  T.CompressorGain := P.CompressorGain;
  T.LevelerOn      := P.LevelerOn;
  T.LevelerTop     := P.LevelerTop;
  T.LevelerDecay   := P.LevelerDecay;
  T.ALCOn          := P.ALCOn;
  T.ALCMaxGain     := P.ALCMaxGain;
  T.ALCDecay       := P.ALCDecay;
  T.PhaseRotOn     := P.PhaseRotOn;
  T.PhaseRotStages := P.PhaseRotStages;
  T.PhaseRotFreq   := P.PhaseRotFreq;
  T.EQOn           := P.EQOn;
  T.EQNumBands     := P.EQNumBands;
  for i := 0 to 10 do
  begin
    T.EQGains[i] := P.EQGains[i];
    T.EQFreqs[i] := P.EQFreqs[i];
  end;
  T.AMCarrierLevel := P.AMCarrierLevel;
  T.TUNLevel       := EnsureRange(P.TUNLevel, 0, 100);
  DrivePct         := EnsureRange(P.DrivePercent, 0, 100);
end;

procedure ClearTXProfModeTable(var T: TTXProfModeTable);
var i: Integer;
begin
  for i := 0 to CFG_MODE_MAX do T[i] := -1;
end;

function ModeTableToJSON(const T: TTXProfModeTable): TJSONArray;
var i: Integer;
begin
  Result := TJSONArray.Create;
  for i := 0 to CFG_MODE_MAX do Result.Add(T[i]);
end;

procedure JSONToModeTable(Node: TJSONData; var T: TTXProfModeTable);
var
  Arr: TJSONArray;
  i, V: Integer;
begin
  ClearTXProfModeTable(T);
  if not (Node is TJSONArray) then Exit;
  Arr := TJSONArray(Node);
  for i := 0 to Min(CFG_MODE_MAX, Arr.Count - 1) do
  begin
    V := Arr.Integers[i];
    if (V >= 0) and (V < TXPROF_MAX) then T[i] := V;
  end;
end;

procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer;
                            out L: TTXProfileList);
var
  // Заводской звук + ЖИВАЯ обвязка микрофона. Всё, что определяет тембр
  // (полоса, динамика, EQ, AM carrier, TUN level), берётся из DefaultTX:
  // «сброс к заводским» обязан сбрасывать именно это, иначе Default наследует
  // ту полосу, на которой оператора застали (например 5000 от ESSB 5k).
  // Не сбрасываем то, что описывает ЖЕЛЕЗО и безопасность: разъём и режим
  // микрофона с его усилением (иначе слетит Line In у микшера) и мощность
  // (внезапный скачок drive пошёл бы в усилитель).
  Stock: TTXSettings;
const
  // Кривые EQ для заводских ESSB. Значения — УЗЛЫ ломаной АЧХ WDSP (eq.c),
  // а не «полосы»: между узлами интерполяция по дБ, а ЗА крайними узлами
  // ctfmode=0 обрывает АЧХ на нет. Отсюда железное правило, по которому
  // подобраны частоты: нижний узел ≤ FilterLow, верхний ≥ FilterHigh, иначе
  // включённый EQ сам срежет полосу, ради которой ESSB и включают.
  // Форма: срез бубнения снизу, ровная середина, мягкий подъём присутствия
  // к верху. Преамп в минус — EQ стоит ДО компрессора и ALC, и плюсовые
  // гейны иначе просто загоняют ALC в ограничение.
  ESSB35_F: array[1..10] of Double = (100, 160, 250, 400, 700, 1200, 1800, 2500, 3100, 3600);
  ESSB35_G: array[1..10] of Double = ( -6,  -4,  -2,    0,   0,    1,    2,    3,    4,    4);
  ESSB35_PRE = -2.0;
  ESSB50_F: array[1..10] of Double = (100, 180, 300, 550, 950, 1600, 2400, 3300, 4200, 5100);
  ESSB50_G: array[1..10] of Double = ( -5,  -3,  -1,    0,   0,    1,    2,  3.5,    5,    5);
  ESSB50_PRE = -3.0;
  // FM: узлы охватывают AF-полосу модулятора (штатно 300..3000, вкладка
  // Hardware), крайние с запасом — за ними EQ обрывает АЧХ. Верх НЕ поднимаем:
  // предыскажение (pre-emphasis) само задирает его 6 дБ/окт и по умолчанию
  // стоит ПОСЛЕ динамики (xemphp вторым, сразу перед xfmmod), то есть
  // компрессор его уже не удержит — лишний буст ушёл бы в перемодуляцию на
  // свистящих. Снизу режем бубнение, лёгкий подъём разборчивости 1.4..2.3 кГц.
  FM_F: array[1..10] of Double = (200, 300, 450, 700, 1000, 1400, 1800, 2300, 2800, 3400);
  FM_G: array[1..10] of Double = ( -8,  -4,  -1,   0,    0,    1,    2,    2,    1,   -2);
  FM_PRE = -1.0;

  // Тембр у заводского профиля — заводской: EQ выключен, все усиления и
  // преамп в нуле, сетка узлов штатная. Наследовать сюда живой EQ нельзя —
  // иначе «сброс к заводским» не сбрасывал бы то, ради чего его жмут.
  procedure FlatEQ(var P: TTXProfile);
  var i: Integer;
  begin
    P.EQOn       := False;
    P.EQNumBands := 10;
    for i := 0 to 10 do
    begin
      P.EQGains[i] := 0.0;
      P.EQFreqs[i] := DEF_EQ_FREQS[i];
    end;
  end;

  procedure Add(const AName: string; ALow, AHigh: Integer;
                AComp: Boolean; ACompGain: Double);
  begin
    if L.Count >= TXPROF_MAX then Exit;
    TXProfileCapture(Stock, DrivePct, L.Items[L.Count]);
    L.Items[L.Count].Name           := AName;
    L.Items[L.Count].FilterLow      := ALow;
    L.Items[L.Count].FilterHigh     := AHigh;
    L.Items[L.Count].CompressorOn   := AComp;
    L.Items[L.Count].CompressorGain := ACompGain;
    FlatEQ(L.Items[L.Count]);
    Inc(L.Count);
  end;

  // То же плюс своя кривая EQ: у ESSB тембр — часть профиля, наследовать
  // сетку 100..3500 от текущих настроек здесь нельзя (см. правило выше).
  procedure AddEQ(const AName: string; ALow, AHigh: Integer;
                  AComp: Boolean; ACompGain: Double;
                  const AF, AG: array of Double; APreamp: Double);
  var
    i, Before: Integer;
  begin
    Before := L.Count;
    Add(AName, ALow, AHigh, AComp, ACompGain);
    if L.Count = Before then Exit;      // упёрлись в TXPROF_MAX — ничего не добавлено
    with L.Items[L.Count - 1] do
    begin
      EQOn       := True;
      EQNumBands := 10;
      EQFreqs[0] := 0.0;                // слот преампа — частота не используется
      EQGains[0] := APreamp;
      for i := 1 to 10 do
      begin
        EQFreqs[i] := AF[i - 1];
        EQGains[i] := AG[i - 1];
      end;
    end;
  end;

begin
  FillChar(L, SizeOf(L), 0);
  L.Count     := 0;
  L.ActiveIdx := 0;
  ClearTXProfModeTable(L.MainByMode);   // главный ничего не переключает
  // База всех заводских профилей: заводской звук, живая обвязка микрофона.
  TSettingsManager.DefaultTX(Stock);
  Stock.MicLineIn     := Base.MicLineIn;
  Stock.MicBoost      := Base.MicBoost;
  Stock.MicBias       := Base.MicBias;
  Stock.MicPTTEnabled := Base.MicPTTEnabled;
  Stock.MicTipRing    := Base.MicTipRing;
  Stock.LineInGainDB  := Base.LineInGainDB;
  Stock.MicGainDB     := Base.MicGainDB;
  // «Default» — ровно заводской звук: полоса 200..3100, компрессор выкл,
  // leveler/ALC как в DefaultTX, EQ ровный.
  TXProfileCapture(Stock, DrivePct, L.Items[0]);
  L.Items[0].Name := 'Default';
  FlatEQ(L.Items[0]);
  L.Count := 1;
  // SSB-профили отличаются от Default только полосой и компрессором;
  // микрофонный вход и мощность наследуются, EQ у всех троих заводской-ровный.
  // ESSB несут ещё и свою кривую: без неё «широкий» профиль звучит тем же
  // телефонным тембром, а с чужой сеткой (верхний узел 3500) EQ обрезал бы
  // верх ESSB 5k.
  Add('SSB DX',    300, 2700, True,  8.0);
  Add('SSB Wide',  200, 3100, False, 5.0);
  AddEQ('ESSB 3.5k', 100, 3500, False, 5.0, ESSB35_F, ESSB35_G, ESSB35_PRE);
  AddEQ('ESSB 5k',   100, 5000, False, 5.0, ESSB50_F, ESSB50_G, ESSB50_PRE);
  // FM: плотная модуляция за счёт компрессора — девиация ограничена жёстко,
  // поэтому громкость даёт средний уровень, а не пики. Полоса 300..3000 здесь
  // косметическая: в FM края bp0 считает сам движок по Карсону (девиация +
  // AF-срез), профильные значения работают, только если привязать этот профиль
  // к голосовому режиму. Девиация и AF-срез — свойства аппарата (вкладка
  // Hardware), профилем не переключаются.
  AddEQ('FM',        300, 3000, True,  8.0, FM_F, FM_G, FM_PRE);
end;

function TSettingsManager.LoadTXProfiles(const MAC: array of Byte;
                                          out L: TTXProfileList): Boolean;
var
  MacStr: string;
  DevObj, Root, O: TJSONObject;
  Node: TJSONData;
  Arr: TJSONArray;
  BaseTX: TTXSettings;
  DefP: TTXProfile;
  i, j: Integer;
begin
  FillChar(L, SizeOf(L), 0);
  L.Count     := 0;
  L.ActiveIdx := 0;
  ClearTXProfModeTable(L.MainByMode);
  Result := False;
  MacStr := MacToStr(MAC);
  if FRoot.Find(MacStr) = nil then Exit;
  DevObj := GetDevObj(MacStr);
  if DevObj.Find('tx_profiles') = nil then Exit;
  Root := EnsureObj(DevObj, 'tx_profiles');
  Node := Root.Find('list');
  if not (Node is TJSONArray) then Exit;
  Arr := TJSONArray(Node);
  // Заводской профиль как источник значений по умолчанию для отсутствующих
  // ключей: конфиг, записанный старой версией, дочитывается без дыр.
  DefaultTX(BaseTX);
  TXProfileCapture(BaseTX, 50, DefP);
  DefP.Name := 'Profile';
  for i := 0 to Arr.Count - 1 do
  begin
    if L.Count >= TXPROF_MAX then Break;
    if not (Arr.Items[i] is TJSONObject) then Continue;
    O := TJSONObject(Arr.Items[i]);
    L.Items[L.Count] := DefP;
    with L.Items[L.Count] do
    begin
      Name           := Copy(JS(O,'name', DefP.Name), 1, 31);
      MicLineIn      := JB(O,'mic_line_in',     MicLineIn);
      MicBoost       := JB(O,'mic_boost',       MicBoost);
      MicPTTEnabled  := JB(O,'mic_ptt',         MicPTTEnabled);
      MicTipRing     := JB(O,'mic_tip_ring',    MicTipRing);
      MicBias        := JB(O,'mic_bias',        MicBias);
      LineInGainDB   := EnsureRange(JD(O,'line_in_gain_db', LineInGainDB), -34.5, 12.0);
      MicGainDB      := JD(O,'mic_gain_db',     MicGainDB);
      FilterLow      := JI(O,'filter_low',      FilterLow);
      FilterHigh     := JI(O,'filter_high',     FilterHigh);
      CompressorOn   := JB(O,'comp_on',         CompressorOn);
      CompressorGain := JD(O,'comp_gain',       CompressorGain);
      LevelerOn      := JB(O,'lev_on',          LevelerOn);
      LevelerTop     := JD(O,'lev_top',         LevelerTop);
      LevelerDecay   := JI(O,'lev_decay',       LevelerDecay);
      ALCOn          := JB(O,'alc_on',          ALCOn);
      ALCMaxGain     := JD(O,'alc_max_gain',    ALCMaxGain);
      ALCDecay       := JI(O,'alc_decay',       ALCDecay);
      PhaseRotOn     := JB(O,'phr_on',          PhaseRotOn);
      PhaseRotStages := JI(O,'phr_stages',      PhaseRotStages);
      PhaseRotFreq   := JD(O,'phr_freq',        PhaseRotFreq);
      EQOn           := JB(O,'eq_on',           EQOn);
      EQNumBands     := JI(O,'eq_bands',        EQNumBands);
      for j := 0 to 10 do
      begin
        EQGains[j] := JD(O,'eq_g_'+IntToStr(j), EQGains[j]);
        EQFreqs[j] := JD(O,'eq_f_'+IntToStr(j), EQFreqs[j]);
      end;
      AMCarrierLevel := JD(O,'am_carrier',      AMCarrierLevel);
      DrivePercent   := EnsureRange(JI(O,'drive',     DrivePercent), 0, 100);
      TUNLevel       := EnsureRange(JI(O,'tun_level', TUNLevel),     0, 100);
      if Name = '' then Name := 'Profile ' + IntToStr(L.Count + 1);
    end;
    Inc(L.Count);
  end;
  if L.Count = 0 then Exit;   // пустой список = как будто секции нет
  L.ActiveIdx := JI(Root, 'active', 0);
  if (L.ActiveIdx < 0) or (L.ActiveIdx >= L.Count) then L.ActiveIdx := 0;
  JSONToModeTable(Root.Find('main_prof_modes'), L.MainByMode);
  for i := 0 to CFG_MODE_MAX do
    if L.MainByMode[i] >= L.Count then L.MainByMode[i] := -1;
  Result := True;
end;

procedure TSettingsManager.SaveTXProfiles(const MAC: array of Byte;
                                           const L: TTXProfileList);
var
  Root, O: TJSONObject;
  Arr: TJSONArray;
  i, j, Idx: Integer;
begin
  Root := EnsureObj(GetDevObj(MacToStr(MAC)), 'tx_profiles');
  JW(Root, 'active', EnsureRange(L.ActiveIdx, 0, Max(0, L.Count - 1)));
  Idx := Root.IndexOfName('main_prof_modes');
  if Idx >= 0 then Root.Delete(Idx);
  Root.Add('main_prof_modes', ModeTableToJSON(L.MainByMode));
  // Список пересоздаётся целиком: удаление профиля не должно оставлять хвост.
  Idx := Root.IndexOfName('list');
  if Idx >= 0 then Root.Delete(Idx);
  Arr := TJSONArray.Create;
  Root.Add('list', Arr);
  for i := 0 to L.Count - 1 do
  begin
    O := TJSONObject.Create;
    Arr.Add(O);
    JWS(O,'name',           L.Items[i].Name);
    JW(O,'mic_line_in',     L.Items[i].MicLineIn);
    JW(O,'mic_boost',       L.Items[i].MicBoost);
    JW(O,'mic_ptt',         L.Items[i].MicPTTEnabled);
    JW(O,'mic_tip_ring',    L.Items[i].MicTipRing);
    JW(O,'mic_bias',        L.Items[i].MicBias);
    JW(O,'line_in_gain_db', L.Items[i].LineInGainDB);
    JW(O,'mic_gain_db',     L.Items[i].MicGainDB);
    JW(O,'filter_low',      L.Items[i].FilterLow);
    JW(O,'filter_high',     L.Items[i].FilterHigh);
    JW(O,'comp_on',         L.Items[i].CompressorOn);
    JW(O,'comp_gain',       L.Items[i].CompressorGain);
    JW(O,'lev_on',          L.Items[i].LevelerOn);
    JW(O,'lev_top',         L.Items[i].LevelerTop);
    JW(O,'lev_decay',       L.Items[i].LevelerDecay);
    JW(O,'alc_on',          L.Items[i].ALCOn);
    JW(O,'alc_max_gain',    L.Items[i].ALCMaxGain);
    JW(O,'alc_decay',       L.Items[i].ALCDecay);
    JW(O,'phr_on',          L.Items[i].PhaseRotOn);
    JW(O,'phr_stages',      L.Items[i].PhaseRotStages);
    JW(O,'phr_freq',        L.Items[i].PhaseRotFreq);
    JW(O,'eq_on',           L.Items[i].EQOn);
    JW(O,'eq_bands',        L.Items[i].EQNumBands);
    for j := 0 to 10 do
    begin
      JW(O,'eq_g_'+IntToStr(j), L.Items[i].EQGains[j]);
      JW(O,'eq_f_'+IntToStr(j), L.Items[i].EQFreqs[j]);
    end;
    JW(O,'am_carrier',      L.Items[i].AMCarrierLevel);
    JW(O,'drive',           L.Items[i].DrivePercent);
    JW(O,'tun_level',       L.Items[i].TUNLevel);
  end;
end;

class procedure TSettingsManager.DefaultAlex(out A: TAlexSettings);
var i: Integer;
begin
  FillChar(A, SizeOf(A), 0);
  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    A.RxAnt[i]   := 1;     // ANT1 — безопасный дефолт
    A.TxAnt[i]   := 1;     // ANT1
    A.RxOnly[i]  := 0;     // вторичный вход выключен
    A.DoNotTx[i] := False;
  end;
  A.RxBypassOnTx := False;
  A.Ext1OnTx     := False;
  A.GndBpf2OnTx  := False;
  A.EnableXvtrHf := False;
end;

function TSettingsManager.LoadAlex(const MAC: array of Byte;
                                    out A: TAlexSettings): Boolean;
var MacStr: string; DevObj, O: TJSONObject; i: Integer;
begin
  DefaultAlex(A);
  MacStr := MacToStr(MAC);
  Result := FRoot.Find(MacStr) <> nil;
  if not Result then Exit;
  DevObj := GetDevObj(MacStr);
  if DevObj.Find('alex') = nil then Exit;
  O := EnsureObj(DevObj, 'alex');
  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    A.RxAnt[i]   := EnsureRange(JI(O, 'rx_ant_'    + IntToStr(i), 1), 1, 3);
    A.TxAnt[i]   := EnsureRange(JI(O, 'tx_ant_'    + IntToStr(i), 1), 1, 3);
    A.RxOnly[i]  := EnsureRange(JI(O, 'rx_only_'   + IntToStr(i), 0), 0, 3);
    A.DoNotTx[i] := JB(O, 'do_not_tx_' + IntToStr(i), False);
  end;
  A.RxBypassOnTx := JB(O, 'rx_bypass_on_tx', False);
  A.Ext1OnTx     := JB(O, 'ext1_on_tx',      False);
  A.GndBpf2OnTx  := JB(O, 'gnd_bpf2_on_tx',  False);
  A.EnableXvtrHf := JB(O, 'enable_xvtr_hf',   False);
end;

class procedure TSettingsManager.DefaultOC(out O: TOCSettings);
var i: Integer;
begin
  FillChar(O, SizeOf(O), 0);
  O.Enabled := False;
  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    O.RxMaskHF[i] := 0;
    O.TxMaskHF[i] := 0;
  end;
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    O.RxMaskXvtr[i] := 0;
    O.TxMaskXvtr[i] := 0;
  end;
  for i := 0 to OC_PIN_COUNT - 1 do
  begin
    O.ActionHF[i]   := OCA_MOX_TUNE_TWOTONE;
    O.ActionXvtr[i] := OCA_MOX_TUNE_TWOTONE;
  end;
end;

function TSettingsManager.LoadOC(const MAC: array of Byte;
                                  out O: TOCSettings): Boolean;
var MacStr: string; DevObj, Obj: TJSONObject; i: Integer;
begin
  DefaultOC(O);
  MacStr := MacToStr(MAC);
  Result := FRoot.Find(MacStr) <> nil;
  if not Result then Exit;
  DevObj := GetDevObj(MacStr);
  if DevObj.Find('oc') = nil then begin Result := False; Exit; end;
  Obj := EnsureObj(DevObj, 'oc');
  O.Enabled := JB(Obj, 'enabled', False);
  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    O.RxMaskHF[i] := EnsureRange(JI(Obj, 'rx_hf_'   + IntToStr(i), 0), 0, 127);
    O.TxMaskHF[i] := EnsureRange(JI(Obj, 'tx_hf_'   + IntToStr(i), 0), 0, 127);
  end;
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    O.RxMaskXvtr[i] := EnsureRange(JI(Obj, 'rx_xvtr_' + IntToStr(i), 0), 0, 127);
    O.TxMaskXvtr[i] := EnsureRange(JI(Obj, 'tx_xvtr_' + IntToStr(i), 0), 0, 127);
  end;
  for i := 0 to OC_PIN_COUNT - 1 do
  begin
    O.ActionHF[i]   := EnsureRange(JI(Obj, 'action_hf_'   + IntToStr(i), OCA_MOX_TUNE_TWOTONE), 0, 6);
    O.ActionXvtr[i] := EnsureRange(JI(Obj, 'action_xvtr_' + IntToStr(i), OCA_MOX_TUNE_TWOTONE), 0, 6);
  end;
end;

procedure TSettingsManager.SaveOC(const MAC: array of Byte;
                                   const O: TOCSettings);
var Obj: TJSONObject; i: Integer;
begin
  Obj := EnsureObj(GetDevObj(MacToStr(MAC)), 'oc');
  JW(Obj, 'enabled', O.Enabled);
  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    JW(Obj, 'rx_hf_'   + IntToStr(i), Integer(O.RxMaskHF[i]));
    JW(Obj, 'tx_hf_'   + IntToStr(i), Integer(O.TxMaskHF[i]));
  end;
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    JW(Obj, 'rx_xvtr_' + IntToStr(i), Integer(O.RxMaskXvtr[i]));
    JW(Obj, 'tx_xvtr_' + IntToStr(i), Integer(O.TxMaskXvtr[i]));
  end;
  for i := 0 to OC_PIN_COUNT - 1 do
  begin
    JW(Obj, 'action_hf_'   + IntToStr(i), Integer(O.ActionHF[i]));
    JW(Obj, 'action_xvtr_' + IntToStr(i), Integer(O.ActionXvtr[i]));
  end;
end;

function TSettingsManager.LoadFilters(const MAC: array of Byte;
                                       out T: TFilterTable): Boolean;
var
  MacStr, K: string;
  DevObj, Root, Obj: TJSONObject;
  D: TJSONData;
  m, s: Integer;
begin
  DefaultFilterTable(T);
  MacStr := MacToStr(MAC);
  Result := FRoot.Find(MacStr) <> nil;
  if not Result then Exit;
  DevObj := GetDevObj(MacStr);
  if DevObj.Find('filters') = nil then begin Result := False; Exit; end;
  Root := EnsureObj(DevObj, 'filters');
  for m := 0 to CFG_MODE_MAX do
  begin
    D := Root.Find('m' + IntToStr(m));
    if (D = nil) or not (D is TJSONObject) then Continue;
    Obj := TJSONObject(D);
    for s := 0 to FILT_SLOTS - 1 do
    begin
      K := 's' + IntToStr(s) + '_';
      T[m][s].Name := JS(Obj, K + 'n',  T[m][s].Name);
      T[m][s].Lo   := JI(Obj, K + 'lo', T[m][s].Lo);
      T[m][s].Hi   := JI(Obj, K + 'hi', T[m][s].Hi);
      if T[m][s].Hi <= T[m][s].Lo then   // битый конфиг не должен глушить приём
      begin
        T[m][s].Lo := DefaultFilterSet(m)[s].Lo;
        T[m][s].Hi := DefaultFilterSet(m)[s].Hi;
      end;
    end;
  end;
end;

procedure TSettingsManager.SaveFilters(const MAC: array of Byte;
                                        const T: TFilterTable);
var
  K: string;
  Root, Obj: TJSONObject;
  Def: TFilterSet;
  m, s, Idx: Integer;
  Differs: Boolean;
begin
  Root := EnsureObj(GetDevObj(MacToStr(MAC)), 'filters');
  for m := 0 to CFG_MODE_MAX do
  begin
    Def := DefaultFilterSet(m);
    Differs := False;
    for s := 0 to FILT_SLOTS - 1 do
      if (T[m][s].Lo <> Def[s].Lo) or (T[m][s].Hi <> Def[s].Hi) or
         (T[m][s].Name <> Def[s].Name) then Differs := True;
    // Секцию режима всегда сносим целиком: иначе возврат слота к заводскому
    // оставил бы старый ключ и он всплыл бы при следующей загрузке.
    Idx := Root.IndexOfName('m' + IntToStr(m));
    if Idx >= 0 then Root.Delete(Idx);
    if not Differs then Continue;
    Obj := EnsureObj(Root, 'm' + IntToStr(m));
    for s := 0 to FILT_SLOTS - 1 do
    begin
      K := 's' + IntToStr(s) + '_';
      JWS(Obj, K + 'n',  T[m][s].Name);
      JW (Obj, K + 'lo', T[m][s].Lo);
      JW (Obj, K + 'hi', T[m][s].Hi);
    end;
  end;
end;

class procedure TSettingsManager.DefaultAnt936x(out A: TAD936xAnt);
var i: Integer;
begin
  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    A.RxAnt[i] := 0;   // RX1
    A.TxAnt[i] := 0;   // TX1
  end;
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    A.XvtrRxAnt[i] := 0;
    A.XvtrTxAnt[i] := 0;
  end;
end;

function TSettingsManager.LoadAnt936x(const MAC: array of Byte;
                                       out A: TAD936xAnt): Boolean;
var MacStr: string; DevObj, O: TJSONObject; i: Integer;
begin
  DefaultAnt936x(A);
  MacStr := MacToStr(MAC);
  Result := FRoot.Find(MacStr) <> nil;
  if not Result then Exit;
  DevObj := GetDevObj(MacStr);
  if DevObj.Find('ant936x') = nil then begin Result := False; Exit; end;
  O := EnsureObj(DevObj, 'ant936x');
  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    A.RxAnt[i] := EnsureRange(JI(O, 'rx_' + IntToStr(i), 0), 0, 1);
    A.TxAnt[i] := EnsureRange(JI(O, 'tx_' + IntToStr(i), 0), 0, 1);
  end;
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    A.XvtrRxAnt[i] := EnsureRange(JI(O, 'xvtr_rx_' + IntToStr(i), 0), 0, 1);
    A.XvtrTxAnt[i] := EnsureRange(JI(O, 'xvtr_tx_' + IntToStr(i), 0), 0, 1);
  end;
end;

procedure TSettingsManager.SaveAnt936x(const MAC: array of Byte;
                                        const A: TAD936xAnt);
var O: TJSONObject; i: Integer;
begin
  O := EnsureObj(GetDevObj(MacToStr(MAC)), 'ant936x');
  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    JW(O, 'rx_' + IntToStr(i), Integer(A.RxAnt[i]));
    JW(O, 'tx_' + IntToStr(i), Integer(A.TxAnt[i]));
  end;
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    JW(O, 'xvtr_rx_' + IntToStr(i), Integer(A.XvtrRxAnt[i]));
    JW(O, 'xvtr_tx_' + IntToStr(i), Integer(A.XvtrTxAnt[i]));
  end;
end;

procedure TSettingsManager.SaveAlex(const MAC: array of Byte;
                                     const A: TAlexSettings);
var O: TJSONObject; i: Integer;
begin
  O := EnsureObj(GetDevObj(MacToStr(MAC)), 'alex');
  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    JW(O, 'rx_ant_'    + IntToStr(i), Integer(A.RxAnt[i]));
    JW(O, 'tx_ant_'    + IntToStr(i), Integer(A.TxAnt[i]));
    JW(O, 'rx_only_'   + IntToStr(i), Integer(A.RxOnly[i]));
    JW(O, 'do_not_tx_' + IntToStr(i), A.DoNotTx[i]);
  end;
  JW(O, 'rx_bypass_on_tx', A.RxBypassOnTx);
  JW(O, 'ext1_on_tx',      A.Ext1OnTx);
  JW(O, 'gnd_bpf2_on_tx',  A.GndBpf2OnTx);
  JW(O, 'enable_xvtr_hf',  A.EnableXvtrHf);
end;

// ---------------------------------------------------------------------------
// XVTR settings (per-device, JSON-секция "xvtr" под MAC)
// ---------------------------------------------------------------------------

class procedure TSettingsManager.DefaultXvtr(out X: TXvtrSettings);
var i: Integer;
begin
  FillChar(X, SizeOf(X), 0);
  X.UseXVTRTunePower := False;
  // Все слоты по умолчанию disabled. Первые два предзаполнены типичными
  // значениями (2m с LO=116MHz, 70cm с LO=404MHz) — чтобы пользователь
  // мог быстро включить.
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    X.Entries[i].Enabled    := False;
    X.Entries[i].ButtonText := '';
    X.Entries[i].FreqBegin  := 0.0;
    X.Entries[i].FreqEnd    := 0.0;
    X.Entries[i].LOOffset   := 0.0;
    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;
    X.Entries[i].DisablePA  := False;
    X.Entries[i].RXAntenna  := 0;
    X.Entries[i].LastFreq         := 0.0;
    X.Entries[i].LastFreqB        := 0.0;
    X.Entries[i].LastMode         := MODE_USB; // VHF SSB calling default
    X.Entries[i].LastFilterIdx    := 0;   // NFM
    X.Entries[i].LastFMSQOn       := False;
    X.Entries[i].LastFMSQLevel    := 30;
    X.Entries[i].LastCTCSSOn      := False;
    X.Entries[i].LastCTCSSToneIdx := 0;
    X.Entries[i].LastFMStepOn     := True;
    X.Entries[i].LastFMStepIdx    := 3;   // 25 kHz
    X.Entries[i].LastCTun         := False;
    X.Entries[i].LastCenterHz     := 0.0;
    X.Entries[i].LastAGCMode      := 1;
    X.Entries[i].LastAGCTop       := 90;
    X.Entries[i].LastFMRptDir        := 0;
    X.Entries[i].LastFMRptOffsetHz   := 0.0;
    X.Entries[i].LastCTCSSAutoActive := False;
    X.Entries[i].LastFMRptAutoActive := False;
    X.Entries[i].LastZoomFactor      := 0.0;
    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';
  X.Entries[0].FreqBegin  := 144000000;
  X.Entries[0].FreqEnd    := 148000000;
  X.Entries[0].LOOffset   := 116000000;
  X.Entries[0].LastFreq   := 144300000;
  X.Entries[0].LastFreqB  := 145500000;
  X.Entries[1].ButtonText := '70cm';
  X.Entries[1].FreqBegin  := 430000000;
  X.Entries[1].FreqEnd    := 440000000;
  X.Entries[1].LOOffset   := 404000000;
  X.Entries[1].LastFreq   := 432200000;
  X.Entries[1].LastFreqB  := 433000000;
  // QO-100 (Es'hail-2) NB downlink: 10489.500–10490.000 МГц, LO 9750 МГц.
  // RX-трансвертер: tune = downlink − LO. Калибровать LOError по маяку.
  // QO-100 — в зарезервированном последнем слоте (отдельная страница настроек).
  X.Entries[QO100_SLOT].ButtonText   := 'QO-100';
  X.Entries[QO100_SLOT].FreqBegin    := 10489500000;
  X.Entries[QO100_SLOT].FreqEnd      := 10490000000;
  X.Entries[QO100_SLOT].LOOffset     := 9750000000;     // LNB LO
  X.Entries[QO100_SLOT].FullDuplexTx := True;           // раздельный TX-LO + full-duplex
  X.Entries[QO100_SLOT].TxLOOffset   := 8089500000;     // транспондер-offset (downlink-uplink)
  X.Entries[QO100_SLOT].LastFreq     := 10489750000;
  X.Entries[QO100_SLOT].LastFreqB    := 10489750000;
end;

function TSettingsManager.LoadXvtr(const MAC: array of Byte;
                                    out X: TXvtrSettings): Boolean;
var
  MacStr: string;
  DevObj, Root, EObj: TJSONObject;
  i: Integer;
begin
  DefaultXvtr(X);
  MacStr := MacToStr(MAC);
  Result := FRoot.Find(MacStr) <> nil;
  if not Result then Exit;
  DevObj := GetDevObj(MacStr);
  if DevObj.Find('xvtr') = nil then Exit;
  Root := EnsureObj(DevObj, 'xvtr');
  X.UseXVTRTunePower := JB(Root, 'use_tun_power', False);
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    if Root.Find('e' + IntToStr(i)) = nil then Continue;
    EObj := EnsureObj(Root, 'e' + IntToStr(i));
    X.Entries[i].Enabled    := JB(EObj, 'en',         X.Entries[i].Enabled);
    X.Entries[i].ButtonText := JS(EObj, 'name',       X.Entries[i].ButtonText);
    X.Entries[i].FreqBegin  := JD(EObj, 'fbeg',       X.Entries[i].FreqBegin);
    X.Entries[i].FreqEnd    := JD(EObj, 'fend',       X.Entries[i].FreqEnd);
    X.Entries[i].LOOffset   := JD(EObj, 'lo_off',     X.Entries[i].LOOffset);
    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);
    X.Entries[i].DisablePA  := JB(EObj, 'dis_pa',     X.Entries[i].DisablePA);
    X.Entries[i].RXAntenna  := EnsureRange(JI(EObj, 'rx_ant', X.Entries[i].RXAntenna), 0, 3);
    X.Entries[i].LastFreq         := JD(EObj, 'last_freq',      X.Entries[i].LastFreq);
    X.Entries[i].LastFreqB        := JD(EObj, 'last_freq_b',    X.Entries[i].LastFreqB);
    X.Entries[i].LastMode         := EnsureRange(JI(EObj, 'last_mode', X.Entries[i].LastMode), CFG_MODE_MIN, CFG_MODE_MAX);
    // Верхняя граница = FILT_SLOTS-1, а не 9: слоты VAR1/VAR2 (10/11) иначе
    // схлопывались в 9, и при восстановлении трансвертера ClampFilterIdx
    // видел индекс вне таблицы режима и откатывал на заводской фильтр.
    X.Entries[i].LastFilterIdx    := EnsureRange(JI(EObj, 'last_filt', X.Entries[i].LastFilterIdx), 0, FILT_SLOTS - 1);
    X.Entries[i].LastFMSQOn       := JB(EObj, 'last_fmsq_on',  X.Entries[i].LastFMSQOn);
    X.Entries[i].LastFMSQLevel    := EnsureRange(JI(EObj, 'last_fmsq_lvl', X.Entries[i].LastFMSQLevel), 0, 100);
    X.Entries[i].LastCTCSSOn      := JB(EObj, 'last_ctcss_on', X.Entries[i].LastCTCSSOn);
    X.Entries[i].LastCTCSSToneIdx := EnsureRange(JI(EObj, 'last_ctcss_idx', X.Entries[i].LastCTCSSToneIdx), 0, 37);
    X.Entries[i].LastFMStepOn     := JB(EObj, 'last_fmstep_on', X.Entries[i].LastFMStepOn);
    X.Entries[i].LastFMStepIdx    := EnsureRange(JI(EObj, 'last_fmstep_idx', X.Entries[i].LastFMStepIdx), 0, 3);
    X.Entries[i].LastCTun         := JB(EObj, 'last_ctun',     X.Entries[i].LastCTun);
    X.Entries[i].LastCenterHz     := JD(EObj, 'last_center_hz', X.Entries[i].LastCenterHz);
    X.Entries[i].LastAGCMode      := EnsureRange(JI(EObj, 'last_agc_mode', X.Entries[i].LastAGCMode), 0, 4);
    X.Entries[i].LastAGCTop       := EnsureRange(JI(EObj, 'last_agc_top',  X.Entries[i].LastAGCTop),  0, 120);
    X.Entries[i].LastFMRptDir        := EnsureRange(JI(EObj, 'last_fmrpt_dir', X.Entries[i].LastFMRptDir), 0, 2);
    X.Entries[i].LastFMRptOffsetHz   := JD(EObj, 'last_fmrpt_offset', X.Entries[i].LastFMRptOffsetHz);
    X.Entries[i].LastCTCSSAutoActive := JB(EObj, 'last_ctcss_auto', X.Entries[i].LastCTCSSAutoActive);
    X.Entries[i].LastFMRptAutoActive := JB(EObj, 'last_fmrpt_auto', X.Entries[i].LastFMRptAutoActive);
    X.Entries[i].LastZoomFactor      := EnsureRange(JD(EObj, 'last_zoom_factor', X.Entries[i].LastZoomFactor), 0.0, 1.0);
    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
      X.Entries[i].LastFreq := (X.Entries[i].FreqBegin + X.Entries[i].FreqEnd) / 2.0;
    if (X.Entries[i].LastFreqB < X.Entries[i].FreqBegin) or
       (X.Entries[i].LastFreqB > X.Entries[i].FreqEnd) then
      X.Entries[i].LastFreqB := (X.Entries[i].FreqBegin + X.Entries[i].FreqEnd) / 2.0;
  end;
  // QO-100 закреплён за зарезервированным слотом QO100_SLOT. Восстанавливаем
  // шаблон, если слот не размечен как split-LO транспондер ИЛИ значения пустые
  // (старый конфиг сохранял слот как обычный пустой XVTR с нулями — тогда
  // FullDuplexTx по умолчанию True, но LO/offset == 0). Реальный QO-100 всегда
  // имеет положительный транспондер-offset, так что валидный конфиг не трогаем.
  if (not X.Entries[QO100_SLOT].FullDuplexTx)
     or (X.Entries[QO100_SLOT].TxLOOffset <= 0)
     or (X.Entries[QO100_SLOT].LOOffset <= 0) then
  begin
    X.Entries[QO100_SLOT].Enabled      := False;
    X.Entries[QO100_SLOT].ButtonText   := 'QO-100';
    X.Entries[QO100_SLOT].FreqBegin    := 10489500000;
    X.Entries[QO100_SLOT].FreqEnd      := 10490000000;
    X.Entries[QO100_SLOT].LOOffset     := 9750000000;
    X.Entries[QO100_SLOT].FullDuplexTx := True;
    X.Entries[QO100_SLOT].TxLOOffset   := 8089500000;
    X.Entries[QO100_SLOT].LastFreq     := 10489750000;
    X.Entries[QO100_SLOT].LastFreqB    := 10489750000;
  end;
end;

procedure TSettingsManager.MigrateXvtrTemplate(var X: TXvtrSettings;
  const AName: string; ABegin, AEnd, ALO: Double;
  AFullDup: Boolean = False; ATxLO: Double = 0.0);
var
  i, freeIdx: Integer;
begin
  freeIdx := -1;
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    if SameText(X.Entries[i].ButtonText, AName) then Exit;  // уже есть — ничего не делаем
    if (freeIdx < 0) and (not X.Entries[i].Enabled) and
       (Trim(X.Entries[i].ButtonText) = '') then
      freeIdx := i;
  end;
  if freeIdx < 0 then Exit;  // нет свободных слотов
  X.Entries[freeIdx].ButtonText   := AName;
  X.Entries[freeIdx].FreqBegin    := ABegin;
  X.Entries[freeIdx].FreqEnd      := AEnd;
  X.Entries[freeIdx].LOOffset     := ALO;
  X.Entries[freeIdx].FullDuplexTx := AFullDup;
  X.Entries[freeIdx].TxLOOffset   := ATxLO;
  X.Entries[freeIdx].LastFreq     := (ABegin + AEnd) / 2.0;
  X.Entries[freeIdx].LastFreqB    := (ABegin + AEnd) / 2.0;
end;

procedure TSettingsManager.SaveXvtr(const MAC: array of Byte;
                                     const X: TXvtrSettings);
var
  Root, EObj: TJSONObject;
  i: Integer;
begin
  Root := EnsureObj(GetDevObj(MacToStr(MAC)), 'xvtr');
  JW(Root, 'use_tun_power', X.UseXVTRTunePower);
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    EObj := EnsureObj(Root, 'e' + IntToStr(i));
    JW(EObj,  'en',        X.Entries[i].Enabled);
    JWS(EObj, 'name',      X.Entries[i].ButtonText);
    JW(EObj,  'fbeg',      X.Entries[i].FreqBegin);
    JW(EObj,  'fend',      X.Entries[i].FreqEnd);
    JW(EObj,  'lo_off',    X.Entries[i].LOOffset);
    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);
    JW(EObj,  'dis_pa',    X.Entries[i].DisablePA);
    JW(EObj,  'rx_ant',    Integer(X.Entries[i].RXAntenna));
    JW(EObj,  'last_freq',     X.Entries[i].LastFreq);
    JW(EObj,  'last_freq_b',   X.Entries[i].LastFreqB);
    JW(EObj,  'last_mode',     X.Entries[i].LastMode);
    JW(EObj,  'last_filt',     X.Entries[i].LastFilterIdx);
    JW(EObj,  'last_fmsq_on',  X.Entries[i].LastFMSQOn);
    JW(EObj,  'last_fmsq_lvl', X.Entries[i].LastFMSQLevel);
    JW(EObj,  'last_ctcss_on', X.Entries[i].LastCTCSSOn);
    JW(EObj,  'last_ctcss_idx',X.Entries[i].LastCTCSSToneIdx);
    JW(EObj,  'last_fmstep_on', X.Entries[i].LastFMStepOn);
    JW(EObj,  'last_fmstep_idx',X.Entries[i].LastFMStepIdx);
    JW(EObj,  'last_ctun',      X.Entries[i].LastCTun);
    JW(EObj,  'last_center_hz', X.Entries[i].LastCenterHz);
    JW(EObj,  'last_agc_mode',  X.Entries[i].LastAGCMode);
    JW(EObj,  'last_agc_top',   X.Entries[i].LastAGCTop);
    JW(EObj,  'last_fmrpt_dir',    X.Entries[i].LastFMRptDir);
    JW(EObj,  'last_fmrpt_offset', X.Entries[i].LastFMRptOffsetHz);
    JW(EObj,  'last_ctcss_auto',   X.Entries[i].LastCTCSSAutoActive);
    JW(EObj,  'last_fmrpt_auto',   X.Entries[i].LastFMRptAutoActive);
    JW(EObj,  'last_zoom_factor',  X.Entries[i].LastZoomFactor);
    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;

class procedure TSettingsManager.DefaultDXCluster(out D: TDXClusterSettings);
begin
  D.Enabled    := False;          // без позывного подключаться всё равно нечем
  D.ShowSpots  := False;          // подписи включает пользователь кнопкой DX
  D.Host       := 'cluster.dxfun.com';
  D.Port       := 8000;
  D.Login      := '';
  D.Password   := '';
  D.PostLogin  := '';
  D.TTLMinutes := 30;
  D.MaxRows    := 4;
  D.ModeMask   := 0;              // 0 = без фильтра по модам
end;

procedure TSettingsManager.LoadDXClusterSettings(out D: TDXClusterSettings);
var O: TJSONObject;
begin
  DefaultDXCluster(D);
  if FRoot.Find('dxcluster') = nil then Exit;
  O := EnsureObj(FRoot, 'dxcluster');
  D.Enabled    := JB(O, 'enabled',    False);
  D.ShowSpots  := JB(O, 'show_spots', False);
  D.Host       := JS(O, 'host',       'cluster.dxfun.com');
  D.Port       := EnsureRange(JI(O, 'port', 8000), 1, 65535);
  D.Login      := JS(O, 'login',      '');
  D.Password   := JS(O, 'password',   '');
  D.PostLogin  := JS(O, 'post_login', '');
  D.TTLMinutes := EnsureRange(JI(O, 'ttl_minutes', 30), 1, 1440);
  D.MaxRows    := EnsureRange(JI(O, 'max_rows', 4), 1, 6);
  D.ModeMask   := LongWord(JI(O, 'mode_mask', 0));
end;

procedure TSettingsManager.SaveDXClusterSettings(const D: TDXClusterSettings);
var O: TJSONObject;
begin
  O := EnsureObj(FRoot, 'dxcluster');
  JW(O,  'enabled',     D.Enabled);
  JW(O,  'show_spots',  D.ShowSpots);
  JWS(O, 'host',        D.Host);
  JW(O,  'port',        D.Port);
  JWS(O, 'login',       D.Login);
  JWS(O, 'password',    D.Password);
  JWS(O, 'post_login',  D.PostLogin);
  JW(O,  'ttl_minutes', D.TTLMinutes);
  JW(O,  'max_rows',    D.MaxRows);
  JW(O,  'mode_mask',   Integer(D.ModeMask));
  Save;
end;

class procedure TSettingsManager.DefaultWeb(out W: TWebSettings);
begin
  W.Enabled  := True;
  W.Port     := 8080;
  W.BindAddr := '0.0.0.0';
  W.User     := 'admin';
  W.Pass     := 'hpsdr';
  W.SpecPixels := 1024;   // экономный дефолт: столько же, сколько слал протокол раньше
end;

procedure TSettingsManager.LoadWebSettings(out W: TWebSettings);
var O: TJSONObject;
begin
  DefaultWeb(W);
  if FRoot.Find('web') = nil then Exit;
  O := EnsureObj(FRoot, 'web');
  W.Enabled  := JB(O, 'enabled',   True);
  W.Port     := EnsureRange(JI(O, 'port', 8080), 1, 65535);
  W.BindAddr := JS(O, 'bind_addr', '0.0.0.0');
  W.User     := JS(O, 'user',     'admin');
  W.Pass     := JS(O, 'pass',     'hpsdr');
  W.SpecPixels := EnsureRange(JI(O, 'spec_pixels', 1024), 256, 4096);
end;

procedure TSettingsManager.SaveWebSettings(const W: TWebSettings);
var O: TJSONObject;
begin
  O := EnsureObj(FRoot, 'web');
  JW(O,  'enabled',   W.Enabled);
  JW(O,  'port',      W.Port);
  JWS(O, 'bind_addr', W.BindAddr);
  JWS(O, 'user',      W.User);
  JWS(O, 'pass',      W.Pass);
  JW(O,  'spec_pixels', W.SpecPixels);
  Save;
end;

class procedure TSettingsManager.DefaultTCI(out T: TTCISettings);
begin
  T.Enabled  := False;         // включается осознанно, как CAT-транспорты
  T.Port     := 40001;
  T.BindAddr := '127.0.0.1';
  T.RecordDir := '';           // пусто = <каталог конфигурации>/records
end;

procedure TSettingsManager.LoadTCISettings(out T: TTCISettings);
var O: TJSONObject;
begin
  DefaultTCI(T);
  if FRoot.Find('tci') = nil then Exit;
  O := EnsureObj(FRoot, 'tci');
  T.Enabled  := JB(O, 'enabled',  False);
  T.Port     := EnsureRange(JI(O, 'port', 40001), 1, 65535);
  T.BindAddr := JS(O, 'bind_addr', '127.0.0.1');
  T.RecordDir := JS(O, 'record_dir', '');
end;

procedure TSettingsManager.SaveTCISettings(const T: TTCISettings);
var O: TJSONObject;
begin
  O := EnsureObj(FRoot, 'tci');
  JW(O,  'enabled',   T.Enabled);
  JW(O,  'port',      T.Port);
  JWS(O, 'bind_addr', T.BindAddr);
  JWS(O, 'record_dir', T.RecordDir);
  Save;
end;

procedure TSettingsManager.SaveTheme(ALightTheme: Boolean);
var O: TJSONObject;
begin
  O := EnsureObj(FRoot, 'preferences');
  JW(O, 'light_theme', ALightTheme);
end;

function TSettingsManager.LoadTheme: Boolean;
var O: TJSONObject;
begin
  Result := False;
  if FRoot.Find('preferences') = nil then Exit;
  O := EnsureObj(FRoot, 'preferences');
  Result := JB(O, 'light_theme', False);
end;

// ---------------------------------------------------------------------------
// Панадаптеры + слайсы (per-device, JSON-секция "pans" под MAC)
// ---------------------------------------------------------------------------

class procedure TSettingsManager.DefaultPans(out P: TPansConfig);
var i: Integer;
begin
  P.GridMode     := False;
  P.GridRowShare := 0.5;
  for i := 0 to CFG_PANS_MAX-1 do
  begin
    P.Pans[i].Active     := False;
    P.Pans[i].FreqHz     := 0.0;
    P.Pans[i].RateKHz    := 384;
    P.Pans[i].ADC        := 0;
    P.Pans[i].Zoom       := 0.0;
    P.Pans[i].PanSlider  := 0.5;
    P.Pans[i].StackShare := 1.0;
    P.Pans[i].ColShare   := 1.0;
    P.Pans[i].SplitRatio := 0.40;   // = TPanafallPanel дефолт
    P.Pans[i].HasDisplay := False;
    P.Pans[i].DPalette   := 0;
    P.Pans[i].DGamma     := 1.0;
    P.Pans[i].DSpecRef   := -20.0;
    P.Pans[i].DSpecRange := 110.0;
    P.Pans[i].DWfAGC     := True;
    P.Pans[i].DWfOffset  := 0.0;
    P.Pans[i].DWfHigh    := -60.0;
    P.Pans[i].DWfLow     := -120.0;
    SetLength(P.Pans[i].Slices, 0);
  end;
end;

procedure TSettingsManager.SavePans(const MAC: array of Byte; const Ctx: string;
  const P: TPansConfig);
var
  Root, PanObj, SliceObj, DispObj: TJSONObject;
  PanArr, SliceArr: TJSONArray;
  i, j, Idx: Integer;
begin
  Root := EnsureObj(EnsureObj(GetDevObj(MacToStr(MAC)), 'pans'), Ctx);
  JW(Root, 'grid_mode', P.GridMode);
  JW(Root, 'grid_row_share', P.GridRowShare);
  // Список панов пересоздаётся целиком (проще, чем merge по индексам).
  Idx := Root.IndexOfName('list');
  if Idx >= 0 then Root.Delete(Idx);
  PanArr := TJSONArray.Create;
  Root.Add('list', PanArr);
  for i := 0 to CFG_PANS_MAX-1 do
  begin
    // Пан 0 пишем всегда (ради его доли стека/слайсов), паны N — только активные.
    if (i > 0) and not P.Pans[i].Active then Continue;
    PanObj := TJSONObject.Create;
    PanArr.Add(PanObj);
    PanObj.Add('id',          i);
    PanObj.Add('active',      P.Pans[i].Active);
    PanObj.Add('freq',        P.Pans[i].FreqHz);
    PanObj.Add('rate_khz',    P.Pans[i].RateKHz);
    PanObj.Add('adc',         P.Pans[i].ADC);
    PanObj.Add('zoom',        P.Pans[i].Zoom);
    PanObj.Add('pan',         P.Pans[i].PanSlider);
    PanObj.Add('stack_share', P.Pans[i].StackShare);
    PanObj.Add('col_share',   P.Pans[i].ColShare);
    PanObj.Add('split_ratio', P.Pans[i].SplitRatio);
    // Per-pan дисплей: пишем блок только при override (иначе пан = глоб. дефолт).
    if P.Pans[i].HasDisplay then
    begin
      DispObj := TJSONObject.Create;
      PanObj.Add('display', DispObj);
      DispObj.Add('palette',    P.Pans[i].DPalette);
      DispObj.Add('gamma',      P.Pans[i].DGamma);
      DispObj.Add('spec_ref',   P.Pans[i].DSpecRef);
      DispObj.Add('spec_range', P.Pans[i].DSpecRange);
      DispObj.Add('wf_agc',     P.Pans[i].DWfAGC);
      DispObj.Add('wf_offset',  P.Pans[i].DWfOffset);
      DispObj.Add('wf_high',    P.Pans[i].DWfHigh);
      DispObj.Add('wf_low',     P.Pans[i].DWfLow);
    end;
    SliceArr := TJSONArray.Create;
    PanObj.Add('slices', SliceArr);
    for j := 0 to High(P.Pans[i].Slices) do
    begin
      SliceObj := TJSONObject.Create;
      SliceArr.Add(SliceObj);
      SliceObj.Add('target_hz',  P.Pans[i].Slices[j].TargetHz);
      SliceObj.Add('mode',       P.Pans[i].Slices[j].Mode);
      SliceObj.Add('filter_lo',  P.Pans[i].Slices[j].FilterLo);
      SliceObj.Add('filter_hi',  P.Pans[i].Slices[j].FilterHi);
      SliceObj.Add('agc',        P.Pans[i].Slices[j].AGC);
      SliceObj.Add('volume',     P.Pans[i].Slices[j].Volume);
      SliceObj.Add('muted',      P.Pans[i].Slices[j].Muted);
      SliceObj.Add('rx_mute_on_tx', P.Pans[i].Slices[j].RxMuteOnTx);
      SliceObj.Add('fmsq_on',    P.Pans[i].Slices[j].FMSQOn);
      SliceObj.Add('fmsq_level', P.Pans[i].Slices[j].FMSQLevel);
      SliceObj.Add('dev_name',   P.Pans[i].Slices[j].DevName);
      SliceObj.Add('in_dev_name', P.Pans[i].Slices[j].InDevName);
      SliceObj.Add('tx_prof_modes',
                   ModeTableToJSON(P.Pans[i].Slices[j].TXProfByMode));
    end;
  end;
end;

function TSettingsManager.LoadPans(const MAC: array of Byte; const Ctx: string;
  out P: TPansConfig): Boolean;
var
  MacStr: string;
  DevObj, PansObj, Root, PanObj, SliceObj, DispObj: TJSONObject;
  ListData, SlData, DispData: TJSONData;
  Arr, SArr: TJSONArray;
  i, id, k, n: Integer;
begin
  DefaultPans(P);
  MacStr := MacToStr(MAC);
  Result := FRoot.Find(MacStr) <> nil;
  if not Result then Exit;
  DevObj := GetDevObj(MacStr);
  if DevObj.Find('pans') = nil then begin Result := False; Exit; end;
  PansObj := EnsureObj(DevObj, 'pans');
  if PansObj.Find(Ctx) = nil then begin Result := False; Exit; end;
  Root := EnsureObj(PansObj, Ctx);
  P.GridMode     := JB(Root, 'grid_mode', False);
  P.GridRowShare := EnsureRange(JD(Root, 'grid_row_share', 0.5), 0.15, 0.85);
  ListData := Root.Find('list');
  if (ListData = nil) or not (ListData is TJSONArray) then Exit;
  Arr := TJSONArray(ListData);
  for i := 0 to Arr.Count-1 do
  begin
    if not (Arr.Items[i] is TJSONObject) then Continue;
    PanObj := TJSONObject(Arr.Items[i]);
    id := JI(PanObj, 'id', -1);
    if (id < 0) or (id >= CFG_PANS_MAX) then Continue;
    P.Pans[id].Active     := JB(PanObj, 'active', id > 0);
    P.Pans[id].FreqHz     := JD(PanObj, 'freq', 0.0);
    P.Pans[id].RateKHz    := JI(PanObj, 'rate_khz', 384);
    P.Pans[id].ADC        := JI(PanObj, 'adc', 0);
    P.Pans[id].Zoom       := EnsureRange(JD(PanObj, 'zoom', 0.0), 0.0, 1.0);
    P.Pans[id].PanSlider  := EnsureRange(JD(PanObj, 'pan', 0.5), 0.0, 1.0);
    P.Pans[id].StackShare := JD(PanObj, 'stack_share', 1.0);
    P.Pans[id].ColShare   := JD(PanObj, 'col_share', 1.0);
    P.Pans[id].SplitRatio := EnsureRange(JD(PanObj, 'split_ratio', 0.40), 0.05, 0.95);
    // Per-pan дисплей: присутствие блока 'display' = override.
    DispData := PanObj.Find('display');
    if (DispData <> nil) and (DispData is TJSONObject) then
    begin
      DispObj := TJSONObject(DispData);
      P.Pans[id].HasDisplay := True;
      P.Pans[id].DPalette   := EnsureRange(JI(DispObj, 'palette', 0), 0, 2);
      P.Pans[id].DGamma     := EnsureRange(JD(DispObj, 'gamma', 1.0), 0.30, 3.00);
      P.Pans[id].DSpecRef   := JD(DispObj, 'spec_ref', -20.0);
      P.Pans[id].DSpecRange := JD(DispObj, 'spec_range', 110.0);
      P.Pans[id].DWfAGC     := JB(DispObj, 'wf_agc', True);
      P.Pans[id].DWfOffset  := JD(DispObj, 'wf_offset', 0.0);
      P.Pans[id].DWfHigh    := JD(DispObj, 'wf_high', -60.0);
      P.Pans[id].DWfLow     := JD(DispObj, 'wf_low', -120.0);
    end;
    SlData := PanObj.Find('slices');
    if (SlData = nil) or not (SlData is TJSONArray) then Continue;
    SArr := TJSONArray(SlData);
    SetLength(P.Pans[id].Slices, 0);
    for k := 0 to SArr.Count-1 do
    begin
      if not (SArr.Items[k] is TJSONObject) then Continue;
      SliceObj := TJSONObject(SArr.Items[k]);
      n := Length(P.Pans[id].Slices);
      SetLength(P.Pans[id].Slices, n+1);
      P.Pans[id].Slices[n].TargetHz  := JD(SliceObj, 'target_hz', 0.0);
      P.Pans[id].Slices[n].Mode      := EnsureRange(
        JI(SliceObj, 'mode', MODE_LSB), CFG_MODE_MIN, CFG_MODE_MAX);
      P.Pans[id].Slices[n].FilterLo  := JI(SliceObj, 'filter_lo', -1500);
      P.Pans[id].Slices[n].FilterHi  := JI(SliceObj, 'filter_hi', 1500);
      P.Pans[id].Slices[n].AGC       := JI(SliceObj, 'agc', 1);
      P.Pans[id].Slices[n].Volume    := EnsureRange(JD(SliceObj, 'volume', 0.7), 0.0, 1.0);
      P.Pans[id].Slices[n].Muted     := JB(SliceObj, 'muted', False);
      P.Pans[id].Slices[n].RxMuteOnTx := JB(SliceObj, 'rx_mute_on_tx', True);
      P.Pans[id].Slices[n].FMSQOn    := JB(SliceObj, 'fmsq_on', False);
      P.Pans[id].Slices[n].FMSQLevel := JI(SliceObj, 'fmsq_level', 30);
      P.Pans[id].Slices[n].DevName   := JS(SliceObj, 'dev_name', '');
      P.Pans[id].Slices[n].InDevName := JS(SliceObj, 'in_dev_name', '');
      JSONToModeTable(SliceObj.Find('tx_prof_modes'),
                      P.Pans[id].Slices[n].TXProfByMode);
    end;
  end;
end;

end.
