{
  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 CATEngine;
{
  CATEngine.pas — CAT command engine (Kenwood + extended ZZ* commands).

  Движок поддерживает базовые команды Kenwood и расширения ZZ*. Команды, для
  которых в EWSDR ещё нет функционала, возвращают
  заглушку и сопровождены комментарием "STUB: <причина>".

  Формат команд:
    SET:  <PREFIX><SUFFIX>;<CR>   (suffix = значение)
    GET:  <PREFIX>;<CR>           (suffix пустой)
    Resp: <PREFIX><VALUE>;
    Err:  ?; (неверный формат) | E; (ошибка выполнения)

  Пример:
    "FA00014250000;" — set VFO A = 14.250 MHz
    "FA;"            — get VFO A
    "ZZFA00014250000;" — то же расширенной командой
}
{$IFDEF FPC}
  {$MODE Delphi}
{$ENDIF}

interface

uses
  Classes, SysUtils, Math, SyncObjs, RadioModes;

const
  CAT_ERROR  = '?;';
  CAT_EXEC_ERROR = 'E;';
  CAT_ID_NUM = '019';   // TS-2000 compatible

  CAT_MODE_COUNT = RadioModes.MODE_COUNT;

  // FM repeater offset directions — зеркало FMRepeater.RPT_* (значения должны
  // совпадать: контроллер хранит FFMRptDir в этих же кодах).
  RPT_NONE  = 0; RPT_MINUS = 1; RPT_PLUS = 2;

  // Границы TX/CW-полей. Совпадают с контролами вкладок Transmit и CW: движок
  // клампует сам, потому что по CAT прилетает что угодно, а контроллер эти
  // поля принимает записью целиком и уже не знает, кто их правил.
  MIC_GAIN_MIN   = -20;  MIC_GAIN_MAX   = 60;     // дБ
  TX_FILT_LO_MIN = 0;    TX_FILT_LO_MAX = 8000;   // Гц
  TX_FILT_HI_MIN = 100;  TX_FILT_HI_MAX = 12000;  // Гц
  COMP_GAIN_MAX  = 20;                            // дБ
  EQ_GAIN_MIN    = -20;  EQ_GAIN_MAX    = 20;     // дБ
  CW_PITCH_MIN   = 200;  CW_PITCH_MAX   = 1200;   // Гц
  CW_HANG_MAX    = 2000;                          // мс
  CW_WPM_MIN     = 5;    CW_WPM_MAX     = 60;
  CW_TEXT_FIELD  = 25;                            // ширина поля текста KY/ZZKY
  FM_DEV_NARROW  = 2500; FM_DEV_WIDE    = 5000;   // Гц (ZZFD — радиокнопка)

type
  TCATGetDoubleFunc = function: Double  of object;
  TCATGetIntFunc    = function: Integer of object;
  TCATGetBoolFunc   = function: Boolean of object;
  TCATSetDoubleProc = procedure(V: Double)  of object;
  TCATSetIntProc    = procedure(V: Integer) of object;
  TCATSetBoolProc   = procedure(V: Boolean) of object;
  TCATProc          = procedure of object;
  // Andromeda front panel input
  TCATPanelEncoderProc = procedure(Enc, Step: Integer) of object;
  TCATPanelButtonProc  = procedure(Btn: Integer; State, LongPress: Boolean) of object;
  // TX-эквалайзер (ZZEB): [0] — преамп, 1..10 — полосы, дБ. Формат провода
  // (счётчик полос + знаковые тройки) знает движок, состав записи — адаптер.
  TCATEQGains       = array[0..10] of Integer;
  TCATGetEQFunc     = function(out Gains: TCATEQGains): Integer of object;  // → число полос
  TCATSetEQProc     = procedure(NumBands: Integer; const Gains: TCATEQGains) of object;

  TCATContext = record
    GetVfoA:         TCATGetDoubleFunc;
    GetVfoB:         TCATGetDoubleFunc;
    GetMode:         TCATGetIntFunc;
    GetActiveVfo:    TCATGetIntFunc;
    GetAGCMode:      TCATGetIntFunc;
    GetVolume:       TCATGetIntFunc;
    GetDriveLevel:   TCATGetIntFunc;
    GetFilterIdx:    TCATGetIntFunc;
    GetFilterBW:     TCATGetIntFunc;
    GetNRMode:       TCATGetIntFunc;
    GetNBMode:       TCATGetIntFunc;
    GetSNBEnabled:   TCATGetBoolFunc;
    GetANFEnabled:   TCATGetBoolFunc;
    GetTransmitting: TCATGetBoolFunc;
    GetRunning:      TCATGetBoolFunc;
    GetSMeter:       TCATGetDoubleFunc;
    GetCurrentBand:  TCATGetIntFunc;
    SetVfoA:         TCATSetDoubleProc;
    SetVfoB:         TCATSetDoubleProc;
    SetMode:         TCATSetIntProc;
    SetActiveVfo:    TCATSetIntProc;
    SetAGCMode:      TCATSetIntProc;
    SetVolume:       TCATSetIntProc;
    SetDriveLevel:   TCATSetIntProc;
    SetFilterIdx:    TCATSetIntProc;
    SetNRMode:       TCATSetIntProc;
    SetNBMode:       TCATSetIntProc;
    SetSNBEnabled:   TCATSetBoolProc;
    SetANFEnabled:   TCATSetBoolProc;
    SetTransmitting: TCATSetBoolProc;
    DoBandUp:        TCATProc;
    DoBandDown:      TCATProc;
    DoTuneUp:        TCATProc;
    DoTuneDown:      TCATProc;
    DoBandByIndex:   TCATSetIntProc;
    // Split / FM (squelch, CTCSS, repeater, step)
    GetSplit:        TCATGetBoolFunc;
    SetSplit:        TCATSetBoolProc;
    GetSquelchOn:    TCATGetBoolFunc;
    SetSquelchOn:    TCATSetBoolProc;
    GetSquelchLevel: TCATGetIntFunc;     // 0..100
    SetSquelchLevel: TCATSetIntProc;     // 0..100
    GetCTCSSOn:      TCATGetBoolFunc;
    SetCTCSSOn:      TCATSetBoolProc;
    GetCTCSSTone:    TCATGetIntFunc;     // index 0..37
    SetCTCSSTone:    TCATSetIntProc;     // index 0..37
    GetFMRptDir:     TCATGetIntFunc;     // 0=none,1=minus,2=plus
    SetFMRptDir:     TCATSetIntProc;
    GetFMRptOffset:  TCATGetDoubleFunc;  // Hz
    SetFMRptOffset:  TCATSetDoubleProc;  // Hz
    GetFMStep:       TCATGetIntFunc;     // index 0..3 (6.25/12.5/20/25 kHz)
    SetFMStep:       TCATSetIntProc;     // index 0..3
    GetAGCTop:       TCATGetIntFunc;     // AGC-T порог, dB
    SetAGCTop:       TCATSetIntProc;     // AGC-T порог, dB
    GetCTun:         TCATGetBoolFunc;    // Click-tune (CTUN) вкл/выкл
    SetCTun:         TCATSetBoolProc;
    // Кромки фильтра в Гц (ZZFL/ZZFH). Главный приёмник настраивается пресетами
    // (callback'и nil → команды отвечают дефолтом режима), слайс — кромками.
    GetFilterLow:    TCATGetIntFunc;
    GetFilterHigh:   TCATGetIntFunc;
    SetFilterLow:    TCATSetIntProc;
    SetFilterHigh:   TCATSetIntProc;
    // Телеграф (KS/KY/ZZKS/ZZKY/ZZKM). Скорость — WPM 5..60; SendCWText ставит
    // текст в очередь программной передачи, CWTextBusy = «очередь занята».
    GetCWSpeed:      TCATGetIntFunc;
    SetCWSpeed:      TCATSetIntProc;
    GetCWKeyerMode:  TCATGetIntFunc;     // 0=прямой ключ, 1=iambic A, 2=iambic B
    SetCWKeyerMode:  TCATSetIntProc;
    SendCWText:      procedure(const S: string) of object;
    CWTextBusy:      TCATGetBoolFunc;
    // Телеграф, продолжение: тон приёма/сайдтона (ZZCL), break-in прошивки
    // (ZZCB) и его hang-time (ZZCD), сайдтон активного источника манипуляции
    // ZZCM означает «monitor DISABLE», инверсия делается в движке.
    GetCWPitch:      TCATGetIntFunc;     // Гц
    SetCWPitch:      TCATSetIntProc;
    GetCWBreakIn:    TCATGetBoolFunc;
    SetCWBreakIn:    TCATSetBoolProc;
    GetCWHangTime:   TCATGetIntFunc;     // мс
    SetCWHangTime:   TCATSetIntProc;
    GetCWMonitor:    TCATGetBoolFunc;
    SetCWMonitor:    TCATSetBoolProc;
    // ── TX-тракт (вкладка Transmit / активный TX-профиль) ──
    GetMicGain:      TCATGetIntFunc;     // дБ (у EWSDR -20..+60)
    SetMicGain:      TCATSetIntProc;
    GetTXFilterLow:  TCATGetIntFunc;     // Гц
    SetTXFilterLow:  TCATSetIntProc;
    GetTXFilterHigh: TCATGetIntFunc;     // Гц
    SetTXFilterHigh: TCATSetIntProc;
    GetCompOn:       TCATGetBoolFunc;    // речевой компрессор (WDSP COMP)
    SetCompOn:       TCATSetBoolProc;
    GetCompGain:     TCATGetIntFunc;     // дБ 0..20
    SetCompGain:     TCATSetIntProc;
    GetTXEQOn:       TCATGetBoolFunc;
    SetTXEQOn:       TCATSetBoolProc;
    GetTXEQ:         TCATGetEQFunc;
    SetTXEQ:         TCATSetEQProc;
    GetTUNLevel:     TCATGetIntFunc;     // мощность настройки, 0..100 %
    SetTUNLevel:     TCATSetIntProc;
    GetFMDeviation:  TCATGetIntFunc;     // девиация передачи, Гц
    SetFMDeviation:  TCATSetIntProc;
    GetTXProfile:      TCATGetIntFunc;   // индекс активного профиля
    SetTXProfile:      TCATSetIntProc;
    GetTXProfileCount: TCATGetIntFunc;
    // MON: у EWSDR монитор передачи — это self-monitor даунлинка (DUP + RX MUTE
    // off), уровень у него свой, отдельный от RX-громкости.
    GetMonitorOn:    TCATGetBoolFunc;
    SetMonitorOn:    TCATSetBoolProc;
    GetMonitorLevel: TCATGetIntFunc;     // 0..100
    SetMonitorLevel: TCATSetIntProc;
    GetTuning:       TCATGetBoolFunc;    // кнопка TUN
    SetTuning:       TCATSetBoolProc;
    GetTwoTone:      TCATGetBoolFunc;    // 2TON
    SetTwoTone:      TCATSetBoolProc;
    GetPureSignal:   TCATGetBoolFunc;    // PS-A
    SetPureSignal:   TCATSetBoolProc;
    DoPSSingleCal:   TCATProc;           // ZZUS — одиночная калибровка PS
    // ── Приёмник и сервис ──
    GetMuted:        TCATGetBoolFunc;    // кнопка MUT (ZZMA)
    SetMuted:        TCATSetBoolProc;
    GetAtten:        TCATGetIntFunc;     // шаговый аттенюатор ADC, дБ (ZZRX)
    SetAtten:        TCATSetIntProc;
    GetSupplyVolts:  TCATGetDoubleFunc;  // напряжение питания PA (ZZRV); <0 = нет данных
    DoVfoSwap:       TCATProc;           // обмен VFO A↔B (ZZVS)
    GetMemoryCount:  TCATGetIntFunc;     // число записей памяти каналов (ZZMV)
    SendCWMacro:     TCATSetIntProc;     // запустить CW-макрос (сообщение) номер n (ZZKM)
    // Andromeda front panel input (ZZZD/ZZZU/ZZZE/ZZZP/ZZZS)
    OnPanelVfoStep:  TCATSetIntProc;        // знаковое число шагов энкодера VFO
    OnPanelEncoder:  TCATPanelEncoderProc;  // номер энкодера (0-based) + знаковый шаг
    OnPanelButton:   TCATPanelButtonProc;   // номер кнопки (0-based) + state/long-press
    OnPanelVersion:  TCATSetIntProc;        // версия HW/SW панели (рукопожатие)
  end;

  TCATEngine = class
  private
    FCtx:  TCATContext;
    FLock: TCriticalSection;

    function Pad(V: Int64; W: Integer): string;
    function SignedPad(V: Int64; W: Integer): string;   // '+nnn' / '-nnn'
    function FreqToStr(Hz: Double): string;
    function StrToFreq(const S: string): Double;
    function ModeToK(M: Integer): Integer;
    function KToMode(K: Integer): Integer;
    function SMeterRaw(dBm: Double): Integer;

    // helpers to safely call context callbacks
    function SafeGetVfoA: Double;
    function SafeGetVfoB: Double;
    function SafeGetMode: Integer;
    function SafeGetCWSpeed: Integer;
    function SafeGetVolume: Integer;
    function SafeGetDriveLevel: Integer;
    function SafeGetFilterIdx: Integer;
    function SafeGetAGCMode: Integer;
    function SafeGetNRMode: Integer;
    function SafeGetNBMode: Integer;
    function SafeGetSNB: Boolean;
    function SafeGetANF: Boolean;
    function SafeGetTX: Boolean;
    function SafeGetRunning: Boolean;
    function SafeGetSMeter: Double;
    function SafeGetBand: Integer;
    function SafeGetSplit: Boolean;
    function SafeGetSquelchOn: Boolean;
    function SafeGetSquelchLevel: Integer;
    function SafeGetCTCSSOn: Boolean;
    function SafeGetCTCSSTone: Integer;
    function SafeGetFMRptDir: Integer;
    function SafeGetFMRptOffset: Double;
    function SafeGetFMStep: Integer;
    function SafeGetAGCTop: Integer;
    function SafeGetCTun: Boolean;
    // TX-тракт / телеграф
    function SafeGetCWKeyerMode: Integer;
    function SafeGetCWPitch: Integer;
    function SafeGetCWHangTime: Integer;
    function SafeGetMicGain: Integer;
    function SafeGetTXFilterLow: Integer;
    function SafeGetTXFilterHigh: Integer;
    function SafeGetCompGain: Integer;
    function SafeGetTUNLevel: Integer;
    function SafeGetFMDeviation: Integer;
    function SafeGetTXProfile: Integer;
    function SafeGetMonitorLevel: Integer;
    function SafeGetBool(const F: TCATGetBoolFunc): Boolean;
    // Общий разбор «1 символ 0/1» для двухпозиционных команд: SET дёргает Setter
    // (если он есть), GET отдаёт Getter. Пустой Setter = команда только читает.
    function OnOff(const s: string; const Getter: TCATGetBoolFunc;
                   const Setter: TCATSetBoolProc): string;
    // То же для числового поля фиксированной ширины с клампом.
    function NumField(const s: string; Width, Lo, Hi: Integer;
                      const Getter: TCATGetIntFunc;
                      const Setter: TCATSetIntProc): string;
    function StepIdxToHz(Idx: Integer): Double;

    // command implementations
    function CmdAG(const s: string): string;
    function CmdAI(const s: string): string;
    function CmdBD: string;
    function CmdBU: string;
    function CmdCN(const s: string): string;
    function CmdCT(const s: string): string;
    function CmdDN: string;
    function CmdFA(const s: string): string;
    function CmdFB(const s: string): string;
    function CmdFR(const s: string): string;
    function CmdFT(const s: string): string;
    function CmdFW(const s: string): string;
    function CmdGT(const s: string): string;
    function CmdID: string;
    function CmdIF: string;
    function CmdKS(const s: string): string;
    function CmdKY(const s: string): string;
    function CmdMD(const s: string): string;
    function CmdMG(const s: string): string;
    function CmdMO(const s: string): string;
    function CmdNB(const s: string): string;
    function CmdNT(const s: string): string;
    function CmdOF(const s: string): string;
    function CmdOS(const s: string): string;
    function CmdPC(const s: string): string;
    function CmdPR(const s: string): string;
    function CmdPS(const s: string): string;
    function CmdQI: string;
    function CmdRC: string;
    function CmdRD(const s: string): string;
    function CmdRT(const s: string): string;
    function CmdRU(const s: string): string;
    function CmdRX(const s: string): string;
    function CmdSH(const s: string): string;
    function CmdSL(const s: string): string;
    function CmdSM(const s: string): string;
    function CmdSQ(const s: string): string;
    function CmdTX(const s: string): string;
    function CmdUP: string;
    function CmdXT(const s: string): string;

    // ZZ extended
    function ParseZZ(const ext, s: string): string;
    function ZZAA(const s: string): string;
    function ZZAB(const s: string): string;
    function ZZAC(const s: string): string;
    function ZZAG(const s: string): string;
    function ZZAI(const s: string): string;
    function ZZAP(const s: string): string;
    function ZZAR(const s: string): string;
    function ZZAS(const s: string): string;
    function ZZAT(const s: string): string;
    function ZZAU(const s: string): string;
    function ZZBA: string;
    function ZZBD: string;
    function ZZBP(const s: string): string;
    function ZZBI(const s: string): string;
    function ZZBM(const s: string): string;
    function ZZBS(const s: string): string;
    function ZZBU: string;
    function ZZCB(const s: string): string;
    function ZZCD(const s: string): string;
    function ZZCN(const s: string): string;
    function ZZCS(const s: string): string;
    function ZZDA(const s: string): string;
    function ZZDB(const s: string): string;
    function ZZDM(const s: string): string;
    function ZZDN(const s: string): string;
    function ZZDU: string;
    function ZZEA(const s: string): string;
    function ZZEB(const s: string): string;
    function ZZEM(const s: string): string;
    function ZZFA(const s: string): string;
    function ZZFB(const s: string): string;
    function ZZFD(const s: string): string;
    function ZZFI(const s: string): string;
    function ZZFL(const s: string): string;
    function ZZFH(const s: string): string;
    function ZZFM: string;
    function ZZFT(const s: string): string;
    function ZZGA(const s: string): string;
    function ZZGR(const s: string): string;
    function ZZGT(const s: string): string;
    function ZZID: string;
    function ZZIF(const s: string): string;
    function ZZIO: string;
    function ZZIS(const s: string): string;
    function ZZKM(const s: string): string;
    function ZZKS(const s: string): string;
    function ZZKY(const s: string): string;
    function ZZMA(const s: string): string;
    function ZZMB(const s: string): string;
    function ZZMD(const s: string): string;
    function ZZMG(const s: string): string;
    function ZZML: string;
    function ZZMN(const s: string): string;
    function ZZMO(const s: string): string;
    function ZZMV: string;
    function ZZMY: string;
    function ZZNA(const s: string): string;
    function ZZNB(const s: string): string;
    function ZZNL(const s: string): string;
    function ZZNN(const s: string): string;
    function ZZNR(const s: string): string;
    function ZZNS(const s: string): string;
    function ZZNT(const s: string): string;
    function ZZOA(const s: string): string;
    function ZZOB(const s: string): string;
    function ZZOS(const s: string): string;
    function ZZOT(const s: string): string;
    function ZZPC(const s: string): string;
    function ZZPD: string;
    function ZZPO(const s: string): string;
    function ZZPS(const s: string): string;
    function ZZQM: string;
    function ZZQR: string;
    function ZZQS: string;
    function ZZRA(const s: string): string;
    function ZZRC: string;
    function ZZRD(const s: string): string;
    function ZZRL(const s: string): string;
    function ZZRU(const s: string): string;
    function ZZRV: string;
    function ZZRX(const s: string): string;
    function ZZSA: string;
    function ZZSB: string;
    function ZZSM(const s: string): string;
    function ZZSO(const s: string): string;
    function ZZSP(const s: string): string;
    function ZZSQ(const s: string): string;
    function ZZSR(const s: string): string;
    function ZZST(const s: string): string;
    function ZZSU: string;
    function ZZTA(const s: string): string;
    function ZZTB(const s: string): string;
    function ZZTX(const s: string): string;
    function ZZUP(const s: string): string;
    function ZZUS: string;
    function ZZUT(const s: string): string;
    function ZZUX(const s: string): string;
    function ZZUY(const s: string): string;
    function ZZVA(const s: string): string;
    function ZZVG(const s: string): string;
    function ZZVN: string;
    function ZZVS(const s: string): string;
    function ZZWA(const s: string): string;
    function ZZWB(const s: string): string;
    function ZZWC(const s: string): string;
    function ZZWD(const s: string): string;
    function ZZWE(const s: string): string;
    function ZZWF(const s: string): string;
    function ZZWG(const s: string): string;
    function ZZWH(const s: string): string;
    function ZZWJ(const s: string): string;
    function ZZWK(const s: string): string;
    function ZZWL(const s: string): string;
    function ZZWM(const s: string): string;
    function ZZWN(const s: string): string;
    function ZZWO(const s: string): string;
    function ZZWP(const s: string): string;
    function ZZWQ(const s: string): string;
    function ZZWR(const s: string): string;
    function ZZWS(const s: string): string;
    function ZZXC: string;
    function ZZXF(const s: string): string;
    function ZZXA(const s: string): string;
    function ZZYA(const s: string): string;
    function ZZYB(const s: string): string;
    function ZZYC(const s: string): string;
    function ZZYR(const s: string): string;
    function ZZZA(const s: string): string;
    function ZZZB: string;
    function ZZZD(const s: string): string;
    function ZZZE(const s: string): string;
    function ZZZM(const s: string): string;
    function ZZZP(const s: string): string;
    function ZZZQ(const s: string): string;
    function ZZZR(const s: string): string;
    function ZZZS(const s: string): string;
    function ZZZT(const s: string): string;
    function ZZZU(const s: string): string;
    function ZZZV(const s: string): string;
    function ZZZW(const s: string): string;
    function ZZZZ: string;
    function ZZZO(const s: string): string;
    // ── TX-тракт и телеграф (проброшены в контроллер, см. doc/CAT_STATUS.md) ──
    function ZZCI(const s: string): string;
    function ZZCL(const s: string): string;
    function ZZCM(const s: string): string;
    function ZZET(const s: string): string;
    function ZZLI(const s: string): string;
    function ZZPK(const s: string): string;
    function ZZPL(const s: string): string;
    function ZZTH(const s: string): string;
    function ZZTL(const s: string): string;
    function ZZTM(const s: string): string;
    function ZZTO(const s: string): string;
    function ZZTP(const s: string): string;
    function ZZTU(const s: string): string;

  public
    constructor Create(const Ctx: TCATContext);
    destructor  Destroy; override;
    function  Parse(const Cmd: string): string;
  end;

implementation

{ ── Local helpers ────────────────────────────────────────────────────────── }

// Convert Boolean to CAT '1'/'0' string — avoids IfThen char-overload ambiguity
function B2C(B: Boolean): string; inline;
begin if B then Result := '1' else Result := '0'; end;

function IsParamless(const Pfx: string): Boolean;
// Кенвудовские команды, у которых и SET, и GET идут с пустым суффиксом
// Команды не принимают аргументов; любой хвост у них — ошибка
// формата, а не значение.
const
  NoArg: array[0..9] of string = (
    'BD', 'BU', 'DN', 'UP',   // диапазон и шаг настройки
    'TX', 'RX',               // передача/приём — тут цена ошибки самая высокая
    'QI', 'RC',               // быстрая память, сброс RIT
    'ID', 'IF');              // только чтение, аргумента не бывает
var i: Integer;
begin
  for i := Low(NoArg) to High(NoArg) do
    if Pfx = NoArg[i] then Exit(True);
  Result := False;
end;

{ ── Helpers ──────────────────────────────────────────────────────────────── }

function TCATEngine.Pad(V: Int64; W: Integer): string;
begin
  Result := IntToStr(Abs(V));
  while Length(Result) < W do Result := '0' + Result;
  if Length(Result) > W then Result := Copy(Result, Length(Result) - W + 1, W);
end;

function TCATEngine.SignedPad(V: Int64; W: Integer): string;
// Знаковое поле фиксированной ширины (ZZAR/ZZFL/ZZFH): знак + W цифр.
begin
  if V >= 0 then Result := '+' + Pad(V, W)
  else           Result := '-' + Pad(-V, W);
end;

function TCATEngine.FreqToStr(Hz: Double): string;
begin
  Result := Pad(Round(Hz), 11);
end;

function TCATEngine.StrToFreq(const S: string): Double;
var V: Int64;
begin
  if TryStrToInt64(S, V) then
    Result := V
  else
    Result := 0;
end;

function TCATEngine.ModeToK(M: Integer): Integer;
begin
  case M of
    MODE_LSB:  Result := 1;
    MODE_USB:  Result := 2;
    MODE_DSB:  Result := 2; // treated as USB for CAT compatibility
    MODE_CWU:  Result := 3;
    MODE_FM:   Result := 4;
    MODE_WFM:  Result := 4; // treated as FM
    MODE_DMR:  Result := 4; // DMR RF transport is narrow FM
    MODE_FMRAW: Result := 4; // raw discriminator is still FM transport
    MODE_AM:   Result := 5;
    MODE_SAM:  Result := 5; // treated as AM
    MODE_DIGU: Result := 6; // Kenwood FSK
    MODE_CWL:  Result := 7;
    MODE_DIGL: Result := 9; // Kenwood FSK-R
  else         Result := 2;
  end;
end;

function TCATEngine.KToMode(K: Integer): Integer;
begin
  case K of
    1: Result := MODE_LSB;
    2: Result := MODE_USB;
    3: Result := MODE_CWU;
    4: Result := MODE_FM;
    5: Result := MODE_AM;
    6: Result := MODE_DIGU; // FSK/RTTY
    7: Result := MODE_CWL;
    9: Result := MODE_DIGL; // FSK-R
  else Result := MODE_USB;
  end;
end;

function TCATEngine.SMeterRaw(dBm: Double): Integer;
var v: Double;
begin
  v := (dBm + 140.0) * 2.0;
  if v < 0   then v := 0;
  if v > 260 then v := 260;
  Result := Round(v);
end;

{ ── Safe getters (handle nil callbacks gracefully) ───────────────────────── }

function TCATEngine.SafeGetVfoA: Double;
begin
  if Assigned(FCtx.GetVfoA) then Result := FCtx.GetVfoA else Result := 14200000;
end;
function TCATEngine.SafeGetVfoB: Double;
begin
  if Assigned(FCtx.GetVfoB) then Result := FCtx.GetVfoB else Result := 14200000;
end;
function TCATEngine.SafeGetMode: Integer;
begin
  if Assigned(FCtx.GetMode) then Result := FCtx.GetMode else Result := MODE_USB;
end;
function TCATEngine.SafeGetVolume: Integer;
begin
  if Assigned(FCtx.GetVolume) then Result := FCtx.GetVolume else Result := 50;
end;
function TCATEngine.SafeGetDriveLevel: Integer;
begin
  if Assigned(FCtx.GetDriveLevel) then Result := FCtx.GetDriveLevel else Result := 50;
end;
function TCATEngine.SafeGetFilterIdx: Integer;
begin
  if Assigned(FCtx.GetFilterIdx) then Result := FCtx.GetFilterIdx else Result := 0;
end;
function TCATEngine.SafeGetAGCMode: Integer;
begin
  if Assigned(FCtx.GetAGCMode) then Result := FCtx.GetAGCMode else Result := 0;
end;
function TCATEngine.SafeGetNRMode: Integer;
begin
  if Assigned(FCtx.GetNRMode) then Result := FCtx.GetNRMode else Result := 0;
end;
function TCATEngine.SafeGetNBMode: Integer;
begin
  if Assigned(FCtx.GetNBMode) then Result := FCtx.GetNBMode else Result := 0;
end;
function TCATEngine.SafeGetSNB: Boolean;
begin
  if Assigned(FCtx.GetSNBEnabled) then Result := FCtx.GetSNBEnabled else Result := False;
end;
function TCATEngine.SafeGetANF: Boolean;
begin
  if Assigned(FCtx.GetANFEnabled) then Result := FCtx.GetANFEnabled else Result := False;
end;
function TCATEngine.SafeGetTX: Boolean;
begin
  if Assigned(FCtx.GetTransmitting) then Result := FCtx.GetTransmitting else Result := False;
end;
function TCATEngine.SafeGetRunning: Boolean;
begin
  if Assigned(FCtx.GetRunning) then Result := FCtx.GetRunning else Result := False;
end;
function TCATEngine.SafeGetSMeter: Double;
begin
  if Assigned(FCtx.GetSMeter) then Result := FCtx.GetSMeter else Result := -120;
end;
function TCATEngine.SafeGetBand: Integer;
begin
  if Assigned(FCtx.GetCurrentBand) then Result := FCtx.GetCurrentBand else Result := 5;
end;
function TCATEngine.SafeGetSplit: Boolean;
begin
  if Assigned(FCtx.GetSplit) then Result := FCtx.GetSplit else Result := False;
end;
function TCATEngine.SafeGetSquelchOn: Boolean;
begin
  if Assigned(FCtx.GetSquelchOn) then Result := FCtx.GetSquelchOn else Result := False;
end;
function TCATEngine.SafeGetSquelchLevel: Integer;
begin
  if Assigned(FCtx.GetSquelchLevel) then Result := FCtx.GetSquelchLevel else Result := 0;
end;
function TCATEngine.SafeGetCTCSSOn: Boolean;
begin
  if Assigned(FCtx.GetCTCSSOn) then Result := FCtx.GetCTCSSOn else Result := False;
end;
function TCATEngine.SafeGetCTCSSTone: Integer;
begin
  if Assigned(FCtx.GetCTCSSTone) then Result := FCtx.GetCTCSSTone else Result := 0;
end;
function TCATEngine.SafeGetFMRptDir: Integer;
begin
  if Assigned(FCtx.GetFMRptDir) then Result := FCtx.GetFMRptDir else Result := 0;
end;
function TCATEngine.SafeGetFMRptOffset: Double;
begin
  if Assigned(FCtx.GetFMRptOffset) then Result := FCtx.GetFMRptOffset else Result := 0;
end;
function TCATEngine.SafeGetFMStep: Integer;
begin
  if Assigned(FCtx.GetFMStep) then Result := FCtx.GetFMStep else Result := 0;
end;
function TCATEngine.SafeGetAGCTop: Integer;
begin
  if Assigned(FCtx.GetAGCTop) then Result := FCtx.GetAGCTop else Result := 0;
end;
function TCATEngine.SafeGetCTun: Boolean;
begin
  if Assigned(FCtx.GetCTun) then Result := FCtx.GetCTun else Result := False;
end;
function TCATEngine.SafeGetCWKeyerMode: Integer;
begin
  if Assigned(FCtx.GetCWKeyerMode) then Result := FCtx.GetCWKeyerMode else Result := 0;
end;
function TCATEngine.SafeGetCWPitch: Integer;
begin
  if Assigned(FCtx.GetCWPitch) then Result := FCtx.GetCWPitch else Result := 600;
end;
function TCATEngine.SafeGetCWHangTime: Integer;
begin
  if Assigned(FCtx.GetCWHangTime) then Result := FCtx.GetCWHangTime else Result := 300;
end;
function TCATEngine.SafeGetMicGain: Integer;
begin
  if Assigned(FCtx.GetMicGain) then Result := FCtx.GetMicGain else Result := 10;
end;
function TCATEngine.SafeGetTXFilterLow: Integer;
begin
  if Assigned(FCtx.GetTXFilterLow) then Result := FCtx.GetTXFilterLow else Result := 100;
end;
function TCATEngine.SafeGetTXFilterHigh: Integer;
begin
  if Assigned(FCtx.GetTXFilterHigh) then Result := FCtx.GetTXFilterHigh else Result := 3100;
end;
function TCATEngine.SafeGetCompGain: Integer;
begin
  if Assigned(FCtx.GetCompGain) then Result := FCtx.GetCompGain else Result := 0;
end;
function TCATEngine.SafeGetTUNLevel: Integer;
begin
  if Assigned(FCtx.GetTUNLevel) then Result := FCtx.GetTUNLevel else Result := 10;
end;
function TCATEngine.SafeGetFMDeviation: Integer;
begin
  if Assigned(FCtx.GetFMDeviation) then Result := FCtx.GetFMDeviation else Result := 5000;
end;
function TCATEngine.SafeGetTXProfile: Integer;
begin
  if Assigned(FCtx.GetTXProfile) then Result := FCtx.GetTXProfile else Result := 0;
end;
function TCATEngine.SafeGetMonitorLevel: Integer;
begin
  if Assigned(FCtx.GetMonitorLevel) then Result := FCtx.GetMonitorLevel else Result := 0;
end;
function TCATEngine.SafeGetBool(const F: TCATGetBoolFunc): Boolean;
begin
  if Assigned(F) then Result := F else Result := False;
end;

function TCATEngine.OnOff(const s: string; const Getter: TCATGetBoolFunc;
  const Setter: TCATSetBoolProc): string;
// Двухпозиционная команда «<PREFIX>0/1;». Формы SET и GET у всех одинаковы —
// держим разбор в одном месте, чтобы тела команд остались про смысл.
begin
  if Length(s) = 0 then Exit(B2C(SafeGetBool(Getter)));
  if (s <> '0') and (s <> '1') then Exit(CAT_ERROR);
  if Assigned(Setter) then Setter(s = '1');
  Result := '';
end;

function TCATEngine.NumField(const s: string; Width, Lo, Hi: Integer;
  const Getter: TCATGetIntFunc; const Setter: TCATSetIntProc): string;
// Числовое поле фиксированной ширины: SET клампует в [Lo..Hi],
// выход за границы не ошибка, а подрезка), GET дополняет нулями слева.
var v: Integer;
begin
  if Length(s) = 0 then
  begin
    if Assigned(Getter) then v := Getter else v := Lo;
    Exit(Pad(EnsureRange(v, Lo, Hi), Width));
  end;
  if Length(s) <> Width then Exit(CAT_ERROR);
  if not TryStrToInt(s, v) then Exit(CAT_ERROR);
  if Assigned(Setter) then Setter(EnsureRange(v, Lo, Hi));
  Result := '';
end;

function TCATEngine.StepIdxToHz(Idx: Integer): Double;
// Шаг настройки по протокольному индексу 0..14, результат в Гц.
const Steps: array[0..14] of Double = (
  1, 10, 25, 50, 100, 250, 500, 1000, 5000, 9000,
  10000, 100000, 250000, 500000, 1000000);
begin
  if (Idx < 0) or (Idx > 14) then Result := 0
  else Result := Steps[Idx];
end;

{ ── Constructor / Destructor ─────────────────────────────────────────────── }

constructor TCATEngine.Create(const Ctx: TCATContext);
begin
  inherited Create;
  FCtx  := Ctx;
  FLock := TCriticalSection.Create;
end;

destructor TCATEngine.Destroy;
begin
  FLock.Free;
  inherited;
end;

{ ══════════════════════════════════════════════════════════════════════════
  Parse — main entry point. Thread-safe, can be called from serial/TCP threads.
  ══════════════════════════════════════════════════════════════════════════ }

function TCATEngine.Parse(const Cmd: string): string;
var
  s, pfx, ext, sfx: string;
  tpos: Integer;
begin
  Result := '';
  s := Trim(Cmd);
  // strip leading semicolons (some loggers prepend them)
  while (Length(s) > 0) and (s[1] = ';') do Delete(s, 1, 1);
  if Length(s) < 3 then Exit(CAT_ERROR);

  tpos := Pos(';', s);
  if tpos < 3 then Exit(CAT_ERROR);

  pfx := UpperCase(Copy(s, 1, 2));

  FLock.Acquire;
  try
    if pfx = 'ZZ' then
    begin
      if tpos < 5 then Exit(CAT_ERROR);
      ext := UpperCase(Copy(s, 3, 2));
      sfx := Copy(s, 5, tpos - 5);
      Result := ParseZZ(ext, sfx);
    end else begin
      sfx := Copy(s, 3, tpos - 3);
      // Команды без параметров должны отбраковывать лишний суффикс в парсере
      // ДО вызова команды. Таблицы ширин у нас нет — держим список
      // здесь, иначе «TXfoo;» дошёл бы до CmdTX и поднял передачу.
      if (sfx <> '') and IsParamless(pfx) then Exit(CAT_ERROR);
      if      pfx = 'AG' then Result := CmdAG(sfx)
      else if pfx = 'AI' then Result := CmdAI(sfx)
      else if pfx = 'BD' then begin CmdBD; Result := ''; end
      else if pfx = 'BU' then begin CmdBU; Result := ''; end
      else if pfx = 'CN' then Result := CmdCN(sfx)
      else if pfx = 'CT' then Result := CmdCT(sfx)
      else if pfx = 'DN' then begin CmdDN; Result := ''; end
      else if pfx = 'FA' then Result := CmdFA(sfx)
      else if pfx = 'FB' then Result := CmdFB(sfx)
      else if pfx = 'FR' then Result := CmdFR(sfx)
      else if pfx = 'FT' then Result := CmdFT(sfx)
      else if pfx = 'FW' then Result := CmdFW(sfx)
      else if pfx = 'GT' then Result := CmdGT(sfx)
      else if pfx = 'ID' then Result := 'ID' + CmdID + ';'
      else if pfx = 'IF' then Result := 'IF' + CmdIF + ';'
      else if pfx = 'KS' then Result := CmdKS(sfx)
      else if pfx = 'KY' then Result := CmdKY(sfx)
      else if pfx = 'MD' then Result := CmdMD(sfx)
      else if pfx = 'MG' then Result := CmdMG(sfx)
      else if pfx = 'MO' then Result := CmdMO(sfx)
      else if pfx = 'NB' then Result := CmdNB(sfx)
      else if pfx = 'NT' then Result := CmdNT(sfx)
      else if pfx = 'OF' then Result := CmdOF(sfx)
      else if pfx = 'OS' then Result := CmdOS(sfx)
      else if pfx = 'PC' then Result := CmdPC(sfx)
      else if pfx = 'PR' then Result := CmdPR(sfx)
      else if pfx = 'PS' then Result := CmdPS(sfx)
      else if pfx = 'QI' then begin CmdQI; Result := ''; end
      else if pfx = 'RC' then begin CmdRC; Result := ''; end
      else if pfx = 'RD' then Result := CmdRD(sfx)
      else if pfx = 'RT' then Result := CmdRT(sfx)
      else if pfx = 'RU' then Result := CmdRU(sfx)
      else if pfx = 'RX' then begin CmdRX(sfx); Result := ''; end
      else if pfx = 'SH' then Result := CmdSH(sfx)
      else if pfx = 'SL' then Result := CmdSL(sfx)
      else if pfx = 'SM' then Result := CmdSM(sfx)
      else if pfx = 'SQ' then Result := CmdSQ(sfx)
      else if pfx = 'TX' then begin CmdTX(sfx); Result := ''; end
      else if pfx = 'UP' then begin CmdUP; Result := ''; end
      else if pfx = 'XT' then Result := CmdXT(sfx)
      else Result := CAT_ERROR;
      // Wrap standard command response
      if (Result <> '') and (Result <> CAT_ERROR) and (Result <> CAT_EXEC_ERROR) then
        if Copy(Result, 1, 2) <> pfx then
          Result := pfx + Result + ';';
    end;
  finally
    FLock.Release;
  end;
end;

{ ══════════════════════════════════════════════════════════════════════════
  Standard commands (non-ZZ)
  ══════════════════════════════════════════════════════════════════════════ }

function TCATEngine.CmdAG(const s: string): string;
// AG: SET=4 chars (0xxx), GET=1 char ('0'=RX1, '1'=RX2)
// Scale 0–255 (Kenwood) ↔ 0–100 (ewsdr)
var v: Integer;
begin
  if Length(s) = 4 then begin
    if not TryStrToInt(Copy(s, 2, 3), v) then Exit(CAT_ERROR);
    v := EnsureRange(Round(v / 2.55), 0, 100);
    if Assigned(FCtx.SetVolume) then FCtx.SetVolume(v);
    Result := '';
  end else if Length(s) = 1 then begin
    v := Round(SafeGetVolume / 0.392);
    Result := '0' + Pad(v, 3);
  end else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdAI(const s: string): string;
// AI: auto-information — maps to ZZAI
begin
  Result := ZZAI(s);
end;

function TCATEngine.CmdBD: string;
begin
  if Assigned(FCtx.DoBandDown) then FCtx.DoBandDown;
  Result := '';
end;

function TCATEngine.CmdBU: string;
begin
  if Assigned(FCtx.DoBandUp) then FCtx.DoBandUp;
  Result := '';
end;

function TCATEngine.CmdCN(const s: string): string;
// CN: CTCSS tone number (2 digits, 1-based 01..38 по таблице CTCSS_TONES).
var idx: Integer;
begin
  if Length(s) = 2 then begin
    if not TryStrToInt(s, idx) then Exit(CAT_ERROR);
    idx := EnsureRange(idx - 1, 0, 37);
    if Assigned(FCtx.SetCTCSSTone) then FCtx.SetCTCSSTone(idx);
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(EnsureRange(SafeGetCTCSSTone, 0, 37) + 1, 2)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdCT(const s: string): string;
// CT: включение CTCSS — обёртка над ZZTA.
begin
  Result := ZZTA(s);
end;

function TCATEngine.CmdDN: string;
begin
  if Assigned(FCtx.DoTuneDown) then FCtx.DoTuneDown;
  Result := '';
end;

function TCATEngine.CmdFA(const s: string): string;
// FA: VFO A frequency (11 digits, Hz)
begin
  if Length(s) = 11 then begin
    if Assigned(FCtx.SetVfoA) then FCtx.SetVfoA(StrToFreq(s));
    Result := '';
  end else if Length(s) = 0 then
    Result := FreqToStr(SafeGetVfoA)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdFB(const s: string): string;
// FB: VFO B frequency (11 digits, Hz)
begin
  if Length(s) = 11 then begin
    if Assigned(FCtx.SetVfoB) then FCtx.SetVfoB(StrToFreq(s));
    Result := '';
  end else if Length(s) = 0 then
    Result := FreqToStr(SafeGetVfoB)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdFR(const s: string): string;
// FR: receive VFO (0=VFO A, 1=VFO B) — выбирает активный приёмный VFO.
begin
  if Length(s) = 1 then begin
    if (s = '0') or (s = '1') then begin
      if Assigned(FCtx.SetActiveVfo) then FCtx.SetActiveVfo(StrToIntDef(s, 0));
      Result := '';
    end else
      Result := CAT_ERROR;
  end else if Length(s) = 0 then begin
    if Assigned(FCtx.GetActiveVfo) then
      Result := IntToStr(EnsureRange(FCtx.GetActiveVfo, 0, 1))
    else
      Result := '0';
  end else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdFT(const s: string): string;
// FT: transmit VFO / split — maps to ZZSP
begin
  Result := ZZSP(s);
end;

function TCATEngine.CmdFW(const s: string): string;
// FW: filter width (4 digits) — maps to filter index
// Filter index is encoded as a 4-digit string from 0000 to 0009.
var idx: Integer;
begin
  if Length(s) = 4 then begin
    if not TryStrToInt(s, idx) then Exit(CAT_ERROR);
    idx := EnsureRange(idx, 0, 9);
    if Assigned(FCtx.SetFilterIdx) then FCtx.SetFilterIdx(idx);
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(SafeGetFilterIdx, 4)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdGT(const s: string): string;
// GT: AGC mode (3 digits) — 0=FAST,1=MED,2=SLOW,3=LONG,4=OFF
var m: Integer;
begin
  if Length(s) = 3 then begin
    if not TryStrToInt(s, m) then Exit(CAT_ERROR);
    if Assigned(FCtx.SetAGCMode) then FCtx.SetAGCMode(EnsureRange(m, 0, 4));
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(SafeGetAGCMode, 3)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdID: string;
begin
  Result := CAT_ID_NUM;
end;

function TCATEngine.CmdIF: string;
// IF: составной статус — 35 символов ответа («37 байт» из
// прежнего комментария — это вся посылка вместе с префиксом и ';').
var
  freq, step, incr, rit, xit, tx, mode, split: string;
  m: Integer;
begin
  freq  := FreqToStr(SafeGetVfoA);
  step  := '0000';   // step size — FM step не маппится на Kenwood-формат IF (см. ZZST)
  incr  := '+00000'; // RIT/XIT value — STUB: no RIT/XIT
  rit   := '0';
  xit   := '0';
  m     := ModeToK(SafeGetMode);
  mode  := IntToStr(m);
  tx    := B2C(SafeGetTX);
  split := B2C(SafeGetSplit);
  Result := freq + step + incr + rit + xit + '000' + tx + mode + '0' + '0' + split + '0000';
end;

function TCATEngine.SafeGetCWSpeed: Integer;
begin
  if Assigned(FCtx.GetCWSpeed) then Result := FCtx.GetCWSpeed else Result := 20;
end;

function TCATEngine.CmdKS(const s: string): string;
// KS: CW keying speed (3 digits, WPM).
var v: Integer;
begin
  if Length(s) = 3 then
  begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);
    if Assigned(FCtx.SetCWSpeed) then FCtx.SetCWSpeed(v);
    Result := '';
  end
  else if Length(s) = 0 then
    Result := Format('%.3d', [SafeGetCWSpeed])
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdKY(const s: string): string;
// KY: передать текст телеграфом. Kenwood-семантика: первый символ — пробел-
// заполнитель признака «не занято», далее сам текст; пустой аргумент = опрос
// занятости ('1' — очередь занята, логгер ждёт).
var Txt: string;
begin
  if Length(s) = 0 then
  begin
    if Assigned(FCtx.CWTextBusy) and FCtx.CWTextBusy then Result := '1'
    else Result := '0';
    Exit;
  end;
  // Поле текста фиксированной ширины: 25 символов, добито пробелами справа.
  // Длиннее — ошибка формата: очередь передачи не должна расти произвольно,
  // иначе один пакет уводит станцию в эфир на неопределённое время.
  if Length(s) > CW_TEXT_FIELD then Exit(CAT_ERROR);
  Txt := s;
  if (Length(Txt) > 0) and (Txt[1] = ' ') then Delete(Txt, 1, 1);
  Txt := TrimRight(Txt);   // иначе хвост набивки ушёл бы в эфир паузами
  if Txt = '' then Exit('');
  if Assigned(FCtx.SendCWText) then FCtx.SendCWText(Txt);
  Result := '';
