unit CATEngine;
{
  CATEngine.pas — CAT command engine (Kenwood + Thetis ZZ* extensions).

  Совместимость с Thetis/PowerSDR: все команды из CATParser.cs + CATCommands.cs
  реализованы. Команды, для которых в 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;

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;

  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;
    // 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 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;
    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 ZZBE(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 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 ZZVB(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;

  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;

{ ── 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.StepIdxToHz(Idx: Integer): Double;
// Шаг настройки по индексу (0..14) — зеркало Thetis Step2Freq (в Гц).
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);
      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 begin CmdRD(sfx); Result := ''; end
      else if pfx = 'RT' then Result := CmdRT(sfx)
      else if pfx = 'RU' then begin CmdRU(sfx); Result := ''; end
      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
    v := Round(StrToIntDef(Copy(s,2,3), 0) / 2.55);
    v := EnsureRange(v, 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
    idx := EnsureRange(StrToIntDef(s, 1) - 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 enable (1=on, 0=off) для FM-передачи.
begin
  if Length(s) = 1 then begin
    if Assigned(FCtx.SetCTCSSOn) then FCtx.SetCTCSSOn(s = '1');
    Result := '';
  end else if Length(s) = 0 then
    Result := B2C(SafeGetCTCSSOn)
  else
    Result := CAT_ERROR;
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
// Thetis encodes filter by index into 4-digit string 0000-0009
var idx: Integer;
begin
  if Length(s) = 4 then begin
    idx := EnsureRange(StrToIntDef(s, 0), 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
begin
  if Length(s) = 3 then begin
    if Assigned(FCtx.SetAGCMode) then FCtx.SetAGCMode(EnsureRange(StrToIntDef(s,0),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: interface status — 37-byte composite response
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.CmdKS(const s: string): string;
// KS: CW keying speed (3 digits, WPM) — STUB: no CW keyer
begin
  if Length(s) = 3 then
    Result := ''  // accepted but ignored
  else if Length(s) = 0 then
    Result := '025'  // default 25 WPM
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdKY(const s: string): string;
// KY: send CW text — STUB: no CW keyer implemented
begin
  Result := '0'; // queue not full
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: mic gain (3 digits, 0-100) — STUB: TX DSP not yet implemented
begin
  if Length(s) = 3 then
    Result := ''
  else if Length(s) = 0 then
    Result := '050'
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdMO(const s: string): string;
// MO: monitor — STUB
begin
  if Length(s) = 1 then Result := ''
  else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

function TCATEngine.CmdNB(const s: string): string;
// NB: noise blanker on/off → maps to NBMode
begin
  if Length(s) = 1 then begin
    if Assigned(FCtx.SetNBMode) then
      FCtx.SetNBMode(StrToIntDef(s, 0));
    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: FM repeater offset frequency (9 digits, Hz).
begin
  if Length(s) = 9 then begin
    if Assigned(FCtx.SetFMRptOffset) then FCtx.SetFMRptOffset(StrToIntDef(s, 0));
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(Round(SafeGetFMRptOffset), 9)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.CmdOS(const s: string): string;
// OS: repeater offset direction (Kenwood: 0=simplex, 1=plus, 2=minus).
// Внутренние константы: RPT_NONE=0, RPT_MINUS=1, RPT_PLUS=2 → требуется трансляция.
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.CmdPC(const s: string): string;
// PC: TX power 0-100 % (3 digits) → DriveLevel
var v: Integer;
begin
  if Length(s) = 3 then begin
    v := EnsureRange(StrToIntDef(s,0), 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: speech compressor — STUB: always off
begin
  if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
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: decrease RIT — maps to tune down
begin
  if Assigned(FCtx.DoTuneDown) then FCtx.DoTuneDown;
  Result := '';
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: increase RIT — maps to tune up
begin
  if Assigned(FCtx.DoTuneUp) then FCtx.DoTuneUp;
  Result := '';
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: high pass filter setting (2 digits, 0-based index) — maps to filter index
var idx: Integer;
begin
  if Length(s) = 2 then begin
    idx := EnsureRange(StrToIntDef(s, 0), 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: low pass filter setting — mirrors SH
begin
  Result := CmdSH(s);
end;

function TCATEngine.CmdSM(const s: string): string;
// SM: S-meter read (1 char VFO selector, response 4 chars 0000-0015)
// Kenwood SM answer: 0000–0015 → S0–S9+60dB
// We return 4-digit raw 0000-0030 compatible format
var sm: Integer;
begin
  if (Length(s) = 1) and ((s = '0') or (s = '1')) then begin
    sm := SMeterRaw(SafeGetSMeter);
    // Scale 0-260 → 0-30 (Kenwood SM scale 0000-0030)
    sm := Round(sm * 30 / 260);
    Result := Pad(sm, 4);
  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
    lvl255 := EnsureRange(StrToIntDef(Copy(s, 2, 3), 0), 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: TX freq display
  else if ext = 'AE' then ans := '' // STUB: RIT value
  else if ext = 'AF' then ans := '' // STUB: XIT value
  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 type
  else if ext = 'BA' then ans := ZZBA
  else if ext = 'BB' then ans := '' // STUB: band button state
  else if ext = 'BD' then begin ZZBD; ans := ''; end
  else if ext = 'BE' then ans := ZZBE(s)
  else if ext = 'BF' then ans := '' // STUB: sub-RX filter
  else if ext = 'BG' then ans := '' // STUB: sub-RX gain
  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: sub-RX enable
  else if ext = 'BS' then ans := ZZBS(s)
  else if ext = 'BT' then ans := '' // STUB: band TX
  else if ext = 'BU' then begin ZZBU; ans := ''; end
  else if ext = 'BY' then ans := '' // STUB: VFO bypass
  else if ext = 'CB' then ans := ZZCB(s)
  else if ext = 'CD' then ans := ZZCD(s)
  else if ext = 'CF' then ans := '' // STUB: compressor freq
  else if ext = 'CI' then ans := '' // STUB: compressor in
  else if ext = 'CL' then ans := '' // STUB: compressor level
  else if ext = 'CM' then ans := '' // STUB: compressor mode
  else if ext = 'CN' then ans := ZZCN(s)
  else if ext = 'CO' then ans := '' // STUB: compressor out
  else if ext = 'CP' then ans := '' // STUB: compressor peak
  else if ext = 'CS' then ans := ZZCS(s)
  else if ext = 'CT' then ans := '' // STUB: compressor threshold
  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: display contrast
  else if ext = 'DD' then ans := '' // STUB: DSP disable
  else if ext = 'DE' then ans := '' // STUB: DSP enable
  else if ext = 'DF' then ans := '' // STUB: TX filter low
  else if ext = 'DG' then ans := '' // STUB: TX filter high
  else if ext = 'DH' then ans := '' // STUB: TX DSP high cut
  else if ext = 'DM' then ans := ZZDM(s)
  else if ext = 'DN' then ans := ZZDN(s)
  else if ext = 'DO' then ans := '' // STUB: sub-RX on
  else if ext = 'DP' then ans := '' // STUB: display position
  else if ext = 'DQ' then ans := '' // STUB: sub-RX
  else if ext = 'DR' then ans := '' // STUB: sub-RX
  else if ext = 'DU' then begin ZZDU; ans := ''; end
  else if ext = 'DX' then ans := '' // STUB: sub NB
  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: EQ reset
  else if ext = 'ET' then ans := '' // STUB: EQ TX
  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 filter
  else if ext = 'FL' then ans := ZZFL(s)
  else if ext = 'FM' then ans := ZZFM
  else if ext = 'FR' then ans := '' // STUB: filter RX
  else if ext = 'FS' then ans := '' // STUB: sub filter
  else if ext = 'FT' then ans := ZZFT(s)
  else if ext = 'FV' then ans := '' // STUB: filter var
  else if ext = 'FW' then ans := '' // STUB: filter write
  else if ext = 'FX' then ans := '' // STUB: filter extended
  else if ext = 'FY' then ans := '' // STUB: filter Y
  else if ext = 'GA' then ans := ZZGA(s)
  else if ext = 'GE' then ans := '' // STUB: gain enable
  else if ext = 'GL' then ans := '' // STUB: gain level
  else if ext = 'GR' then ans := ZZGR(s)
  else if ext = 'GT' then ans := ZZGT(s)
  else if ext = 'GU' then ans := '' // STUB: gain update
  else if ext = 'HA' then ans := '' // STUB: hardware A
  else if ext = 'HR' then ans := '' // STUB: hardware read
  else if ext = 'HT' then ans := '' // STUB: hardware TX
  else if ext = 'HU' then ans := '' // STUB: hardware update
  else if ext = 'HV' then ans := '' // STUB: hardware value
  else if ext = 'HW' then ans := '' // STUB: hardware write
  else if ext = 'HX' then ans := '' // STUB: hardware extended
  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: initial TX
  else if ext = 'IU' then ans := '' // STUB: initial update
  else if ext = 'JO' then ans := '' // STUB: JSON
  else if ext = 'JP' then ans := '' // STUB: JSON preset
  else if ext = 'JQ' then ans := '' // STUB: JSON query
  else if ext = 'JR' then ans := '' // STUB: JSON read
  else if ext = 'JS' then ans := '' // STUB: JSON status
  else if ext = 'KM' then ans := ZZKM(s)
  else if ext = 'KO' then ans := '' // STUB: key on
  else if ext = 'KS' then ans := ZZKS(s)
  else if ext = 'KY' then ans := ZZKY(s)
  else if ext = 'LA' then ans := '' // STUB: level A
  else if ext = 'LB' then ans := '' // STUB: level B
  else if ext = 'LC' then ans := '' // STUB: level C
  else if ext = 'LD' then ans := '' // STUB: level D
  else if ext = 'LE' then ans := '' // STUB: level E
  else if ext = 'LF' then ans := '' // STUB: level F
  else if ext = 'LG' then ans := '' // STUB: level G
  else if ext = 'LH' then ans := '' // STUB: level H
  else if ext = 'LI' then ans := '' // STUB: level I
  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: mode enable
  else if ext = 'MF' then ans := '' // STUB: mode freq
  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: mode RX
  else if ext = 'MS' then ans := '' // STUB: mode status
  else if ext = 'MT' then ans := '' // STUB: mode TX
  else if ext = 'MU' then ans := '' // STUB: mode update
  else if ext = 'MV' then ans := ZZMV
  else if ext = 'MW' then ans := '' // STUB: mode write
  else if ext = 'MX' then ans := '' // STUB: mode extended
  else if ext = 'MY' then ans := ZZMY
  else if ext = 'MZ' then ans := '' // STUB: mode Z
  else if ext = 'NA' then ans := ZZNA(s)
  else if ext = 'NB' then ans := ZZNB(s)
  else if ext = 'NC' then ans := '' // STUB: NR C
  else if ext = 'ND' then ans := '' // STUB: NR D
  else if ext = 'NE' then ans := '' // STUB: NR E
  else if ext = 'NF' then ans := '' // STUB: NR F
  else if ext = 'NG' then ans := '' // STUB: noise gate
  else if ext = 'NH' then ans := '' // STUB: NR H
  else if ext = 'NL' then ans := ZZNL(s)
  else if ext = 'NM' then ans := '' // STUB: NR mode
  else if ext = 'NN' then ans := ZZNN(s)
  else if ext = 'NO' then ans := '' // STUB: sub NR
  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: NR update
  else if ext = 'NV' then ans := '' // STUB: NR value
  else if ext = 'NW' then ans := '' // STUB: NR write
  else if ext = 'OA' then ans := ZZOA(s)
  else if ext = 'OB' then ans := ZZOB(s)
  else if ext = 'OC' then ans := '' // STUB: output compression
  else if ext = 'OD' then ans := '' // STUB: output DSP
  else if ext = 'OE' then ans := '' // STUB: output E
  else if ext = 'OF' then ans := '' // STUB: sub-RX freq
  else if ext = 'OG' then ans := '' // STUB: output gain
  else if ext = 'OH' then ans := '' // STUB: output high
  else if ext = 'OJ' then ans := '' // STUB: output J
  else if ext = 'OL' then ans := '' // STUB: output level
  else if ext = 'OS' then ans := '' // STUB: output status
  else if ext = 'OT' then ans := '' // STUB: FM repeater offset
  else if ext = 'OU' then ans := '' // STUB: output update
  else if ext = 'OV' then ans := '' // STUB: output value
  else if ext = 'OW' then ans := '' // STUB: output write
  else if ext = 'OX' then ans := '' // STUB: oxide extra
  else if ext = 'OZ' then ans := '' // STUB: oxide Z
  else if ext = 'PA' then ans := '' // STUB: sub-RX pan
  else if ext = 'PB' then ans := '' // STUB: power B
  else if ext = 'PC' then ans := ZZPC(s)
  else if ext = 'PD' then ans := ZZPD
  else if ext = 'PE' then ans := '' // STUB: power enable
  else if ext = 'PO' then ans := ZZPO(s)
  else if ext = 'PS' then ans := ZZPS(s)
  else if ext = 'PY' then ans := '' // STUB: power Y
  else if ext = 'PZ' then ans := '' // STUB: power Z
  else if ext = 'QK' then ans := '' // STUB: query keep
  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: RIT B
  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 freq
  else if ext = 'RH' then ans := '' // STUB: RIT high
  else if ext = 'RL' then ans := ZZRL(s)
  else if ext = 'RM' then ans := '' // STUB: RIT mode
  else if ext = 'RS' then ans := '' // STUB: RIT status
  else if ext = 'RT' then ans := '' // STUB: RIT transmit
  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: RIT Y
  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: sub-RX display
  else if ext = 'SF' then ans := '' // STUB: sweep freq
  else if ext = 'SG' then ans := '' // STUB: sub gain
  else if ext = 'SH' then ans := '' // STUB: sub high cut
  else if ext = 'SM' then ans := ZZSM(s)
  else if ext = 'SN' then ans := '' // STUB: serial number
  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: sub-RX
  else if ext = 'ST' then ans := ZZST(s)
  else if ext = 'SU' then begin ZZSU; ans := ''; end
  else if ext = 'SV' then ans := '' // STUB: spectrum value
  else if ext = 'SW' then ans := '' // STUB: spectrum write
  else if ext = 'SX' then ans := '' // STUB: spectrum X
  else if ext = 'SY' then ans := '' // STUB: spectrum Y
  else if ext = 'SZ' then ans := '' // STUB: spectrum Z
  else if ext = 'TA' then ans := ZZTA(s)
  else if ext = 'TB' then ans := ZZTB(s)
  else if ext = 'TF' then ans := '' // STUB: tone freq
  else if ext = 'TH' then ans := '' // STUB: tone high
  else if ext = 'TI' then ans := '' // STUB: tone input
  else if ext = 'TL' then ans := '' // STUB: tone level
  else if ext = 'TM' then ans := '' // STUB: tone mode
  else if ext = 'TO' then ans := '' // STUB: tone on/off
  else if ext = 'TP' then ans := '' // STUB: tone preset
  else if ext = 'TS' then ans := '' // STUB: tone status
  else if ext = 'TU' then ans := '' // STUB: tone update
  else if ext = 'TV' then ans := '' // STUB: tone value
  else if ext = 'TX' then ans := ZZTX(s)
  else if ext = 'UA' then ans := '' // STUB: update A
  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 := ZZVB(s)
  else if ext = 'VC' then ans := '' // STUB: voice compression
  else if ext = 'VD' then ans := '' // STUB: VFO DSP
  else if ext = 'VE' then ans := '' // STUB: VFO enable
  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: VFO high
  else if ext = 'VI' then ans := '' // STUB: VFO input
  else if ext = 'VJ' then ans := '' // STUB: VFO J
  else if ext = 'VK' then ans := '' // STUB: VFO K
  else if ext = 'VL' then ans := '' // STUB: VFO level
  else if ext = 'VM' then ans := '' // STUB: VFO mode
  else if ext = 'VN' then ans := ZZVN
  else if ext = 'VO' then ans := '' // STUB: VFO on/off
  else if ext = 'VP' then ans := '' // STUB: VFO preset
  else if ext = 'VQ' then ans := '' // STUB: VFO Q
  else if ext = 'VR' then ans := '' // STUB: VFO read
  else if ext = 'VS' then ans := ZZVS(s)
  else if ext = 'VT' then ans := '' // STUB: VFO TX
  else if ext = 'VU' then ans := '' // STUB: VFO update
  else if ext = 'VV' then ans := '' // STUB: VFO value
  else if ext = 'VW' then ans := '' // STUB: VFO write
  else if ext = 'VX' then ans := '' // STUB: VFO extended
  else if ext = 'VY' then ans := '' // STUB: VFO Y
  else if ext = 'VZ' then ans := '' // STUB: VFO Z
  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: waterfall T
  else if ext = 'WU' then ans := '' // STUB: waterfall U
  else if ext = 'WV' then ans := '' // STUB: waterfall V
  else if ext = 'WW' then ans := '' // STUB: waterfall W
  else if ext = 'XA' then ans := ZZXA(s)
  else if ext = 'XC' then ans := ZZXC
  else if ext = 'XD' then ans := '' // STUB: extended D
  else if ext = 'XF' then ans := ZZXF(s)
  else if ext = 'XH' then ans := '' // STUB: extended H
  else if ext = 'XN' then ans := '' // STUB: extended N
  else if ext = 'XO' then ans := '' // STUB: extended O
  else if ext = 'XS' then ans := '' // STUB: extended S
  else if ext = 'XT' then ans := '' // STUB: extended T
  else if ext = 'XU' then ans := '' // STUB: extended U
  else if ext = 'XV' then ans := '' // STUB: extended V
  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: system N
  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: RX1 AF gain — alias ZZAG
begin Result := ZZAG(s); end;

function TCATEngine.ZZAB(const s: string): string;
// ZZAB: RX2 AF gain — STUB: no RX2
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: band selection (2-digit band index)
var idx: Integer;
begin
  if Length(s) = 2 then begin
    idx := EnsureRange(StrToIntDef(s, 0), 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.ZZAG(const s: string): string;
// ZZAG: AF gain 0–100
var v: Integer;
begin
  if Length(s) = 3 then begin
    v := EnsureRange(StrToIntDef(s, 0), 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: TX power 0–100
begin Result := ZZPC(s); 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
    v := StrToIntDef(s, 0);  // StrToIntDef понимает ведущий +/-
    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).
var idx: Integer;
begin
  if Length(s) = 2 then begin
    idx := EnsureRange(StrToIntDef(s, 0), 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).
var idx: Integer;
begin
  if Length(s) = 2 then begin
    idx := EnsureRange(StrToIntDef(s, 0), 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: sub-RX enable — STUB: no 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: antenna tuner — STUB: no ATU
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: sub-RX AF gain — STUB: no RX2
begin Result := '050'; end;

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

function TCATEngine.ZZBE(const s: string): string;
// ZZBE: band enable — STUB: all bands always enabled
begin
  if Length(s) = 3 then Result := ''
  else if Length(s) = 2 then Result := '1'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZBI(const s: string): string;
// ZZBI: filter index (2-digit, 0-9)
var idx: Integer;
begin
  if Length(s) = 2 then begin
    idx := EnsureRange(StrToIntDef(s, 0), 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.ZZBM(const s: string): string;
// ZZBM: band mode — set mode for current band
begin Result := ZZMD(s); end;

function TCATEngine.ZZBS(const s: string): string;
// ZZBS: band select — same as ZZAC
begin Result := ZZAC(s); end;

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

function TCATEngine.ZZCB(const s: string): string;
// ZZCB: compression bypass — STUB: TX DSP not implemented
begin
  if Length(s) = 1 then Result := '' else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZCD(const s: string): string;
// ZZCD: compressor drive — STUB: TX DSP not implemented
begin
  if Length(s) = 3 then Result := '' else if Length(s) = 0 then Result := '050'
  else Result := CAT_ERROR;
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: compressor status — STUB
begin
  if Length(s) = 0 then Result := '0' else Result := CAT_ERROR;
end;

function TCATEngine.ZZDA(const s: string): string;
// ZZDA: diversity A — STUB: diversity not implemented
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 B — 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: display mode — STUB: no separate display mode setting
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: drive level — alias ZZPC
begin Result := ZZPC(s); end;

function TCATEngine.ZZDU: string;
// ZZDU: display units update — 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: RX EQ profile name — STUB: no EQ
begin
  if Length(s) > 0 then Result := '' else Result := ''; // no EQ
end;

function TCATEngine.ZZEB(const s: string): string;
// ZZEB: TX EQ profile name — STUB: TX DSP not implemented
begin
  if Length(s) > 0 then Result := '' else 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: FM deviation — STUB: FM deviation not configurable
begin
  if Length(s) = 1 then Result := '' else if Length(s) = 0 then Result := '1'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZFI(const s: string): string;
// ZZFI: filter index (2-digit)
begin Result := ZZBI(s); end;

function TCATEngine.ZZFL(const s: string): string;
// ZZFL: filter low cut (signed Hz, e.g. "-0150"). Работает, если контекст умеет
// кромки (слайс-CAT); у главного приёмника фильтр индексный — заглушка.
begin
  if Length(s) >= 4 then begin
    if Assigned(FCtx.SetFilterLow) then FCtx.SetFilterLow(StrToIntDef(s, 0));
    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: filter high cut (signed Hz) — см. ZZFL.
begin
  if Length(s) >= 4 then begin
    if Assigned(FCtx.SetFilterHigh) then FCtx.SetFilterHigh(StrToIntDef(s, 0));
    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: TX frequency — same as ZZFA (no split yet)
begin Result := ZZFA(s); end;

function TCATEngine.ZZGA(const s: string): string;
// ZZGA: GUID add (TCP client ID management) — handled by TCP server layer
// 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 remove (TCP client ID) — handled by TCP server layer
begin
  if Length(s) = 36 then Result := s
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZGT(const s: string): string;
// ZZGT: AGC mode (1 char: 0=FAST,1=MED,2=SLOW,3=LONG,4=OFF) → 3-digit response
var m: Integer;
begin
  if Length(s) = 1 then begin
    m := EnsureRange(StrToIntDef(s, 0), 0, 4);
    if Assigned(FCtx.SetAGCMode) then FCtx.SetAGCMode(m);
    Result := '';
  end else if Length(s) = 0 then
    Result := Pad(SafeGetAGCMode, 3)
  else
    Result := CAT_ERROR;
end;

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

function TCATEngine.ZZIF(const s: string): string;
// ZZIF: IF — STUB: secondary interface (returns same as IF)
begin
  if Length(s) = 0 then Result := CmdIF
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZIO: string;
// ZZIO: I/O status — STUB
begin Result := '0'; end;

function TCATEngine.ZZIS(const s: string): string;
// ZZIS: initial setup flag — STUB
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 key mode — STUB: no CW keyer
begin
  if Length(s) = 1 then Result := '' else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZKS(const s: string): string;
// ZZKS: CW speed (WPM) — STUB: no CW keyer
begin
  if Length(s) = 3 then Result := '' else if Length(s) = 0 then Result := '025'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZKY(const s: string): string;
// ZZKY: CW text to send — STUB: no CW keyer implemented
begin
  Result := '0'; // buffer not full
end;

function TCATEngine.ZZMA(const s: string): string;
// ZZMA: RX1 mode
begin Result := ZZMD(s); end;

function TCATEngine.ZZMB(const s: string): string;
// ZZMB: RX2 mode — STUB: no RX2; returns same as RX1
begin Result := ZZMD(s); 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: microphone gain 0–70 (Thetis scale) — STUB: TX DSP not implemented
begin
  if Length(s) = 3 then Result := '' else if Length(s) = 0 then Result := '050'
  else Result := CAT_ERROR;
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: mode name — returns mode name for given mode number.
// Индекс режима двузначный (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: monitor on/off — STUB: no TX monitor
begin
  if Length(s) = 1 then Result := '' else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZMV: string;
// ZZMV: mode value — returns current ewsdr mode index (MODE_MIN..MODE_MAX)
begin Result := Pad(SafeGetMode, 2); end;

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

function TCATEngine.ZZNA(const s: string): string;
// ZZNA: noise blanker A (NB1) on/off
var v: Integer;
begin
  if Length(s) = 1 then begin
    v := StrToIntDef(s, 0);
    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
    v := EnsureRange(StrToIntDef(s,0),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: NB level — STUB: NB level not configurable
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: notch (manual) on/off — STUB: no manual notch
begin
  if Length(s) = 1 then Result := '' else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
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
    v := EnsureRange(StrToIntDef(s,0),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: spectral noise blanker (SNB) on/off
begin
  if Length(s) = 1 then begin
    if Assigned(FCtx.SetSNBEnabled) then FCtx.SetSNBEnabled(s = '1');
    Result := '';
  end else if Length(s) = 0 then
    Result := B2C(SafeGetSNB)
  else
    Result := CAT_ERROR;
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 output level — alias ZZAG
begin Result := ZZAG(s); end;

function TCATEngine.ZZOB(const s: string): string;
// ZZOB: RX2 output level — STUB: no 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
    v := EnsureRange(StrToIntDef(s,0),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: power default — return nominal 100
begin Result := '100'; end;

function TCATEngine.ZZPO(const s: string): string;
// ZZPO: power on/off — STUB: always on when running
begin
  if Length(s) = 1 then Result := ''
  else if Length(s) = 0 then Result := B2C(SafeGetRunning)
  else Result := CAT_ERROR;
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: current mode query
begin Result := IntToStr(ModeToK(SafeGetMode)); 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: RIT A value — STUB: no RIT
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: tune/RIT down
begin
  if Assigned(FCtx.DoTuneDown) then FCtx.DoTuneDown;
  Result := '';
end;

function TCATEngine.ZZRL(const s: string): string;
// ZZRL: RF gain — STUB: no RF gain control
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: tune/RIT up
begin
  if Assigned(FCtx.DoTuneUp) then FCtx.DoTuneUp;
  Result := '';
end;

function TCATEngine.ZZRV: string;
// ZZRV: software version
begin Result := '001000'; end;  // version 0.1.0

function TCATEngine.ZZRX(const s: string): string;
// ZZRX: switch to receive
begin
  if Assigned(FCtx.SetTransmitting) then FCtx.SetTransmitting(False);
  if Length(s) = 0 then Result := B2C(not SafeGetTX)
  else Result := '';
end;

function TCATEngine.ZZSA: string;
// ZZSA: VFO tune down (maps to step down)
begin
  if Assigned(FCtx.DoTuneDown) then FCtx.DoTuneDown;
  Result := '';
end;

function TCATEngine.ZZSB: string;
// ZZSB: VFO tune up (maps to step up)
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
    v := EnsureRange(StrToIntDef(s, 0), 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: spectrum reference level (dBm, signed 4 chars e.g. "-020")
begin
  if Length(s) = 4 then Result := ''  // STUB: spec ref not settable via CAT
  else if Length(s) = 0 then Result := '-020'
  else Result := CAT_ERROR;
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.
begin
  if Length(s) = 2 then begin
    if Assigned(FCtx.SetFMStep) then FCtx.SetFMStep(EnsureRange(StrToIntDef(s, 0), 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 on/off (1=on, 0=off) для FM-передачи.
begin
  if Length(s) = 1 then begin
    if Assigned(FCtx.SetCTCSSOn) then FCtx.SetCTCSSOn(s = '1');
    Result := '';
  end else if Length(s) = 0 then
    Result := B2C(SafeGetCTCSSOn)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZTB(const s: string): string;
// ZZTB: CTCSS tone index (2 digits, 0-based 00..37 в таблице CTCSS_TONES).
begin
  if Length(s) = 2 then begin
    if Assigned(FCtx.SetCTCSSTone) then FCtx.SetCTCSSTone(EnsureRange(StrToIntDef(s, 0), 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: TX on/off (0=RX, 1=TX, 2=TUNE)
begin
  if Length(s) = 1 then begin
    if Assigned(FCtx.SetTransmitting) then FCtx.SetTransmitting(s <> '0');
    Result := '';
  end else if Length(s) = 0 then
    Result := B2C(SafeGetTX)
  else
    Result := CAT_ERROR;
end;

function TCATEngine.ZZUP(const s: string): string;
// ZZUP: xPA on/off — STUB: no external PA control
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: two-tone test — STUB: TX DSP not implemented
begin Result := ''; end;

function TCATEngine.ZZUT(const s: string): string;
// ZZUT: TX update — STUB
begin
  if Length(s) = 1 then Result := '' else if Length(s) = 0 then Result := '0'
  else Result := CAT_ERROR;
end;

function TCATEngine.ZZUX(const s: string): string;
// ZZUX: VFO A lock — STUB
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 lock — STUB
begin Result := ZZUX(s); end;

function TCATEngine.ZZVA(const s: string): string;
// ZZVA: swap VFO A↔B
var tmp: Double;
begin
  if Length(s) = 0 then begin
    if Assigned(FCtx.GetVfoA) and Assigned(FCtx.GetVfoB) and
       Assigned(FCtx.SetVfoA) and Assigned(FCtx.SetVfoB) then begin
      tmp := FCtx.GetVfoA;
      FCtx.SetVfoA(FCtx.GetVfoB);
      FCtx.SetVfoB(tmp);
    end;
    Result := '';
  end else Result := CAT_ERROR;
end;

function TCATEngine.ZZVB(const s: string): string;
// ZZVB: copy VFO A to B — STUB: needs explicit operation
begin
  if Length(s) = 0 then begin
    if Assigned(FCtx.GetVfoA) and Assigned(FCtx.SetVfoB) then
      FCtx.SetVfoB(FCtx.GetVfoA);
    Result := '';
  end else Result := CAT_ERROR;
end;

function TCATEngine.ZZVG(const s: string): string;
// ZZVG: volume/gain — alias ZZAG
begin Result := ZZAG(s); 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 split — STUB
begin Result := ZZSP(s); end;

// WA..WS: Waterfall settings — STUB: waterfall not controllable via CAT
// These are Thetis-specific waterfall display controls
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;

// XA, XC, XF: extended controls — STUB: Thetis-specific hardware
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;

// YA..YR: Y-axis / spectrum controls — STUB
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;

// ZA..ZZ: System commands — STUB (most are Thetis-specific internal controls)
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 — поворот энкодера. Кодировка Thetis: верхние 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: system reset — STUB: would require restarting DSP engine
begin Result := ''; end;
function TCATEngine.ZZZO(const s: string): string;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;

end.