end;

function TCATEngine.CmdMD(const s: string): string;
// MD: mode (1 char Kenwood) — 1=LSB,2=USB,3=CW,4=FM,5=AM,7=CW-R
var k: Integer;
begin
  if Length(s) = 1 then begin
    k := StrToIntDef(s, 0);
    if (k >= 1) and Assigned(FCtx.SetMode) then
      FCtx.SetMode(KToMode(k));
    Result := '';
  end else if Length(s) = 0 then
    Result := IntToStr(ModeToK(SafeGetMode))
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdMG(const s: string): string;
// MG: усиление микрофона по-кенвудовски (000..100). Шкала 100:70 к дБ — как в
// n/1.43, дальше кламп под диапазон вкладки Transmit. Отрицательные дБ
// в кенвудовскую шкалу не ложатся: на чтении подрезаем к нулю.
var v: Integer;
begin
  if Length(s) = 0 then
    Result := Pad(EnsureRange(Round(SafeGetMicGain / 0.7), 0, 100), 3)
  else if Length(s) = 3 then
  begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);
    if Assigned(FCtx.SetMicGain) then
      FCtx.SetMicGain(EnsureRange(Round(EnsureRange(v, 0, 100) / 1.43),
                                  MIC_GAIN_MIN, MIC_GAIN_MAX));
    Result := '';
  end
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdMO(const s: string): string;
// MO: монитор передачи — то же, что ZZMO.
begin
  Result := ZZMO(s);
end;

function TCATEngine.CmdNB(const s: string): string;
// NB: noise blanker on/off → maps to NBMode
var v: Integer;
begin
  if Length(s) = 1 then begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);
    if Assigned(FCtx.SetNBMode) then FCtx.SetNBMode(v);
    Result := '';
  end else if Length(s) = 0 then
    Result := IntToStr(EnsureRange(SafeGetNBMode, 0, 1))
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdNT(const s: string): string;
// NT: notch (ANF) on/off
begin
  if Length(s) = 1 then begin
    if Assigned(FCtx.SetANFEnabled) then FCtx.SetANFEnabled(s = '1');
    Result := '';
  end else if Length(s) = 0 then
    Result := B2C(SafeGetANF)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdOF(const s: string): string;
// OF: сдвиг репитера — обёртка над ZZOT.
begin
  Result := ZZOT(s);
end;

function TCATEngine.CmdOS(const s: string): string;
// OS: направление сдвига репитера — обёртка над ZZOS.
begin
  Result := ZZOS(s);
end;

function TCATEngine.ZZOS(const s: string): string;
// ZZOS: направление сдвига репитера. В протоколе 0=симплекс, 1=вверх,
// 2=вниз; внутри у нас RPT_NONE/MINUS/PLUS — отсюда трансляция.
// ★Некорректное значение трактуется как симплекс для совместимости клиентов.
var dir: Integer;
begin
  if Length(s) = 1 then begin
    case StrToIntDef(s, 0) of
      1: dir := RPT_PLUS;
      2: dir := RPT_MINUS;
    else dir := RPT_NONE;
    end;
    if Assigned(FCtx.SetFMRptDir) then FCtx.SetFMRptDir(dir);
    Result := '';
  end else if Length(s) = 0 then begin
    case SafeGetFMRptDir of
      RPT_PLUS:  Result := '1';
      RPT_MINUS: Result := '2';
    else Result := '0';
    end;
  end else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZOT(const s: string): string;
// ZZOT: величина сдвига репитера, 9 цифр. Поле кодирует МГц с шестью знаками
// после запятой, что численно соответствует целому значению в герцах. Знак не
// передаётся, направление задаётся в ZZOS.
var v: Int64;
begin
  if Length(s) = 9 then begin
    // Нечисловое поле — ошибка формата: StrToIntDef молча обнулил бы сдвиг.
    if not TryStrToInt64(s, v) then Exit(CAT_ERROR);
    if Assigned(FCtx.SetFMRptOffset) then FCtx.SetFMRptOffset(v);
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(Round(SafeGetFMRptOffset), 9)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdPC(const s: string): string;
// PC: TX power 0-100 % (3 digits) → DriveLevel
var v: Integer;
begin
  if Length(s) = 3 then begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);
    v := EnsureRange(v, 0, 100);
    if Assigned(FCtx.SetDriveLevel) then FCtx.SetDriveLevel(v);
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(SafeGetDriveLevel, 3)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdPR(const s: string): string;
// PR: речевой компрессор вкл/выкл. Базовая команда доступна только для чтения;
// у нас компрессор настоящий, поэтому SET тоже работает — расширение вверх,
// кенвудовские клиенты от него не ломаются.
begin
  Result := OnOff(s, FCtx.GetCompOn, FCtx.SetCompOn);
end;

function TCATEngine.CmdPS(const s: string): string;
// PS: power status
begin
  if Length(s) = 1 then Result := ''  // accept but ignore
  else if Length(s) = 0 then
    Result := B2C(SafeGetRunning)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdQI: string;
// QI: store VFO A to quick memory — STUB
begin
  Result := '';
end;

function TCATEngine.CmdRC: string;
// RC: clear RIT — STUB: no RIT
begin
  Result := '';
end;

function TCATEngine.CmdRD(const s: string): string;
// RD: убавить RIT (аргумент — 5 цифр смещения) — STUB: расстройки приёма нет.
// Шаг настройки вниз — это DN, перестройка — FA/FB.
begin
  if (Length(s) = 0) or (Length(s) = 5) then Result := '' else Result := CAT_ERROR;
end;

function TCATEngine.CmdRT(const s: string): string;
// RT: RIT on/off — STUB: no RIT
begin
  if Length(s) = 1 then Result := ''
  else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

function TCATEngine.CmdRU(const s: string): string;
// RU: прибавить RIT — STUB, см. RD.
begin
  if (Length(s) = 0) or (Length(s) = 5) then Result := '' else Result := CAT_ERROR;
end;

function TCATEngine.CmdRX(const s: string): string;
// RX: switch to receive
begin
  if Assigned(FCtx.SetTransmitting) then FCtx.SetTransmitting(False);
  Result := '';
end;

function TCATEngine.CmdSH(const s: string): string;
// SH: верхняя кромка фильтра, индекс пресета (2 цифры). ⚠ У нас SH и SL ходят
// в ОДИН и тот же индекс пресета: раздельных «сторон» у главного приёмника нет.
// Кромки в герцах — это ZZFL/ZZFH.
var idx: Integer;
begin
  if Length(s) = 2 then begin
    if not TryStrToInt(s, idx) then Exit(CAT_ERROR);
    idx := EnsureRange(idx, 0, 9);
    if Assigned(FCtx.SetFilterIdx) then FCtx.SetFilterIdx(idx);
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(SafeGetFilterIdx, 2)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdSL(const s: string): string;
// SL: нижняя кромка фильтра — тот же индекс пресета, что и SH (см. оговорку там).
begin
  Result := CmdSH(s);
end;

function TCATEngine.CmdSM(const s: string): string;
// SM: S-метр. Запрос — один символ-селектор приёмника, ответ — ПЯТЬ цифр
// (шкала Kenwood 00000..00030: до 00015 это S0..S9, дальше +1 за каждый дБ).
// Совместимые клиенты присылают селектор '0', '1' или '2'; принимаем все:
// второго приёмника нет, отвечаем одним и тем же.
var sm: Integer;
begin
  if (Length(s) = 1) and (s[1] in ['0', '1', '2']) then begin
    sm := SMeterRaw(SafeGetSMeter);
    sm := Round(sm * 30 / 260);      // 0..260 (шкала ZZSM) → 0..30
    Result := Pad(sm, 5);
  end else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdSQ(const s: string): string;
// SQ: squelch. SET: <sel><lll> (sel=0/1 RX, lll=000–255). GET: <sel> → <sel><lll>.
// Шкала Kenwood 0–255 маппится на внутренний уровень 0–100 %.
var lvl255, lvl100: Integer;
begin
  if Length(s) = 4 then begin
    if not TryStrToInt(Copy(s, 2, 3), lvl255) then Exit(CAT_ERROR);
    lvl255 := EnsureRange(lvl255, 0, 255);
    lvl100 := Round(lvl255 * 100 / 255);
    if Assigned(FCtx.SetSquelchLevel) then FCtx.SetSquelchLevel(lvl100);
    if Assigned(FCtx.SetSquelchOn)    then FCtx.SetSquelchOn(lvl100 > 0);
    Result := '';
  end else if Length(s) = 1 then begin
    lvl255 := Round(EnsureRange(SafeGetSquelchLevel, 0, 100) * 255 / 100);
    Result := s + Pad(lvl255, 3);
  end else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdTX(const s: string): string;
// TX: switch to transmit
begin
  if Assigned(FCtx.SetTransmitting) then FCtx.SetTransmitting(True);
  Result := '';
end;

function TCATEngine.CmdUP: string;
begin
  if Assigned(FCtx.DoTuneUp) then FCtx.DoTuneUp;
  Result := '';
end;

function TCATEngine.CmdXT(const s: string): string;
// XT: XIT on/off — STUB: no XIT
begin
  if Length(s) = 1 then Result := ''
  else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

{ ══════════════════════════════════════════════════════════════════════════
  ZZ* extended commands dispatcher
  ══════════════════════════════════════════════════════════════════════════ }

function TCATEngine.ParseZZ(const ext, s: string): string;
var ans: string;
begin
  ans := '';
  if      ext = 'AA' then ans := ZZAA(s)
  else if ext = 'AB' then ans := ZZAB(s)
  else if ext = 'AC' then ans := ZZAC(s)
  else if ext = 'AD' then ans := '' // STUB: сдвиг VFO A вниз на nn шагов — см. ZZAU (вверх)
  else if ext = 'AE' then ans := '' // STUB: VFO A вниз на nn пресетных шагов — нет глобального шага, см. ZZAC
  else if ext = 'AF' then ans := '' // STUB: VFO A вверх на nn пресетных шагов — см. ZZAE
  else if ext = 'AG' then ans := ZZAG(s)
  else if ext = 'AI' then ans := ZZAI(s)
  else if ext = 'AP' then ans := ZZAP(s)
  else if ext = 'AR' then ans := ZZAR(s)
  else if ext = 'AS' then ans := ZZAS(s)
  else if ext = 'AT' then ans := ZZAT(s)
  else if ext = 'AU' then ans := ZZAU(s)
  else if ext = 'AY' then ans := '' // STUB: тип APF — нет аудиопикового фильтра
  else if ext = 'BA' then ans := ZZBA
  else if ext = 'BB' then ans := '' // STUB: bandswitch RX2 вверх — RX2 нет
  else if ext = 'BD' then begin ZZBD; ans := ''; end
  else if ext = 'BE' then ans := '' // STUB: VFO B вниз на nn пресетных шагов — см. ZZAE
  else if ext = 'BF' then ans := '' // STUB: VFO B вверх на nn пресетных шагов — см. ZZAE
  else if ext = 'BG' then ans := '' // STUB: группа диапазонов HF/VHF — не разделяем
  else if ext = 'BI' then ans := ZZBI(s)
  else if ext = 'BM' then ans := ZZBM(s)
  else if ext = 'BP' then ans := ZZBP(s)
  else if ext = 'BR' then ans := '' // STUB: кнопка BCI Rejection — такого фильтра нет
  else if ext = 'BS' then ans := ZZBS(s)
  else if ext = 'BT' then ans := '' // STUB: диапазон RX2 — RX2 нет
  else if ext = 'BU' then begin ZZBU; ans := ''; end
  else if ext = 'BY' then ans := '' // STUB: выключение консоли — по CAT не гасимся
  else if ext = 'CB' then ans := ZZCB(s)
  else if ext = 'CD' then ans := ZZCD(s)
  else if ext = 'CF' then ans := '' // STUB: показывать частоту телеграфа в UI
  else if ext = 'CI' then ans := ZZCI(s)
  else if ext = 'CL' then ans := ZZCL(s)
  else if ext = 'CM' then ans := ZZCM(s)
  else if ext = 'CN' then ans := ZZCN(s)
  else if ext = 'CO' then ans := '' // STUB: CTUN для RX2 — RX2 нет (CTUN RX1 — это ZZCN)
  else if ext = 'CP' then ans := '' // STUB: компандер (у нас его нет — есть COMP, см. ZZPK)
  else if ext = 'CS' then ans := ZZCS(s)
  else if ext = 'CT' then ans := '' // STUB: порог компандера
  else if ext = 'CU' then begin ZZDU; ans := ''; end
  else if ext = 'DA' then ans := ZZDA(s)
  else if ext = 'DB' then ans := ZZDB(s)
  else if ext = 'DC' then ans := '' // STUB: усиление RX2 в окне Diversity — диверсити нет
  else if ext = 'DD' then ans := '' // STUB: фаза в окне Diversity — диверсити нет
  else if ext = 'DE' then ans := '' // STUB: кнопка Enable в окне Diversity — диверсити нет
  else if ext = 'DF' then ans := '' // STUB: открыть/закрыть окно Diversity — диверсити нет
  else if ext = 'DG' then ans := '' // STUB: усиление RX в окне Diversity — диверсити нет
  else if ext = 'DH' then ans := '' // STUB: источник RX в окне Diversity — диверсити нет
  else if ext = 'DM' then ans := ZZDM(s)
  else if ext = 'DN' then ans := ZZDN(s)
  else if ext = 'DO' then ans := '' // STUB: верхний уровень водопада — не проброшено (см. ZZDN)
  else if ext = 'DP' then ans := '' // STUB: верх сетки спектра — не проброшено
  else if ext = 'DQ' then ans := '' // STUB: низ сетки спектра — не проброшено
  else if ext = 'DR' then ans := '' // STUB: шаг сетки спектра — не проброшено
  else if ext = 'DU' then begin ZZDU; ans := ''; end
  else if ext = 'DX' then ans := '' // STUB: кнопка DX (споты кластера) — флаг живёт в UI, не в контроллере
  else if ext = 'EA' then ans := ZZEA(s)
  else if ext = 'EB' then ans := ZZEB(s)
  else if ext = 'EM' then ans := ZZEM(s)
  else if ext = 'ER' then ans := '' // STUB: кнопка RXEQ — эквалайзер приёма
  else if ext = 'ET' then ans := ZZET(s)
  else if ext = 'FA' then ans := ZZFA(s)
  else if ext = 'FB' then ans := ZZFB(s)
  else if ext = 'FD' then ans := ZZFD(s)
  else if ext = 'FH' then ans := ZZFH(s)
  else if ext = 'FI' then ans := ZZFI(s)
  else if ext = 'FJ' then ans := '' // STUB: фильтр RX2 — RX2 нет
  else if ext = 'FL' then ans := ZZFL(s)
  else if ext = 'FM' then ans := ZZFM
  else if ext = 'FR' then ans := '' // STUB: верхняя кромка фильтра RX2 — RX2 нет
  else if ext = 'FS' then ans := '' // STUB: нижняя кромка фильтра RX2 — RX2 нет
  else if ext = 'FT' then ans := ZZFT(s)
  else if ext = 'FV' then ans := '' // STUB: чтение вендорской шины (1 байт)
  else if ext = 'FW' then ans := '' // STUB: чтение вендорской шины (2 байта)
  else if ext = 'FX' then ans := '' // STUB: запись вендорской шины (1 байт)
  else if ext = 'FY' then ans := '' // STUB: запись вендорской шины (2 байта)
  else if ext = 'GA' then ans := ZZGA(s)
  else if ext = 'GE' then ans := '' // STUB: кнопка noise gate — такого блока нет
  else if ext = 'GL' then ans := '' // STUB: уровень noise gate — такого блока нет
  else if ext = 'GR' then ans := ZZGR(s)
  else if ext = 'GT' then ans := ZZGT(s)
  else if ext = 'GU' then ans := '' // STUB: постоянная AGC для RX2 — RX2 нет
  else if ext = 'HA' then ans := '' // STUB: размер аудиобуфера — по CAT не крутим
  else if ext = 'HR' then ans := '' // STUB: размер буфера DSP RX (телефония) — по CAT не крутим
  else if ext = 'HT' then ans := '' // STUB: размер буфера DSP TX (телефония) — по CAT не крутим
  else if ext = 'HU' then ans := '' // STUB: размер буфера DSP RX (CW) — по CAT не крутим
  else if ext = 'HV' then ans := '' // STUB: размер буфера DSP TX (CW) — по CAT не крутим
  else if ext = 'HW' then ans := '' // STUB: размер буфера DSP RX (digital) — по CAT не крутим
  else if ext = 'HX' then ans := '' // STUB: размер буфера DSP TX (digital) — по CAT не крутим
  else if ext = 'ID' then ans := ZZID
  else if ext = 'IF' then ans := ZZIF(s)
  else if ext = 'IO' then ans := ZZIO
  else if ext = 'IS' then ans := ZZIS(s)
  else if ext = 'IT' then ans := '' // STUB: сдвиг IF — полосу двигаем кромками (ZZFL/ZZFH)
  else if ext = 'IU' then ans := '' // STUB: сброс сдвига фильтра — см. ZZIT
  else if ext = 'JO' then ans := '' // STUB: команда не описана
  else if ext = 'JP' then ans := '' // STUB: служебная JSON-команда
  else if ext = 'JQ' then ans := '' // STUB: служебная JSON-команда
  else if ext = 'JR' then ans := '' // STUB: служебная JSON-команда
  else if ext = 'JS' then ans := '' // STUB: служебная JSON-команда
  else if ext = 'KM' then ans := ZZKM(s)
  else if ext = 'KO' then ans := '' // STUB: команда не описана
  else if ext = 'KS' then ans := ZZKS(s)
  else if ext = 'KY' then ans := ZZKY(s)
  else if ext = 'LA' then ans := '' // STUB: уровень RX0 внешнего микшера
  else if ext = 'LB' then ans := '' // STUB: стереобаланс главного приёмника — микшера нет
  else if ext = 'LC' then ans := '' // STUB: уровень sub-RX — sub-RX нет
  else if ext = 'LD' then ans := '' // STUB: стереобаланс sub-RX — sub-RX нет
  else if ext = 'LE' then ans := '' // STUB: уровень RX2 — RX2 нет
  else if ext = 'LF' then ans := '' // STUB: стереобаланс RX2 — RX2 нет
  else if ext = 'LG' then ans := '' // STUB: автомьют RX1 при TX на VFO B — такой автоматики нет
  else if ext = 'LH' then ans := '' // STUB: автомьют RX2 при TX на VFO A — RX2 нет
  else if ext = 'LI' then ans := ZZLI(s)
  else if ext = 'MA' then ans := ZZMA(s)
  else if ext = 'MB' then ans := ZZMB(s)
  else if ext = 'MD' then ans := ZZMD(s)
  else if ext = 'ME' then ans := '' // STUB: команда не описана
  else if ext = 'MF' then ans := '' // STUB: текст на LCD мультифункционального энкодера Andromeda — не реализовано
  else if ext = 'MG' then ans := ZZMG(s)
  else if ext = 'ML' then ans := ZZML
  else if ext = 'MN' then ans := ZZMN(s)
  else if ext = 'MO' then ans := ZZMO(s)
  else if ext = 'MR' then ans := '' // STUB: режим измерителя приёма — метр один
  else if ext = 'MS' then ans := '' // STUB: галка MultiRX Swap — MultiRX нет
  else if ext = 'MT' then ans := '' // STUB: режим измерителя передачи — метр один
  else if ext = 'MU' then ans := '' // STUB: кнопка MultiRX — MultiRX нет
  else if ext = 'MV' then ans := ZZMV
  else if ext = 'MW' then ans := '' // STUB: удалить канал памяти — память в CAT не проброшена
  else if ext = 'MX' then ans := '' // STUB: восстановить канал памяти n — см. ZZMW
  else if ext = 'MY' then ans := ZZMY
  else if ext = 'MZ' then ans := '' // STUB: записать конфигурацию в канал памяти n — см. ZZMW
  else if ext = 'NA' then ans := ZZNA(s)
  else if ext = 'NB' then ans := ZZNB(s)
  else if ext = 'NC' then ans := '' // STUB: NB для RX2 — RX2 нет
  else if ext = 'ND' then ans := '' // STUB: NB2 для RX2 — RX2 нет
  else if ext = 'NE' then ans := '' // STUB: статус NR RX1 (0..4) — дублирует ZZNR, не проброшено
  else if ext = 'NF' then ans := '' // STUB: статус NR для RX2 — RX2 нет
  else if ext = 'NG' then ans := '' // STUB: глубина подавления NR4 — такого алгоритма нет
  else if ext = 'NH' then ans := '' // STUB: глубина подавления NR4 (вторая) — см. ZZNG
  else if ext = 'NL' then ans := ZZNL(s)
  else if ext = 'NM' then ans := '' // STUB: порог NB2 — порог бланкера не вынесен в настройки
  else if ext = 'NN' then ans := ZZNN(s)
  else if ext = 'NO' then ans := '' // STUB: спектральный бланкер RX2 — RX2 нет (RX1 — это ZZNN)
  else if ext = 'NR' then ans := ZZNR(s)
  else if ext = 'NS' then ans := ZZNS(s)
  else if ext = 'NT' then ans := ZZNT(s)
  else if ext = 'NU' then ans := '' // STUB: ANF для RX2 — RX2 нет
  else if ext = 'NV' then ans := '' // STUB: статус шумоподавителя — дублирует ZZNR
  else if ext = 'NW' then ans := '' // STUB: кнопка NR2 для RX2 — RX2 нет
  else if ext = 'OA' then ans := ZZOA(s)
  else if ext = 'OB' then ans := ZZOB(s)
  else if ext = 'OC' then ans := '' // STUB: антенна передачи — порты AD936x в CAT не проброшены
  else if ext = 'OD' then ans := '' // STUB: режим антенн — см. ZZOC
  else if ext = 'OE' then ans := '' // STUB: галка внешней антенны RX1 — см. ZZOC
  else if ext = 'OF' then ans := '' // STUB: реле TX на разъёме RCA — управления реле нет
  else if ext = 'OG' then ans := '' // STUB: разрешение задержек реле TX — см. ZZOF
  else if ext = 'OH' then ans := '' // STUB: величины задержек реле TX — см. ZZOF
  else if ext = 'OJ' then ans := '' // STUB: команда не описана
  else if ext = 'OL' then ans := '' // STUB: click-tune офсет для DIGL — офсетов режима нет
  else if ext = 'OS' then ans := ZZOS(s)
  else if ext = 'OT' then ans := ZZOT(s)
  else if ext = 'OU' then ans := '' // STUB: click-tune офсет для DIGU — см. ZZOL
  else if ext = 'OV' then ans := '' // STUB: кнопка ATU — тюнера нет
  else if ext = 'OW' then ans := '' // STUB: кнопка ATU Bypass — тюнера нет
  else if ext = 'OX' then ans := '' // STUB: состояние настройки ATU Aries — тюнера нет
  else if ext = 'OZ' then ans := '' // STUB: сброс состояния ATU Aries — тюнера нет
  else if ext = 'PA' then ans := '' // STUB: преамп/аттенюатор — не проброшено (аттенюатор RX1 — ZZRX)
  else if ext = 'PB' then ans := '' // STUB: преамп RX2 — RX2 нет
  else if ext = 'PC' then ans := ZZPC(s)
  else if ext = 'PD' then ans := ZZPD
  else if ext = 'PE' then ans := '' // STUB: пан дисплея — не проброшено
  else if ext = 'PK' then ans := ZZPK(s)
  else if ext = 'PL' then ans := ZZPL(s)
  else if ext = 'PO' then ans := ZZPO(s)
  else if ext = 'PS' then ans := ZZPS(s)
  else if ext = 'PY' then ans := '' // STUB: зум дисплея — не проброшено
  else if ext = 'PZ' then ans := '' // STUB: кнопки зума дисплея — не проброшено
  // ZZQK различает semi и QSK; у нас break-in один
  // (прошивочный), а разница semi/QSK выражается hang-time'ом — ZZCD.
  else if ext = 'QK' then ans := '' // STUB: нет отдельного переключателя semi/QSK
  else if ext = 'QM' then ans := ZZQM
  else if ext = 'QR' then ans := ZZQR
  else if ext = 'QS' then ans := ZZQS
  else if ext = 'RA' then ans := ZZRA(s)
  else if ext = 'RB' then ans := '' // STUB: галка RTTY-офсета для VFO B — RTTY-движка нет
  else if ext = 'RC' then begin ZZRC; ans := ''; end
  else if ext = 'RD' then begin ZZRD(s); ans := ''; end
  else if ext = 'RF' then ans := '' // STUB: величина RIT — расстройки приёма нет
  else if ext = 'RH' then ans := '' // STUB: частота офсета RTTY DIGH — RTTY-движка нет
  else if ext = 'RL' then ans := ZZRL(s)
  else if ext = 'RM' then ans := '' // STUB: показание измерителя приёма — читается через ZZSM
  else if ext = 'RS' then ans := '' // STUB: кнопка RX2 — RX2 нет
  else if ext = 'RT' then ans := '' // STUB: кнопка RIT — расстройки приёма нет
  else if ext = 'RU' then begin ZZRU(s); ans := ''; end
  else if ext = 'RV' then ans := ZZRV
  else if ext = 'RX' then ans := ZZRX(s)
  else if ext = 'RY' then ans := '' // STUB: шаговый аттенюатор RX2 — RX2 нет (RX1 — это ZZRX)
  else if ext = 'SA' then begin ZZSA; ans := ''; end
  else if ext = 'SB' then begin ZZSB; ans := ''; end
  else if ext = 'SD' then ans := '' // STUB: шаг настройки вниз колесом — это ZZSA
  else if ext = 'SF' then ans := '' // STUB: фильтр «центр+ширина» одной командой — не проброшено
  else if ext = 'SG' then ans := '' // STUB: VFO B вниз на один шаг — не проброшено (по индексу — ZZBM)
  else if ext = 'SH' then ans := '' // STUB: VFO B вверх на один шаг — не проброшено (по индексу — ZZBP)
  else if ext = 'SM' then ans := ZZSM(s)
  else if ext = 'SN' then ans := '' // STUB: серийный номер радио — не проброшено
  else if ext = 'SO' then ans := ZZSO(s)
  else if ext = 'SP' then ans := ZZSP(s)
  else if ext = 'SQ' then ans := ZZSQ(s)
  else if ext = 'SR' then ans := ZZSR(s)
  else if ext = 'SS' then ans := '' // STUB: команда не описана
  else if ext = 'ST' then ans := ZZST(s)
  else if ext = 'SU' then begin ZZSU; ans := ''; end
  else if ext = 'SV' then ans := '' // STUB: шумоподавитель RX2 — RX2 нет
  else if ext = 'SW' then ans := '' // STUB: обмен кнопок TX VFO A/B — не проброшено (обмен VFO — ZZVS)
  else if ext = 'SX' then ans := '' // STUB: порог шумоподавителя RX2 — RX2 нет
  else if ext = 'SY' then ans := '' // STUB: кнопка VFO Sync — синхронизации VFO нет
  else if ext = 'SZ' then ans := '' // STUB: подгон VFO под шаг настройки — см. ZZSY
  else if ext = 'TA' then ans := ZZTA(s)
  else if ext = 'TB' then ans := ZZTB(s)
  else if ext = 'TF' then ans := '' // STUB: показывать полосу TX на спектре
  else if ext = 'TH' then ans := ZZTH(s)
  else if ext = 'TI' then ans := '' // STUB: запрет выхода мощности (внешние антенны/тюнер)
  else if ext = 'TL' then ans := ZZTL(s)
  else if ext = 'TM' then ans := ZZTM(s)
  else if ext = 'TO' then ans := ZZTO(s)
  else if ext = 'TP' then ans := ZZTP(s)
  else if ext = 'TS' then ans := '' // STUB: неподдерживаемый датчик температуры
  else if ext = 'TU' then ans := ZZTU(s)
  else if ext = 'TV' then ans := '' // STUB: TX-частота при split с RX2
  else if ext = 'TX' then ans := ZZTX(s)
  else if ext = 'UA' then ans := '' // STUB: имена диапазонов трансвертера — не проброшено
  else if ext = 'UP' then ans := ZZUP(s)
  else if ext = 'US' then ans := ZZUS
  else if ext = 'UT' then ans := ZZUT(s)
  else if ext = 'UX' then ans := ZZUX(s)
  else if ext = 'UY' then ans := ZZUY(s)
  else if ext = 'VA' then ans := ZZVA(s)
  else if ext = 'VB' then ans := '' // STUB: усиление приёма в VAC — VAC нет
  else if ext = 'VC' then ans := '' // STUB: усиление передачи в VAC — VAC нет
  else if ext = 'VD' then ans := '' // STUB: частота дискретизации VAC — VAC нет
  else if ext = 'VE' then ans := '' // STUB: кнопка VOX — VOX нет
  else if ext = 'VF' then ans := ZZFA(s) // alias ZZFA
  else if ext = 'VG' then ans := ZZVG(s)
  else if ext = 'VH' then ans := '' // STUB: галка I/Q в VAC — VAC нет
  else if ext = 'VI' then ans := '' // STUB: входной кабель VAC — VAC нет
  else if ext = 'VJ' then ans := '' // STUB: галка Direct I/Q Use RX2 — RX2 нет
  else if ext = 'VK' then ans := '' // STUB: галка VAC2 Enable — VAC нет
  else if ext = 'VL' then ans := '' // STUB: кнопка блокировки VFO — не проброшено (SetVfoLock есть)
  else if ext = 'VM' then ans := '' // STUB: драйвер VAC — VAC нет
  else if ext = 'VN' then ans := ZZVN
  else if ext = 'VO' then ans := '' // STUB: выходной кабель VAC — VAC нет
  else if ext = 'VP' then ans := '' // STUB: галка VAC1 IQ Calibrate — VAC нет
  else if ext = 'VQ' then ans := '' // STUB: драйвер VAC2 — VAC нет
  else if ext = 'VR' then ans := '' // STUB: входной кабель VAC2 — VAC нет
  else if ext = 'VS' then ans := ZZVS(s)
  else if ext = 'VT' then ans := '' // STUB: выходной кабель VAC2 — VAC нет
  else if ext = 'VU' then ans := '' // STUB: частота дискретизации VAC2 — VAC нет
  else if ext = 'VV' then ans := '' // STUB: галка VAC2 Stereo — VAC нет
  else if ext = 'VW' then ans := '' // STUB: команда не описана
  else if ext = 'VX' then ans := '' // STUB: усиление передачи в VAC2 — VAC нет
  else if ext = 'VY' then ans := '' // STUB: размер буфера VAC1 — VAC нет
  else if ext = 'VZ' then ans := '' // STUB: размер буфера VAC1 (второй) — VAC нет
  else if ext = 'WA' then ans := ZZWA(s)
  else if ext = 'WB' then ans := ZZWB(s)
  else if ext = 'WC' then ans := ZZWC(s)
  else if ext = 'WD' then ans := ZZWD(s)
  else if ext = 'WE' then ans := ZZWE(s)
  else if ext = 'WF' then ans := ZZWF(s)
  else if ext = 'WG' then ans := ZZWG(s)
  else if ext = 'WH' then ans := ZZWH(s)
  else if ext = 'WJ' then ans := ZZWJ(s)
  else if ext = 'WK' then ans := ZZWK(s)
  else if ext = 'WL' then ans := ZZWL(s)
  else if ext = 'WM' then ans := ZZWM(s)
  else if ext = 'WN' then ans := ZZWN(s)
  else if ext = 'WO' then ans := ZZWO(s)
  else if ext = 'WP' then ans := ZZWP(s)
  else if ext = 'WQ' then ans := ZZWQ(s)
  else if ext = 'WR' then ans := ZZWR(s)
  else if ext = 'WS' then ans := ZZWS(s)
  else if ext = 'WT' then ans := '' // STUB: уровень микрофона в микшере F1500 — железо другого вендора
  else if ext = 'WU' then ans := '' // STUB: уровень входа FireWire в микшере F1500 — см. ZZWT
  else if ext = 'WV' then ans := '' // STUB: уровень наушников в микшере F1500 — см. ZZWT
  else if ext = 'WW' then ans := '' // STUB: уровень выхода вендорской шины — см. ZZWT
  else if ext = 'XA' then ans := ZZXA(s)
  else if ext = 'XC' then ans := ZZXC
  else if ext = 'XD' then ans := '' // STUB: убавить XIT — расстройки передачи нет
  else if ext = 'XF' then ans := ZZXF(s)
  else if ext = 'XH' then ans := '' // STUB: задержка VOX — VOX нет
  else if ext = 'XN' then ans := '' // STUB: составной статус RX1 — не собираем
  else if ext = 'XO' then ans := '' // STUB: составной статус RX2 — RX2 нет
  else if ext = 'XS' then ans := '' // STUB: кнопка XIT — расстройки передачи нет
  else if ext = 'XT' then ans := '' // STUB: кнопка X2TR — такого режима нет
  else if ext = 'XU' then ans := '' // STUB: прибавить XIT — расстройки передачи нет
  else if ext = 'XV' then ans := '' // STUB: составной статус VFO — не собираем
  else if ext = 'YA' then ans := ZZYA(s)
  else if ext = 'YB' then ans := ZZYB(s)
  else if ext = 'YC' then ans := ZZYC(s)
  else if ext = 'YR' then ans := ZZYR(s)
  else if ext = 'ZA' then ans := ZZZA(s)
  else if ext = 'ZB' then begin ZZZB; ans := ''; end
  else if ext = 'ZD' then ans := ZZZD(s)
  else if ext = 'ZE' then ans := ZZZE(s)
  else if ext = 'ZM' then ans := ZZZM(s)
  else if ext = 'ZN' then ans := '' // STUB: быстрый split вкл/выкл — не проброшено
  else if ext = 'ZO' then ans := ZZZO(s)
  else if ext = 'ZP' then ans := ZZZP(s)
  else if ext = 'ZQ' then ans := ZZZQ(s)
  else if ext = 'ZR' then ans := ZZZR(s)
  else if ext = 'ZS' then ans := ZZZS(s)
  else if ext = 'ZT' then ans := ZZZT(s)
  else if ext = 'ZU' then ans := ZZZU(s)
  else if ext = 'ZV' then ans := ZZZV(s)
  else if ext = 'ZW' then ans := ZZZW(s)
  else if ext = 'ZZ' then begin ZZZZ; ans := ''; end
  else begin Result := CAT_ERROR; Exit; end;

  if (ans = '') or (ans = CAT_ERROR) or (ans = CAT_EXEC_ERROR) then
    Result := ans
  else
    Result := 'ZZ' + ext + s + ans + ';';
end;

{ ══════════════════════════════════════════════════════════════════════════
  ZZ command implementations
  ══════════════════════════════════════════════════════════════════════════ }

function TCATEngine.ZZAA(const s: string): string;
// ZZAA: усиление APF — STUB: аудиопикового фильтра в EWSDR нет.
// (Громкость — это ZZAG; раньше ZZAA был ошибочно заалиашен на неё.)
begin
  if Length(s) = 4 then Result := '' else if Length(s) = 0 then Result := '0000'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZAB(const s: string): string;
// ZZAB: полоса APF — STUB: аудиопикового фильтра нет.
begin
  if Length(s) = 3 then Result := '' else if Length(s) = 0 then Result := '050'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZAC(const s: string): string;
// ZZAC: размер шага настройки консоли (индекс) — STUB: у EWSDR глобального
// шага нет, колесо/UP/DN ходят фиксированным шагом.
// (Выбор диапазона — это ZZBS; раньше он был заалиашен сюда.)
begin
  if Length(s) = 2 then Result := '' else if Length(s) = 0 then Result := '00'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZAG(const s: string): string;
// ZZAG: AF gain 0–100
var v: Integer;
begin
  if Length(s) = 3 then begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);
    v := EnsureRange(v, 0, 100);
    if Assigned(FCtx.SetVolume) then FCtx.SetVolume(v);
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(SafeGetVolume, 3)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZAI(const s: string): string;
// ZZAI: auto-information broadcast — STUB: not applicable for CAT-only mode
begin
  if Length(s) = 1 then Result := ''
  else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZAP(const s: string): string;
// ZZAP: кнопка APF — STUB: аудиопикового фильтра нет.
// (Мощность — это ZZPC; раньше ZZAP был ошибочно заалиашен на неё.)
begin
  Result := OnOff(s, nil, nil);
end;

function TCATEngine.ZZAR(const s: string): string;
// ZZAR: RX1 AGC-T порог (RF gain). Формат: знак + 3 цифры (напр. "+020", "+120").
// Контроллер клампит в свой диапазон (20..120 dB).
var v: Integer;
begin
  if Length(s) = 4 then begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);  // Val понимает ведущий +/-
    if Assigned(FCtx.SetAGCTop) then FCtx.SetAGCTop(v);
    Result := '';
  end else if Length(s) = 0 then begin
    Result := SignedPad(SafeGetAGCTop, 3);
  end else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZAU(const s: string): string;
// ZZAU: сдвиг VFO A вверх на один шаг с индексом nn (00..14, см. StepIdxToHz).
// ★Нечисловое поле — ошибка формата: StrToIntDef молча дал бы индекс 0, то
// есть «ZZAUxx;» двигал бы VFO вместо честного «?;» (как в ZZFL/ZZFH).
var idx: Integer;
begin
  if Length(s) = 2 then begin
    if not TryStrToInt(s, idx) then Exit(CAT_ERROR);
    idx := EnsureRange(idx, 0, 14);
    if Assigned(FCtx.SetVfoA) then FCtx.SetVfoA(SafeGetVfoA + StepIdxToHz(idx));
    Result := '';
  end else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZBP(const s: string): string;
// ZZBP: сдвиг VFO B вверх на один шаг с индексом nn (00..14, см. StepIdxToHz).
// Разбор поля — как у ZZAU: нечисловое значит ошибку, а не нулевой индекс.
var idx: Integer;
begin
  if Length(s) = 2 then begin
    if not TryStrToInt(s, idx) then Exit(CAT_ERROR);
    idx := EnsureRange(idx, 0, 14);
    if Assigned(FCtx.SetVfoB) then FCtx.SetVfoB(SafeGetVfoB + StepIdxToHz(idx));
    Result := '';
  end else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZAS(const s: string): string;
// ZZAS: AGC-T второго приёмника — STUB: RX2 нет.
begin
  if Length(s) = 1 then Result := ''
  else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZAT(const s: string): string;
// ZZAT: настройка APF — STUB: аудиопикового фильтра нет.
begin
  if Length(s) = 1 then Result := ''
  else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZBA: string;
// ZZBA: bandswitch второго приёмника вниз — STUB: RX2 нет.
begin Result := '050'; end;

function TCATEngine.ZZBD: string;
// ZZBD: band down
begin
  if Assigned(FCtx.DoBandDown) then FCtx.DoBandDown;
  Result := '';
end;

function TCATEngine.ZZBI(const s: string): string;
// ZZBI: кнопка BIN (бинауральный приём) — STUB: такого режима нет.
// (Индекс фильтра — это ZZFI; раньше он жил здесь.)
begin
  Result := OnOff(s, nil, nil);
end;

function TCATEngine.ZZBM(const s: string): string;
// ZZBM: сдвиг VFO B ВНИЗ на один шаг с индексом nn (00..14) — пара к ZZBP.
// (Режим — это ZZMD; раньше ZZBM был ошибочно заалиашен на него.)
// Разбор поля — как у ZZAU: нечисловое значит ошибку, а не нулевой индекс.
var idx: Integer;
begin
  if Length(s) = 2 then begin
    if not TryStrToInt(s, idx) then Exit(CAT_ERROR);
    idx := EnsureRange(idx, 0, 14);
    if Assigned(FCtx.SetVfoB) then FCtx.SetVfoB(SafeGetVfoB - StepIdxToHz(idx));
    Result := '';
  end else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZBS(const s: string): string;
// ZZBS: выбор диапазона. В EWSDR поле двузначное, а базовый формат использует
// несёт КОД диапазона ("160"/"040"/"WWV"), у нас — двузначный ИНДЕКС в списке
// диапазонов EWSDR. Менять поздно: на этом формате уже сидят клиенты.
// ★Нечисловое поле — ошибка формата: StrToIntDef переключал бы на диапазон 0,
// то есть уводил бы оператора с рабочего бэнда по опечатке клиента.
var idx: Integer;
begin
  if Length(s) = 2 then begin
    if not TryStrToInt(s, idx) then Exit(CAT_ERROR);
    idx := EnsureRange(idx, 0, 10);
    if Assigned(FCtx.DoBandByIndex) then FCtx.DoBandByIndex(idx);
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(SafeGetBand, 2)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZBU: string;
begin
  if Assigned(FCtx.DoBandUp) then FCtx.DoBandUp;
  Result := '';
end;

function TCATEngine.ZZCB(const s: string): string;
// ZZCB: break-in — прошивка сама поднимает PTT на нажатие ключа.
begin
  Result := OnOff(s, FCtx.GetCWBreakIn, FCtx.SetCWBreakIn);
end;

function TCATEngine.ZZCD(const s: string): string;
// ZZCD: задержка отпускания break-in (hang time), мс. В EWSDR потолок 2000 —
// это намеренный предел: столько же
// стоит в настройках телеграфа и уходит в поле HangDelay пакета DUC Specific.
// Значение выше подрезается по общему правилу, формат — те же 4 цифры.
begin
  Result := NumField(s, 4, 0, CW_HANG_MAX, FCtx.GetCWHangTime, FCtx.SetCWHangTime);
end;

function TCATEngine.ZZCN(const s: string): string;
// ZZCN: CTUN (click-tune) для RX1 вкл/выкл.
begin
  if Length(s) = 1 then begin
    if Assigned(FCtx.SetCTun) then FCtx.SetCTun(s = '1');
    Result := '';
  end else if Length(s) = 0 then
    Result := B2C(SafeGetCTun)
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZCS(const s: string): string;
// ZZCS: скорость кейера, WPM (два разряда). Та же величина, что у KS/ZZKS —
// у Kenwood-команды поле шире, вот и всё различие.
begin
  Result := NumField(s, 2, CW_WPM_MIN, CW_WPM_MAX, FCtx.GetCWSpeed, FCtx.SetCWSpeed);
end;

function TCATEngine.ZZDA(const s: string): string;
// ZZDA: усреднение спектра вкл/выкл — STUB: в CAT не проброшено.
begin
  if Length(s) = 1 then Result := '' else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZDB(const s: string): string;
// ZZDB: опорный приёмник в окне Diversity — STUB: диверсити-приёма нет.
begin
  if Length(s) = 1 then Result := '' else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZDM(const s: string): string;
// ZZDM: режим дисплея (панадаптер/водопад/…) — STUB: в CAT не проброшено.
begin
  if Length(s) = 1 then Result := '' else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZDN(const s: string): string;
// ZZDN: нижний уровень водопада, dBm — STUB: уровни водопада живут в per-pan
// конфиге, в контроллер пока не проброшены (см. §3 doc/CAT_STATUS.md).
// (Мощность — это ZZPC; раньше ZZDN был ошибочно заалиашен на неё.)
begin
  if Length(s) = 4 then Result := '' else if Length(s) = 0 then Result := '-120'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZDU: string;
// ZZDU: составное слово состояния для DDUtil — STUB: не собираем.
begin Result := ''; end;

function TCATEngine.ZZEM(const s: string): string;
// ZZEM: error message — returns error code info (Verbose mode)
begin Result := CAT_ERROR; end;

function TCATEngine.ZZEA(const s: string): string;
// ZZEA: значения эквалайзера ПРИЁМА — STUB: RX-эквалайзера нет (TX — ZZEB).
begin
  if Length(s) > 0 then Result := '' else Result := ''; // no EQ
end;

function TCATEngine.ZZEB(const s: string): string;
// ZZEB: значения TX-эквалайзера одной строкой в протокольном формате.
//   nnn + (nnn){nb+1}, добито нулями до 36 символов;
//   nb — число полос, следом преамп и полосы, отрицательные как '-nn'.
var
  G: TCATEQGains;
  nb, i, v: Integer;
  ans, fld: string;
begin
  if Length(s) = 0 then
  begin
    FillChar(G, SizeOf(G), 0);
    if Assigned(FCtx.GetTXEQ) then nb := FCtx.GetTXEQ(G) else nb := 0;
    // Отдаём ровно то, что примем обратно (см. разбор SET ниже): сетка бывает
    // только трёх- или десятиполосной, а движок всё, что не 3, играет как 10.
    if nb <> 3 then nb := 10;
    ans := Pad(nb, 3);
    for i := 0 to nb do
    begin
      v := EnsureRange(G[i], EQ_GAIN_MIN, EQ_GAIN_MAX);
      if v < 0 then ans := ans + '-' + Pad(-v, 2)
      else          ans := ans + Pad(v, 3);
    end;
    while Length(ans) < 36 do ans := ans + '0';
    Exit(Copy(ans, 1, 36));
  end;
  if Length(s) <> 36 then Exit(CAT_ERROR);
  if not TryStrToInt(Copy(s, 1, 3), nb) then Exit(CAT_ERROR);
  // Сетка эквалайзера бывает только трёх- или десятиполосной: движок
  // (PushTXEQProfile) и редактор в настройках знают ровно эти два случая,
  // любое другое число полос уехало бы в сохранённый TX-профиль мусором.
  if (nb <> 3) and (nb <> 10) then Exit(CAT_ERROR);
  FillChar(G, SizeOf(G), 0);
  for i := 0 to nb do
  begin
    fld := Copy(s, 4 + i * 3, 3);
    if not TryStrToInt(fld, v) then Exit(CAT_ERROR);
    G[i] := EnsureRange(v, EQ_GAIN_MIN, EQ_GAIN_MAX);
  end;
  if Assigned(FCtx.SetTXEQ) then FCtx.SetTXEQ(nb, G);
  Result := '';
end;

function TCATEngine.ZZFA(const s: string): string;
// ZZFA: VFO A frequency (11 digits, Hz)
begin
  if Length(s) = 11 then begin
    if Assigned(FCtx.SetVfoA) then FCtx.SetVfoA(StrToFreq(s));
    Result := '';
  end else if Length(s) = 0 then
    Result := FreqToStr(SafeGetVfoA)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZFB(const s: string): string;
// ZZFB: VFO B frequency
begin
  if Length(s) = 11 then begin
    if Assigned(FCtx.SetVfoB) then FCtx.SetVfoB(StrToFreq(s));
    Result := '';
  end else if Length(s) = 0 then
    Result := FreqToStr(SafeGetVfoB)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZFD(const s: string): string;
// ZZFD: девиация передачи — радиокнопка «узкая/широкая» (2.5/5 кГц). Поле в
// профиле непрерывное, поэтому на чтении делим по середине: всё, что ниже
// 5 кГц, отвечает узкой.
begin
  if Length(s) = 0 then
    Exit(B2C(SafeGetFMDeviation >= FM_DEV_WIDE));
  if (s <> '0') and (s <> '1') then Exit(CAT_ERROR);
  if Assigned(FCtx.SetFMDeviation) then
  begin
    if s = '1' then FCtx.SetFMDeviation(FM_DEV_WIDE)
    else            FCtx.SetFMDeviation(FM_DEV_NARROW);
  end;
  Result := '';
end;

function TCATEngine.ZZFI(const s: string): string;
// ZZFI: индекс текущего фильтра (2 цифры).
// Разбор поля — как у ZZFL/ZZFH: нечисловое значит ошибку, а не фильтр 0.
var idx: Integer;
begin
  if Length(s) = 2 then begin
    if not TryStrToInt(s, idx) then Exit(CAT_ERROR);
    idx := EnsureRange(idx, 0, 9);
    if Assigned(FCtx.SetFilterIdx) then FCtx.SetFilterIdx(idx);
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(SafeGetFilterIdx, 2)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZFL(const s: string): string;
// ZZFL: нижняя кромка фильтра, Гц со знаком — поле фиксированное, ровно пять
// символов («-0150»). Нечисловой ввод раньше проваливался через StrToIntDef в
// ноль и МОЛЧА схлопывал кромку; теперь это ошибка формата.
var v: Integer;
begin
  if Length(s) = 5 then begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);
    if Assigned(FCtx.SetFilterLow) then FCtx.SetFilterLow(v);
    Result := '';
  end else if Length(s) = 0 then begin
    if Assigned(FCtx.GetFilterLow) then Result := SignedPad(FCtx.GetFilterLow, 4)
    else if SafeGetMode in [MODE_CWL, MODE_CWU] then Result := '-0500'
    else Result := '-1500';
  end else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZFH(const s: string): string;
// ZZFH: верхняя кромка фильтра, Гц со знаком — формат и разбор как у ZZFL.
var v: Integer;
begin
  if Length(s) = 5 then begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);
    if Assigned(FCtx.SetFilterHigh) then FCtx.SetFilterHigh(v);
    Result := '';
  end else if Length(s) = 0 then begin
    if Assigned(FCtx.GetFilterHigh) then Result := SignedPad(FCtx.GetFilterHigh, 4)
    else if SafeGetMode in [MODE_CWL, MODE_CWU] then Result := '+0500'
    else Result := '+3000';
  end else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZFM: string;
// ZZFM: filter mode — read-only, returns current filter index as string
begin
  Result := Pad(SafeGetFilterIdx, 2);
end;

function TCATEngine.ZZFT(const s: string): string;
// ZZFT: частота передачи. Со split'ом передача идёт с VFO B — туда и адресуем,
// иначе на VFO A. (Раньше команда всегда била в VFO A: split ещё не было.)
begin
  if SafeGetSplit then Result := ZZFB(s) else Result := ZZFA(s);
end;

function TCATEngine.ZZGA(const s: string): string;
// ZZGA: регистрация GUID клиента — адресацией ответов ведает слой TCP.
// If called from serial, just acknowledge
begin
  if Length(s) = 36 then Result := s
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZGR(const s: string): string;
// ZZGR: снятие GUID клиента — см. ZZGA.
begin
  if Length(s) = 36 then Result := s
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZGT(const s: string): string;
// ZZGT: режим АРУ (0=FAST,1=MED,2=SLOW,3=LONG,4=OFF). Ответ — три цифры, как у
// кенвудовской GT, и SET обязан принимать ту же ширину: клиент, прочитавший
// «ZZGT000;», пишет назад ровно это, и односимвольная форма отдавала ему «?;».
// Однобуквенную форму оставляем — на ней уже сидят клиенты.
var m: Integer;
begin
  if Length(s) = 0 then Exit(Pad(SafeGetAGCMode, 3));
  if (Length(s) <> 1) and (Length(s) <> 3) then Exit(CAT_ERROR);
  if not TryStrToInt(s, m) then Exit(CAT_ERROR);
  m := EnsureRange(m, 0, 4);
  if Assigned(FCtx.SetAGCMode) then FCtx.SetAGCMode(m);
  Result := '';
end;

function TCATEngine.ZZID: string;
begin Result := CAT_ID_NUM; end;

function TCATEngine.ZZIF(const s: string): string;
// ZZIF: составной статус трансивера — то же, что кенвудовская IF.
begin
  if Length(s) = 0 then Result := CmdIF
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZIO: string;
// ZZIO: список установленных опций — STUB: опций не заводим.
begin Result := '0'; end;

function TCATEngine.ZZIS(const s: string): string;
// ZZIS: ширина IF — STUB: полоса задаётся кромками (ZZFL/ZZFH) и пресетами.
begin
  if Length(s) = 1 then Result := '' else if Length(s) = 0 then Result := '1'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZKM(const s: string): string;
// ZZKM: запустить CW-макрос номер n (1..9) — у нас это ячейка памяти сообщений
// (окно CW Messages, F1..F8). Команда доступна только для записи.
// Раньше здесь сидел режим кейера — своей команды у него в протоколе нет,
// иамбик вкл/выкл доступен через ZZCI.
var v: Integer;
begin
  if Length(s) <> 1 then Exit(CAT_ERROR);
  if not TryStrToInt(s, v) then Exit(CAT_ERROR);
  if (v < 1) or (v > 9) then Exit(CAT_ERROR);
  if Assigned(FCtx.SendCWMacro) then FCtx.SendCWMacro(v);
  Result := '';
end;

function TCATEngine.ZZKS(const s: string): string;
// ZZKS: скорость кейера, WPM.
begin
  Result := CmdKS(s);
end;

function TCATEngine.ZZKY(const s: string): string;
// ZZKY: передать текст телеграфом (то же, что KY).
begin
  Result := CmdKY(s);
end;

function TCATEngine.ZZMA(const s: string): string;
// ZZMA: кнопка MUT (заглушить приём).
// (Режим — это ZZMD; раньше ZZMA был ошибочно заалиашен на него.)
begin
  Result := OnOff(s, FCtx.GetMuted, FCtx.SetMuted);
end;

function TCATEngine.ZZMB(const s: string): string;
// ZZMB: кнопка MUT второго приёмника — STUB: RX2 нет.
begin
  Result := OnOff(s, nil, nil);
end;

function TCATEngine.ZZMD(const s: string): string;
// ZZMD: mode (1 digit Kenwood code, same as MD)
var k: Integer;
begin
  if Length(s) = 1 then begin
    k := StrToIntDef(s, 0);
    if (k >= 1) and Assigned(FCtx.SetMode) then FCtx.SetMode(KToMode(k));
    Result := '';
  end else if Length(s) = 0 then
    Result := IntToStr(ModeToK(SafeGetMode))
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZMG(const s: string): string;
// ZZMG: усиление микрофона в дБ. SET — три цифры либо знак+две
// ('+10'/'-05'), GET всегда отдаёт знак+две. Значение уезжает в активный
// TX-профиль (MicGainDB), поэтому подрезаем под диапазон вкладки Transmit.
var v: Integer;
begin
  if Length(s) = 0 then
    Exit(SignedPad(EnsureRange(SafeGetMicGain, MIC_GAIN_MIN, MIC_GAIN_MAX), 2));
  // Три символа (`025`, `+10`, `-05`) либо четыре со знаком (`-050`) — ровно
  // Допустимы две протокольные длины поля. Более короткие поля — ошибка формата, а не
  // «маленькое значение»: молча крутить усиление по ним нельзя.
  if (Length(s) < 3) or (Length(s) > 4) then Exit(CAT_ERROR);
  if not TryStrToInt(s, v) then Exit(CAT_ERROR);
  if Assigned(FCtx.SetMicGain) then
    FCtx.SetMicGain(EnsureRange(v, MIC_GAIN_MIN, MIC_GAIN_MAX));
  Result := '';
end;

function TCATEngine.ZZML: string;
// ZZML: mode list (returns all mode names)
begin Result := 'LSB USB DSB CWL CWU FM  AM  SAM DIGUDIGLWFM DMR FMRAW'; end;

function TCATEngine.ZZMN(const s: string): string;
// ZZMN: имя режима по номеру. В базовом формате ZZMN отдаёт пресеты
// DSP-фильтров (180 символов). Имён режимов в протоколе нет вовсе, а список
// режимов (ZZML) на эту команду опирается — оставляем как расширение EWSDR.
// Индекс режима двузначный (MODE_MIN..MODE_MAX), но односимвольный запрос
// продолжаем принимать — старые клиенты шлют одну цифру.
var k: Integer;
    names: array[0..CAT_MODE_COUNT-1] of string;
begin
  names[MODE_LSB]:='LSB';    names[MODE_USB]:='USB';
  names[MODE_DSB]:='DSB';    names[MODE_CWL]:='CWL';
  names[MODE_CWU]:='CWU';    names[MODE_FM]:='FM';
  names[MODE_AM]:='AM';      names[MODE_SAM]:='SAM';
  names[MODE_DIGU]:='DIGU';  names[MODE_DIGL]:='DIGL';
  names[MODE_WFM]:='WFM';    names[MODE_DMR]:='DMR';
  names[MODE_FMRAW]:='FM RAW';
  if (Length(s) = 1) or (Length(s) = 2) then begin
    k := EnsureRange(StrToIntDef(s, MODE_MIN), MODE_MIN, MODE_MAX);
    Result := names[k];
  end else Result := CAT_ERROR;
end;

function TCATEngine.ZZMO(const s: string): string;
// ZZMO: кнопка MON. У EWSDR монитор передачи — это self-monitor даунлинка
// (RX MUTE снят); слышно его, разумеется, только в дуплексе. Уровень — ZZTM.
begin
  Result := OnOff(s, FCtx.GetMonitorOn, FCtx.SetMonitorOn);
end;

function TCATEngine.ZZMV: string;
// ZZMV: число записей в памяти каналов (read-only).
// (Текущий режим — это ZZMD; раньше ZZMV отдавал его индекс.)
var n: Integer;
begin
  if Assigned(FCtx.GetMemoryCount) then n := FCtx.GetMemoryCount else n := 0;
  Result := Pad(n, 3);
end;

function TCATEngine.ZZMY: string;
// ZZMY: mode Y — STUB
begin Result := ''; end;

function TCATEngine.ZZNA(const s: string): string;
// ZZNA: кнопка NB1 (у нас режимы NB нумерованы, 1 = первый бланкер).
var v: Integer;
begin
  if Length(s) = 1 then begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);
    if Assigned(FCtx.SetNBMode) then FCtx.SetNBMode(v);
    Result := '';
  end else if Length(s) = 0 then
    Result := IntToStr(EnsureRange(SafeGetNBMode,0,1))
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZNB(const s: string): string;
// ZZNB: noise blanker mode (extended, 0-4)
var v: Integer;
begin
  if Length(s) = 1 then begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);
    v := EnsureRange(v, 0, 4);
    if Assigned(FCtx.SetNBMode) then FCtx.SetNBMode(v);
    Result := '';
  end else if Length(s) = 0 then
    Result := IntToStr(SafeGetNBMode)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZNL(const s: string): string;
// ZZNL: порог NB1 — STUB: порог бланкера не вынесен в настройки.
begin
  if Length(s) = 3 then Result := '' else if Length(s) = 0 then Result := '050'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZNN(const s: string): string;
// ZZNN: спектральный бланкер (SNB) RX1.
// (Раньше здесь стояла заглушка «ручной нотч», а SNB сидел в ZZNS.)
begin
  Result := OnOff(s, FCtx.GetSNBEnabled, FCtx.SetSNBEnabled);
end;

function TCATEngine.ZZNR(const s: string): string;
// ZZNR: noise reduction on/off (0=off, 1-4=NR mode)
var v: Integer;
begin
  if Length(s) = 1 then begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);
    v := EnsureRange(v, 0, 4);
    if Assigned(FCtx.SetNRMode) then FCtx.SetNRMode(v);
    Result := '';
  end else if Length(s) = 0 then
    Result := IntToStr(SafeGetNRMode)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZNS(const s: string): string;
// ZZNS: кнопка NR2 — у нас шумоподавитель один и режимный, NR2 = режим 2.
// Включение ставит режим 2, выключение — 0; прочие режимы читаются как '0'.
// (SNB переехал в ZZNN, где ему и место по протоколу.)
const NR2 = 2;
begin
  if Length(s) = 0 then Exit(B2C(SafeGetNRMode = NR2));
  if (s <> '0') and (s <> '1') then Exit(CAT_ERROR);
  if Assigned(FCtx.SetNRMode) then
  begin
    if s = '1' then FCtx.SetNRMode(NR2)
    else if SafeGetNRMode = NR2 then FCtx.SetNRMode(0);
  end;
  Result := '';
end;

function TCATEngine.ZZNT(const s: string): string;
// ZZNT: ANF (auto-notch) вкл/выкл.
begin
  if Length(s) = 1 then begin
    if Assigned(FCtx.SetANFEnabled) then FCtx.SetANFEnabled(s = '1');
    Result := '';
  end else if Length(s) = 0 then
    Result := B2C(SafeGetANF)
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZOA(const s: string): string;
// ZZOA: антенна RX1 — STUB: порты AD936x в контроллере есть (SetAnt936x), но в
// CAT не проброшены (см. §3 doc/CAT_STATUS.md).
// (Громкость — это ZZAG; раньше ZZOA был ошибочно заалиашен на неё.)
begin
  Result := OnOff(s, nil, nil);
end;

function TCATEngine.ZZOB(const s: string): string;
// ZZOB: антенна RX2 — STUB: RX2 нет.
begin
  if Length(s) = 3 then Result := '' else if Length(s) = 0 then Result := '050'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZPC(const s: string): string;
// ZZPC: TX power 0–100 → DriveLevel
var v: Integer;
begin
  if Length(s) = 3 then begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);
    v := EnsureRange(v, 0, 100);
    if Assigned(FCtx.SetDriveLevel) then FCtx.SetDriveLevel(v);
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(SafeGetDriveLevel, 3)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZPD: string;
// ZZPD: центрировать пан дисплея — STUB: пан/зум в CAT не проброшены.
// (Раньше команда отдавала «номинальную мощность 100» — выдумка.)
begin Result := ''; end;

function TCATEngine.ZZPO(const s: string): string;
// ZZPO: кнопка Peak на дисплее — STUB: удержания пика в CAT нет.
// (Питание/состояние — это ZZPS; раньше ZZPO отвечал за него.)
begin
  Result := OnOff(s, nil, nil);
end;

function TCATEngine.ZZPS(const s: string): string;
// ZZPS: power status
begin
  if Length(s) = 1 then Result := ''
  else if Length(s) = 0 then Result := B2C(SafeGetRunning)
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZQM: string;
// ZZQM: частота из быстрой памяти (11 цифр) — STUB: быстрая память не заведена.
// (Текущий режим — это ZZMD; раньше ZZQM отдавал его.)
begin Result := Pad(0, 11); end;

function TCATEngine.ZZQR: string;
// ZZQR: quick recall — STUB
begin Result := ''; end;

function TCATEngine.ZZQS: string;
// ZZQS: quick store — STUB
begin Result := ''; end;

function TCATEngine.ZZRA(const s: string): string;
// ZZRA: галка RTTY-офсета для VFO A — STUB: RTTY-движка нет.
begin
  if Length(s) = 5 then Result := '' else if Length(s) = 0 then Result := '+0000'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZRC: string;
begin Result := ''; end;  // clear RIT — STUB

function TCATEngine.ZZRD(const s: string): string;
// ZZRD: убавить RIT — STUB: расстройки приёма в EWSDR нет.
// (Шаг настройки вниз — это ZZSA/ZZSD и кенвудовская RD.)
begin
  if (Length(s) = 0) or (Length(s) = 5) then Result := '' else Result := CAT_ERROR;
end;

function TCATEngine.ZZRL(const s: string): string;
// ZZRL: частота офсета RTTY DIGL — STUB: RTTY-движка нет.
begin
  if Length(s) = 3 then Result := '' else if Length(s) = 0 then Result := '100'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZRU(const s: string): string;
// ZZRU: прибавить RIT — STUB: расстройки приёма в EWSDR нет.
// (Шаг настройки вверх — это ZZSB/ZZSU и кенвудовская RU.)
begin
  if (Length(s) = 0) or (Length(s) = 5) then Result := '' else Result := CAT_ERROR;
end;

function TCATEngine.ZZRV: string;
// ZZRV: напряжение питания PA, формат «NN.N» (4 символа).
// (Версия ПО — это ZZVN; раньше ZZRV отдавал её.)
var v: Double;
begin
  if Assigned(FCtx.GetSupplyVolts) then v := FCtx.GetSupplyVolts else v := -1;
  if v < 0 then v := 0;          // телеметрии нет — отвечаем нулём
  if v > 99.9 then v := 99.9;
  Result := Format('%04.1f', [v]);
end;

function TCATEngine.ZZRX(const s: string): string;
// ZZRX: шаговый аттенюатор RX1, дБ (00..31).
// (Переход на приём — это ZZTX0 и кенвудовская RX; раньше ZZRX делал его.)
begin
  Result := NumField(s, 2, 0, 31, FCtx.GetAtten, FCtx.SetAtten);
end;

function TCATEngine.ZZSA: string;
// ZZSA: шаг настройки вниз. В EWSDR шагает активный VFO,
// (отдельного «шага VFO A» в контроллере нет).
begin
  if Assigned(FCtx.DoTuneDown) then FCtx.DoTuneDown;
  Result := '';
end;

function TCATEngine.ZZSB: string;
// ZZSB: шаг настройки вверх — см. оговорку в ZZSA.
begin
  if Assigned(FCtx.DoTuneUp) then FCtx.DoTuneUp;
  Result := '';
end;

function TCATEngine.ZZSM(const s: string): string;
// ZZSM: S-meter (0=RX1, 1=RX2)
// Response: 3-digit 000-260 (calculated as (dBm+140)*2)
var sm: Integer;
begin
  if (Length(s) = 1) and ((s = '0') or (s = '1')) then begin
    sm := SMeterRaw(SafeGetSMeter);
    Result := Pad(sm, 3);
  end else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZSO(const s: string): string;
// ZZSO: squelch on/off (расширенный статус шумоподавителя). 0=off, иначе on.
begin
  if Length(s) = 1 then begin
    if Assigned(FCtx.SetSquelchOn) then FCtx.SetSquelchOn(s <> '0');
    Result := '';
  end else if Length(s) = 0 then
    Result := B2C(SafeGetSquelchOn)
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZSP(const s: string): string;
// ZZSP: split mode — 1=TX на VFO-B, 0=TX на VFO-A. TX-частоту радио берёт из
// ActiveTXFreqHz (учитывает split), поэтому достаточно выставить флаг.
begin
  if Length(s) = 1 then begin
    if Assigned(FCtx.SetSplit) then FCtx.SetSplit(s = '1');
    Result := '';
  end else if Length(s) = 0 then
    Result := B2C(SafeGetSplit)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZSQ(const s: string): string;
// ZZSQ: squelch threshold (3 digits, 000–100 %). Уровень>0 включает шумоподавитель,
// 000 — открывает (выключает). Маппится на FM-squelch контроллера.
var v: Integer;
begin
  if Length(s) = 3 then begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);
    v := EnsureRange(v, 0, 100);
    if Assigned(FCtx.SetSquelchLevel) then FCtx.SetSquelchLevel(v);
    if Assigned(FCtx.SetSquelchOn)    then FCtx.SetSquelchOn(v > 0);
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(SafeGetSquelchLevel, 3)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZSR(const s: string): string;
// ZZSR: кнопка Spur Reduction — STUB: такого блока в тракте нет.
// (Опорный уровень спектра — это ZZDP; раньше ZZSR отвечал за него.)
begin
  Result := OnOff(s, nil, nil);
end;

function TCATEngine.ZZST(const s: string): string;
// ZZST: FM tuning step index (2 digits): 00=6.25k, 01=12.5k, 02=20k, 03=25k.
var v: Integer;
begin
  if Length(s) = 2 then begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);
    if Assigned(FCtx.SetFMStep) then FCtx.SetFMStep(EnsureRange(v, 0, 3));
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(EnsureRange(SafeGetFMStep, 0, 3), 2)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZSU: string;
// ZZSU: шаг настройки вверх (как колесо мыши) — сдвигает активный VFO вверх.
begin
  if Assigned(FCtx.DoTuneUp) then FCtx.DoTuneUp;
  Result := '';
end;

function TCATEngine.ZZTA(const s: string): string;
// ZZTA: CTCSS вкл/выкл для FM-передачи. Значение строго 0/1 — иначе «CT9;»
// молча выключал бы тон вместо ответа об ошибке.
begin
  Result := OnOff(s, FCtx.GetCTCSSOn, FCtx.SetCTCSSOn);
end;

function TCATEngine.ZZTB(const s: string): string;
// ZZTB: CTCSS tone index (2 digits, 0-based 00..37 в таблице CTCSS_TONES).
var v: Integer;
begin
  if Length(s) = 2 then begin
    if not TryStrToInt(s, v) then Exit(CAT_ERROR);
    if Assigned(FCtx.SetCTCSSTone) then FCtx.SetCTCSSTone(EnsureRange(v, 0, 37));
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(EnsureRange(SafeGetCTCSSTone, 0, 37), 2)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZTX(const s: string): string;
// ZZTX: кнопка MOX — только 0 (приём) и 1 (передача). Никакого «2=TUNE» в
// протоколе нет (настройка — это ZZTU): на любом другом значении отвечаем
// ошибкой и НЕ поднимаем передачу.
begin
  Result := OnOff(s, FCtx.GetTransmitting, FCtx.SetTransmitting);
end;

function TCATEngine.ZZUP(const s: string): string;
// ZZUP: внешний PA вкл/выкл — STUB: управления внешним усилителем нет.
begin
  if Length(s) = 1 then Result := '' else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZUS: string;
// ZZUS: запустить одиночную калибровку PureSignal (write-only).
begin
  if Assigned(FCtx.DoPSSingleCal) then FCtx.DoPSSingleCal;
  Result := '';
end;

function TCATEngine.ZZUT(const s: string): string;
// ZZUT: двухтональник (2TON).
begin
  Result := OnOff(s, FCtx.GetTwoTone, FCtx.SetTwoTone);
end;

function TCATEngine.ZZUX(const s: string): string;
// ZZUX: блокировка VFO A — STUB: у нас лок общий (SetVfoLock), раздельного нет.
begin
  if Length(s) = 1 then Result := '' else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZUY(const s: string): string;
// ZZUY: блокировка VFO B — STUB: см. ZZUX.
begin Result := ZZUX(s); end;

function TCATEngine.ZZVA(const s: string): string;
// ZZVA: включение VAC — STUB: виртуального аудиокабеля в EWSDR нет.
// (Обмен VFO — это ZZVS; раньше он жил здесь.)
begin
  Result := OnOff(s, nil, nil);
end;

function TCATEngine.ZZVG(const s: string): string;
// ZZVG: усиление VOX — STUB: VOX'а нет.
// (Громкость — это ZZAG; раньше ZZVG был ошибочно заалиашен на неё.)
begin
  if Length(s) = 4 then Result := '' else if Length(s) = 0 then Result := '0000'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZVN: string;
// ZZVN: software version string
begin Result := 'EWSDR 0.1.0'; end;

function TCATEngine.ZZVS(const s: string): string;
// ZZVS: операция над парой VFO (write-only). Аргумент — НОМЕР операции, а не
// флаг: 0 = A→B, 1 = B→A, 2 = обмен, 3 = IF→VFO.
// (Split — это ZZSP; раньше ZZVS был заалиашен на него.)
begin
  if Length(s) <> 1 then Exit(CAT_ERROR);
  case s[1] of
    '0': if Assigned(FCtx.SetVfoB) then FCtx.SetVfoB(SafeGetVfoA);
    '1': if Assigned(FCtx.SetVfoA) then FCtx.SetVfoA(SafeGetVfoB);
    '2': if Assigned(FCtx.DoVfoSwap) then FCtx.DoVfoSwap;
    // IF→VFO переносит расстройку приёма в частоту VFO. Расстройки у нас нет,
    // переносить нечего — команду принимаем и ничего не делаем.
    '3': ;
  else
    Exit(CAT_ERROR);
  end;
  Result := '';
end;

// ZZWA..ZZWS: неподдерживаемые команды внешнего микшера — STUB.
// Водопад тут ни при чём: его
// уровни живут в ZZDN/ZZDO.
function TCATEngine.ZZWA(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWB(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWC(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWD(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWE(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWF(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWG(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWH(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWJ(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWK(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWL(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWM(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWN(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWO(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWP(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWQ(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWR(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZWS(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;

// ZZXA: включение аудиоусилителя G2; ZZXC/ZZXF: сброс и значение XIT —
// STUB: усилителя не касаемся, расстройки передачи (XIT) в EWSDR нет.
function TCATEngine.ZZXA(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZXC: string;
begin Result := '0'; end;
function TCATEngine.ZZXF(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;

// ZZYA/ZZYB: галки Direct I/Q и IQ Calibrate для VAC2 — STUB: VAC нет.
// ZZYC: отдельное усиление микрофона в FM — STUB: у нас оно одно на все режимы
//       (ZZMG). ZZYR: переключатель RX1/RX2 — STUB: RX2 нет.
function TCATEngine.ZZYA(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZYB(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZYC(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZYR(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;

// ZZZA: трип-состояние усилителя Ganymede; ZZZB, ZZZQ, ZZZR: внутренние
// служебные команды; ZZZM/ZZZV: строки модели и версии железа; ZZZT: зум на
// диапазон; ZZZW: обмен «колёс» VFO в midi2cat; ZZZO: быстрый split.
// Всё — STUB: либо чужое железо, либо в CAT не проброшено.
function TCATEngine.ZZZA(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZZB: string; begin Result := ''; end;

function TCATEngine.ZZZD(const s: string): string;
// ZZZD: Andromeda — энкодер VFO повёрнут вниз на nn шагов (write-only).
begin
  if Length(s) > 0 then begin
    if Assigned(FCtx.OnPanelVfoStep) then FCtx.OnPanelVfoStep(-StrToIntDef(s, 0));
    Result := '';
  end else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZZE(const s: string): string;
// ZZZE: Andromeda — поворот энкодера. Верхние 2 цифры кодируют номер
// энкодера (01..20 = +шаг, 51..70 = −шаг), нижняя цифра — величина шага.
var raw, enc, step: Integer;
begin
  if Length(s) > 0 then begin
    raw  := StrToIntDef(s, 0);
    step := raw mod 10;
    enc  := raw div 10;
    if (enc >= 1) and (enc <= 20) then begin
      if Assigned(FCtx.OnPanelEncoder) then FCtx.OnPanelEncoder(enc - 1, step);
    end else if (enc >= 51) and (enc <= 70) then begin
      if Assigned(FCtx.OnPanelEncoder) then FCtx.OnPanelEncoder(enc - 51, -step);
    end;
    Result := '';
  end else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZZM(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;

function TCATEngine.ZZZP(const s: string): string;
// ZZZP: Andromeda — нажатие кнопки (write-only). Кодировка: nn*10 + state,
// state: 0=отпущена, 1=нажата, 2=long-press. nn — номер кнопки (1-based).
var raw, btn: Integer; state, long: Boolean;
begin
  if Length(s) > 0 then begin
    raw   := StrToIntDef(s, 0);
    state := (raw mod 10) = 1;
    long  := (raw mod 10) = 2;
    btn   := raw div 10;
    if Assigned(FCtx.OnPanelButton) then FCtx.OnPanelButton(btn - 1, state, long);
    Result := '';
  end else
    Result := CAT_ERROR;
end;
function TCATEngine.ZZZQ(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZZR(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZZS(const s: string): string;
// ZZZS: Andromeda — панель сообщает версию HW/SW (write-only, рукопожатие).
begin
  if Length(s) > 0 then begin
    if Assigned(FCtx.OnPanelVersion) then FCtx.OnPanelVersion(StrToIntDef(s, 0));
    Result := '';
  end else
    Result := CAT_ERROR;
end;
function TCATEngine.ZZZT(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZZU(const s: string): string;
// ZZZU: Andromeda — энкодер VFO повёрнут вверх на nn шагов (write-only).
begin
  if Length(s) > 0 then begin
    if Assigned(FCtx.OnPanelVfoStep) then FCtx.OnPanelVfoStep(StrToIntDef(s, 0));
    Result := '';
  end else
    Result := CAT_ERROR;
end;
function TCATEngine.ZZZV(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZZW(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
function TCATEngine.ZZZZ: string;
// ZZZZ: команда не описана — STUB.
begin Result := ''; end;
function TCATEngine.ZZZO(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;

{ ── TX-тракт и телеграф ──────────────────────────────────────────────────── }

function TCATEngine.ZZCI(const s: string): string;
// ZZCI: иамбический манипулятор вкл/выкл. У нас режим кейера трёхпозиционный
// (прямой ключ / iambic A / iambic B) — включение выбирает B по
// умолчанию, а если иамбик уже стоит, ранее выбранная буква сохраняется.
const KEYER_STRAIGHT = 0; KEYER_IAMBIC_B = 2;
begin
  if Length(s) = 0 then Exit(B2C(SafeGetCWKeyerMode <> KEYER_STRAIGHT));
  if (s <> '0') and (s <> '1') then Exit(CAT_ERROR);
  if Assigned(FCtx.SetCWKeyerMode) then
  begin
    if s = '0' then FCtx.SetCWKeyerMode(KEYER_STRAIGHT)
    else if SafeGetCWKeyerMode = KEYER_STRAIGHT then FCtx.SetCWKeyerMode(KEYER_IAMBIC_B);
  end;
  Result := '';
end;

function TCATEngine.ZZCL(const s: string): string;
// ZZCL: тон телеграфа (он же сайдтон), Гц.
begin
  Result := NumField(s, 4, CW_PITCH_MIN, CW_PITCH_MAX, FCtx.GetCWPitch, FCtx.SetCWPitch);
end;

function TCATEngine.ZZCM(const s: string): string;
// ZZCM означает «CW Monitor DISABLE», значение инвертировано к сайдтону.
begin
  if Length(s) = 0 then Exit(B2C(not SafeGetBool(FCtx.GetCWMonitor)));
  if (s <> '0') and (s <> '1') then Exit(CAT_ERROR);
  if Assigned(FCtx.SetCWMonitor) then FCtx.SetCWMonitor(s = '0');
  Result := '';
end;

function TCATEngine.ZZET(const s: string): string;
// ZZET: кнопка TXEQ.
begin
  Result := OnOff(s, FCtx.GetTXEQOn, FCtx.SetTXEQOn);
end;

function TCATEngine.ZZLI(const s: string): string;
// ZZLI: кнопка PS-A (автокалибровка PureSignal).
begin
  Result := OnOff(s, FCtx.GetPureSignal, FCtx.SetPureSignal);
end;

function TCATEngine.ZZPK(const s: string): string;
// ZZPK: речевой компрессор (WDSP COMP) вкл/выкл. Пара к ZZPL.
begin
  Result := OnOff(s, FCtx.GetCompOn, FCtx.SetCompOn);
end;

function TCATEngine.ZZPL(const s: string): string;
// ZZPL: усиление речевого компрессора, дБ.
begin
  Result := NumField(s, 2, 0, COMP_GAIN_MAX, FCtx.GetCompGain, FCtx.SetCompGain);
end;

function TCATEngine.ZZTH(const s: string): string;
// ZZTH: верхняя кромка TX-фильтра, Гц.
begin
  Result := NumField(s, 5, TX_FILT_HI_MIN, TX_FILT_HI_MAX,
                     FCtx.GetTXFilterHigh, FCtx.SetTXFilterHigh);
end;

function TCATEngine.ZZTL(const s: string): string;
// ZZTL: нижняя кромка TX-фильтра, Гц.
begin
  Result := NumField(s, 4, TX_FILT_LO_MIN, TX_FILT_LO_MAX,
                     FCtx.GetTXFilterLow, FCtx.SetTXFilterLow);
end;

function TCATEngine.ZZTM(const s: string): string;
// ZZTM: громкость монитора передачи, 0..100.
begin
  Result := NumField(s, 3, 0, 100, FCtx.GetMonitorLevel, FCtx.SetMonitorLevel);
end;

function TCATEngine.ZZTO(const s: string): string;
// ZZTO: мощность настройки (уровень TUN), % от полной шкалы Drive.
begin
  Result := NumField(s, 3, 0, 100, FCtx.GetTUNLevel, FCtx.SetTUNLevel);
end;

function TCATEngine.ZZTP(const s: string): string;
// ZZTP: активный TX-профиль по индексу. Индекса вне списка нет — это ошибка
// выполнения, а не подрезка: клиент выбирает из того, что ему отдал ZZTP;.
var v, Cnt: Integer;
begin
  if Length(s) = 0 then Exit(Pad(Max(0, SafeGetTXProfile), 2));
  if Length(s) <> 2 then Exit(CAT_ERROR);
  if not TryStrToInt(s, v) then Exit(CAT_ERROR);
  if Assigned(FCtx.GetTXProfileCount) then Cnt := FCtx.GetTXProfileCount else Cnt := 0;
  if (v < 0) or (v >= Cnt) then Exit(CAT_ERROR);
  if Assigned(FCtx.SetTXProfile) then FCtx.SetTXProfile(v);
  Result := '';
end;

function TCATEngine.ZZTU(const s: string): string;
// ZZTU: кнопка TUN.
begin
  Result := OnOff(s, FCtx.GetTuning, FCtx.SetTuning);
end;

end.
