feat(cat): Andromeda/G2 front panel support (serial, LED indicators)

Bidirectional Apache Labs Andromeda/G2 front-panel protocol on top of CAT.
Panel logic lives in dedicated units; existing files get only thin hooks.

New units:
- AndromedaMap.pas   — data only: action enums + default button/encoder
  layout tables and LED (ZZZI) numbers (mirroring the Thetis defaults)
- AndromedaPanel.pas — TAndromedaPanel: inbound panel events -> controller
  actions (marshalled via Invoke), state subscription -> ZZZI push via SendProc

Hooks:
- CATEngine: parse ZZZD/ZZZU/ZZZE/ZZZP/ZZZS into generic TCATContext callbacks
- CATAdapter: creates the panel, routes engine callbacks to it, supplies the
  SendProc to the Andromeda port, AddStateListener, ZZZS handshake
- CATSerial: Andromeda flag in port config + HasAndromeda/SendToAndromeda
- RadioController: AddStateListener (multicast, does not steal MainForm's
  OnStateChanged)
- Settings/SettingsForm/MainForm: CATSerialAndromeda[0..3] + persistence + checkbox

First version: serial transport, hardcoded default mapping, LED indicators
(MOX/Tune/CTUN/VFO A-B/Split/NB/NR/SNB/ANF/Squelch). Doc: doc/ANDROMEDA.md.

TODO: default button mapping does not match the physical panel (hardcoded) —
to be fixed later; also ZZMF/LCD text, TCP G2V2 transport, configurable mapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:40:10 +03:00
co-authored by Claude Opus 4.8
parent 4a92624e9b
commit 2d7290e44d
10 changed files with 704 additions and 9 deletions
+134
View File
@@ -0,0 +1,134 @@
unit AndromedaMap;
{
AndromedaMap.pas — таблицы раскладки передней панели Apache Labs Andromeda / G2.
Только ДАННЫЕ: перечисления действий и дефолтные таблицы
«номер кнопки → действие», «номер энкодера → параметр»,
«индикатор → номер LED (ZZZI)». Без логики и без зависимостей от
контроллера — этим занимается AndromedaPanel.pas.
Соответствие протоколу Thetis:
• действия кнопок/энкодеров — подмножество EButtonBarActions / EEncoderActions;
• номера LED-индикаторов (ZZZI nn) зеркалят дефолтную таблицу Thetis
(MakeNewAndromedaDataset): 1=MOX, 3=Tune, 7=CTUN и т.д. Свободные слоты
12..17 заняты функциями ewsdr (Split/NB/NR/SNB/ANF/Squelch).
}
{$mode objfpc}{$H+}
interface
type
// Действие кнопки панели (подмножество, поддержанное ewsdr).
TAndButton = (
abNone, abMOX, abTune, abBandUp, abBandDown, abModeUp, abModeDown,
abMute, abNB, abNR, abSNB, abANF, abVFOSplit, abVFOSwap, abCTUN,
abCTCSS, abSquelch, abFilterUp, abFilterDown);
// Параметр, управляемый поворотным энкодером панели.
TAndEncoder = (
aeNone, aeAFGain, aeAGCLevel, aeDrive, aeSquelch,
aeFilterWidth, aeMulti);
// Индикатор (LED) панели. Порядок зеркалит EIndicatorActions Thetis.
TAndIndicator = (
aiNone, aiMOX, aiTune, aiRIT, aiXIT, aiSplit, aiCTune, aiVFOSync,
aiVFOAB, aiVFOLock, aiNB, aiNR, aiSNB, aiANF, aiSquelch,
aiCompander, aiPuresignal, aiDiversity, aiATUReady, aiShift);
const
// Размеры дефолтных таблиц (кнопки/энкодеры нумеруются с 0 после разбора ZZZP/ZZZE).
AND_MAX_BUTTONS = 32;
AND_MAX_ENCODERS = 20;
// Раскладка кнопок: индекс = номер кнопки (0-based), значение = действие.
// Неназначенные → abNone. Дефолт ewsdr (правится при появлении конкретной панели).
AND_BUTTON_MAP: array[0..AND_MAX_BUTTONS-1] of TAndButton = (
abMOX, // 0
abTune, // 1
abBandUp, // 2
abBandDown, // 3
abModeUp, // 4
abModeDown, // 5
abNB, // 6
abNR, // 7
abANF, // 8
abSNB, // 9
abVFOSplit, // 10
abVFOSwap, // 11
abCTUN, // 12
abMute, // 13
abCTCSS, // 14
abSquelch, // 15
abFilterUp, // 16
abFilterDown, // 17
abNone, abNone, abNone, abNone, abNone, abNone, abNone, abNone,
abNone, abNone, abNone, abNone, abNone, abNone);
// Раскладка энкодеров: индекс = номер энкодера (0-based), значение = параметр.
AND_ENCODER_MAP: array[0..AND_MAX_ENCODERS-1] of TAndEncoder = (
aeAFGain, // 0
aeAGCLevel, // 1
aeDrive, // 2
aeSquelch, // 3
aeFilterWidth, // 4
aeNone, aeNone, aeNone, aeNone, aeNone,
aeNone, aeNone, aeNone, aeNone, aeNone,
aeNone, aeNone, aeNone, aeNone, aeMulti);
// Номер LED (ZZZI nn) для каждого индикатора; 0 = панель этот индикатор не имеет.
// 1..11 — дефолт Thetis; 12..17 — функции ewsdr на свободных слотах.
AND_INDICATOR_NUM: array[TAndIndicator] of Integer = (
{aiNone} 0,
{aiMOX} 1,
{aiTune} 3,
{aiRIT} 8,
{aiXIT} 9,
{aiSplit} 12,
{aiCTune} 7,
{aiVFOSync} 0,
{aiVFOAB} 10,
{aiVFOLock} 11,
{aiNB} 13,
{aiNR} 14,
{aiSNB} 15,
{aiANF} 16,
{aiSquelch} 17,
{aiCompander} 0,
{aiPuresignal} 0,
{aiDiversity} 0,
{aiATUReady} 2,
{aiShift} 6);
{ Возвращает действие для номера кнопки (0-based); abNone вне диапазона. }
function ButtonAction(BtnIndex: Integer): TAndButton;
{ Возвращает параметр для номера энкодера (0-based); aeNone вне диапазона. }
function EncoderParam(EncIndex: Integer): TAndEncoder;
{ Номер ZZZI для индикатора; 0 если панель его не отображает. }
function IndicatorNum(Ind: TAndIndicator): Integer;
implementation
function ButtonAction(BtnIndex: Integer): TAndButton;
begin
if (BtnIndex >= 0) and (BtnIndex < AND_MAX_BUTTONS) then
Result := AND_BUTTON_MAP[BtnIndex]
else
Result := abNone;
end;
function EncoderParam(EncIndex: Integer): TAndEncoder;
begin
if (EncIndex >= 0) and (EncIndex < AND_MAX_ENCODERS) then
Result := AND_ENCODER_MAP[EncIndex]
else
Result := aeNone;
end;
function IndicatorNum(Ind: TAndIndicator): Integer;
begin
Result := AND_INDICATOR_NUM[Ind];
end;
end.
+221
View File
@@ -0,0 +1,221 @@
unit AndromedaPanel;
{
AndromedaPanel.pas — логика передней панели Apache Labs Andromeda / G2.
TAndromedaPanel — «мозг» панели поверх TRadioController:
• входящие события (энкодер VFO, поворот энкодера, нажатие кнопки, версия HW)
транслируются в действия контроллера по таблицам AndromedaMap;
• изменения состояния контроллера (через AddStateListener) превращаются в
сообщения подсветки ZZZI и отдаются наружу через callback SendProc.
Архитектура: панель зависит ТОЛЬКО от TRadioController и AndromedaMap. Транспорт
(serial-порт) и связывание с CAT-движком — в CATAdapter, который создаёт панель,
даёт ей SendProc и маршрутизирует к ней callback'и движка.
Потоки: входящие хэндлеры зовутся из потока транспорта (CAT serial) и
маршалятся в поток контроллера через FController.Invoke. Слушатель состояния
и SendProc работают из потока контроллера — формирование строки и отправка в
порт потокобезопасны (serial SendStr под своим локом).
}
{$mode objfpc}{$H+}
interface
uses
SysUtils, RadioController, AndromedaMap;
const
AND_VFO_STEP_HZ = 10; // Гц на один шаг энкодера VFO (как DoTuneUp/Down в CAT)
AND_BW_STEP_HZ = 50; // Гц на шаг энкодера ширины фильтра
MODE_COUNT_CYCLE = 8; // MODE_LSB..MODE_SAM (0..7) для перебора режимов
type
TAndromedaSend = procedure(const S: string) of object;
TAndromedaPanel = class
private
FController: TRadioController;
FSend: TAndromedaSend;
FConnected: Boolean; // панель прислала версию (ZZZS)
FIndState: array[0..20] of Integer; // кэш состояний LED: -1 неизв., 0/1
// scratch для маршалинга в поток контроллера
FPendingVfoSteps: Integer;
FPendingEnc: TAndEncoder;
FPendingStep: Integer;
FPendingButton: TAndButton;
procedure SyncVfo;
procedure SyncEncoder;
procedure SyncButton;
procedure PushIndicator(Ind: TAndIndicator; State: Boolean);
procedure SendIndicatorNum(Num: Integer; State: Boolean);
public
constructor Create(AController: TRadioController; ASend: TAndromedaSend);
// Вызывается при включении панели: запрос версии + (после ответа) синк LED.
procedure RequestVersion;
// Полная синхронизация всех управляемых индикаторов с текущим состоянием.
procedure SyncAllIndicators;
// ── Входящие события панели (из потока транспорта) ──
procedure HandleVfoStep(Steps: Integer);
procedure HandleEncoder(Enc, Step: Integer);
procedure HandleButton(Btn: Integer; State, LongPress: Boolean);
procedure HandleVersion(Version: Integer);
// ── Слушатель состояния контроллера (зарегистрировать через AddStateListener) ──
procedure OnControllerState(Sender: TObject; Field: TRadioField);
property Connected: Boolean read FConnected;
end;
implementation
constructor TAndromedaPanel.Create(AController: TRadioController; ASend: TAndromedaSend);
var i: Integer;
begin
inherited Create;
FController := AController;
FSend := ASend;
FConnected := False;
for i := Low(FIndState) to High(FIndState) do FIndState[i] := -1;
end;
procedure TAndromedaPanel.RequestVersion;
begin
if Assigned(FSend) then FSend('ZZZS;'); // панель ответит ZZZS<v> → HandleVersion
end;
{ ── Входящие события ───────────────────────────────────────────────────────── }
procedure TAndromedaPanel.HandleVfoStep(Steps: Integer);
begin
if Steps = 0 then Exit;
FPendingVfoSteps := Steps;
FController.Invoke(@SyncVfo);
end;
procedure TAndromedaPanel.SyncVfo;
begin
FController.TuneActiveBy(FPendingVfoSteps * AND_VFO_STEP_HZ);
end;
procedure TAndromedaPanel.HandleEncoder(Enc, Step: Integer);
var p: TAndEncoder;
begin
p := EncoderParam(Enc);
if (p = aeNone) or (Step = 0) then Exit;
FPendingEnc := p;
FPendingStep := Step;
FController.Invoke(@SyncEncoder);
end;
procedure TAndromedaPanel.SyncEncoder;
begin
case FPendingEnc of
aeAFGain: FController.SetVolume(FController.FVolume + FPendingStep);
aeAGCLevel: FController.SetAGCTop(FController.FAGCTop + FPendingStep);
aeDrive: FController.SetDrive(FController.FDrivePercent + FPendingStep);
aeSquelch: FController.SetFMSquelchLevel(FController.FFMSQLevel + FPendingStep);
aeFilterWidth: FController.SetFilterBW(FController.FFilterBW + FPendingStep * AND_BW_STEP_HZ);
// aeMulti — мультифункц. энкодер: пока не назначен
end;
end;
procedure TAndromedaPanel.HandleButton(Btn: Integer; State, LongPress: Boolean);
var a: TAndButton;
begin
if not State then Exit; // реагируем на нажатие; отпускание/long-press — позже
a := ButtonAction(Btn);
if a = abNone then Exit;
FPendingButton := a;
FController.Invoke(@SyncButton);
end;
procedure TAndromedaPanel.SyncButton;
begin
case FPendingButton of
abMOX: FController.SetMOX(not FController.FTransmitting);
abTune: FController.SetTune(not FController.FTuning);
abBandUp: FController.BandUp;
abBandDown: FController.BandDown;
abModeUp: FController.SetMode((FController.FMode + 1) mod MODE_COUNT_CYCLE);
abModeDown: FController.SetMode((FController.FMode + MODE_COUNT_CYCLE - 1) mod MODE_COUNT_CYCLE);
abMute: FController.SetMute(not FController.FMuted);
abNB: if FController.FNBMode > 0 then FController.SetNB(0) else FController.SetNB(1);
abNR: if FController.FNRMode > 0 then FController.SetNR(0) else FController.SetNR(1);
abSNB: FController.SetSNB(not FController.FSNB);
abANF: FController.SetANF(not FController.FANF);
abVFOSplit: FController.SetSplit(not FController.FSplitTxB);
abVFOSwap: FController.SetActiveVfo(1 - FController.FActiveVfo);
abCTUN: FController.SetCTun(not FController.FCTun);
abCTCSS: FController.SetFMCTCSS(not FController.FFMCTCSSOn);
abSquelch: FController.SetFMSquelch(not FController.FFMSQOn);
abFilterUp: FController.SetFilterIdx(FController.FFilter + 1);
abFilterDown:FController.SetFilterIdx(FController.FFilter - 1);
end;
end;
procedure TAndromedaPanel.HandleVersion(Version: Integer);
begin
FConnected := True;
SyncAllIndicators; // панель подключилась — выставить актуальную подсветку
end;
{ ── Индикаторы (подсветка) ─────────────────────────────────────────────────── }
procedure TAndromedaPanel.SendIndicatorNum(Num: Integer; State: Boolean);
var v: Integer;
begin
if (Num <= 0) or (Num > High(FIndState)) then Exit;
if State then v := 1 else v := 0;
if FIndState[Num] = v then Exit; // не слать повтор
FIndState[Num] := v;
if Assigned(FSend) then
FSend('ZZZI' + Format('%.2d', [Num]) + IntToStr(v) + ';');
end;
procedure TAndromedaPanel.PushIndicator(Ind: TAndIndicator; State: Boolean);
begin
if not FConnected then Exit;
SendIndicatorNum(IndicatorNum(Ind), State);
end;
procedure TAndromedaPanel.SyncAllIndicators;
begin
// FConnected уже True к моменту вызова из HandleVersion
PushIndicator(aiMOX, FController.FTransmitting);
PushIndicator(aiTune, FController.FTuning);
PushIndicator(aiCTune, FController.FCTun);
PushIndicator(aiVFOAB, FController.FActiveVfo = 1);
PushIndicator(aiSplit, FController.FSplitTxB);
PushIndicator(aiNB, FController.FNBMode > 0);
PushIndicator(aiNR, FController.FNRMode > 0);
PushIndicator(aiSNB, FController.FSNB);
PushIndicator(aiANF, FController.FANF);
PushIndicator(aiSquelch, FController.FFMSQOn);
end;
procedure TAndromedaPanel.OnControllerState(Sender: TObject; Field: TRadioField);
begin
if not FConnected then Exit;
case Field of
rfTransmitting: PushIndicator(aiMOX, FController.FTransmitting);
rfTuning: PushIndicator(aiTune, FController.FTuning);
rfCTun: PushIndicator(aiCTune, FController.FCTun);
rfActiveVfo: begin
PushIndicator(aiVFOAB, FController.FActiveVfo = 1);
PushIndicator(aiSplit, FController.FSplitTxB);
end;
rfNB: PushIndicator(aiNB, FController.FNBMode > 0);
rfNR: PushIndicator(aiNR, FController.FNRMode > 0);
rfSNB: PushIndicator(aiSNB, FController.FSNB);
rfANF: PushIndicator(aiANF, FController.FANF);
rfFMSQ: PushIndicator(aiSquelch, FController.FFMSQOn);
end;
end;
end.
+34 -1
View File
@@ -20,7 +20,8 @@ interface
uses
Classes, SysUtils,
RadioController, CATEngine, CATSerial, CATTcp, Settings, WDSPEngine;
RadioController, CATEngine, CATSerial, CATTcp, Settings, WDSPEngine,
AndromedaPanel;
type
TCATAdapter = class
@@ -29,6 +30,7 @@ type
FEngine: TCATEngine;
FSerial: TCATSerialManager;
FTcp: TCATTcpServer;
FPanel: TAndromedaPanel; // передняя панель Andromeda/G2 (если включена)
// Параметры команды для маршалинга в поток контроллера (через Invoke).
FSyncFreq: Double;
FSyncInt: Integer;
@@ -93,6 +95,12 @@ type
procedure SetAGCTop(V: Integer);
procedure SetCTun(V: Boolean);
// ── Andromeda: форвард callback'ов движка в панель (из потока транспорта) ──
procedure PanelVfoStep(V: Integer);
procedure PanelEncoder(Enc, Step: Integer);
procedure PanelButton(Btn: Integer; State, LongPress: Boolean);
procedure PanelVersion(V: Integer);
// ── Sync-методы (выполняются в потоке контроллера) ──
procedure SyncVfoA;
procedure SyncVfoB;
@@ -194,10 +202,18 @@ begin
Ctx.SetAGCTop := @SetAGCTop;
Ctx.GetCTun := @GetCTun;
Ctx.SetCTun := @SetCTun;
Ctx.OnPanelVfoStep := @PanelVfoStep;
Ctx.OnPanelEncoder := @PanelEncoder;
Ctx.OnPanelButton := @PanelButton;
Ctx.OnPanelVersion := @PanelVersion;
FEngine := TCATEngine.Create(Ctx);
FSerial := TCATSerialManager.Create(FEngine);
FTcp := TCATTcpServer.Create(FEngine);
// Передняя панель: отправляет наружу через Andromeda-порт, слушает состояние.
FPanel := TAndromedaPanel.Create(FController, @FSerial.SendToAndromeda);
FController.AddStateListener(@FPanel.OnControllerState);
end;
destructor TCATAdapter.Destroy;
@@ -205,6 +221,7 @@ begin
if Assigned(FTcp) then begin FTcp.Stop; FreeAndNil(FTcp); end;
if Assigned(FSerial) then begin FSerial.StopAll; FreeAndNil(FSerial); end;
FreeAndNil(FEngine);
FreeAndNil(FPanel);
inherited Destroy;
end;
@@ -225,8 +242,11 @@ begin
2: Cfgs[i].Parity := cspEven;
else Cfgs[i].Parity := cspNone;
end;
Cfgs[i].Andromeda := G.CATSerialAndromeda[i];
end;
FSerial.ApplyConfig(Cfgs);
// Если назначен Andromeda-порт — запросить версию панели (рукопожатие ZZZS).
if FSerial.HasAndromeda and Assigned(FPanel) then FPanel.RequestVersion;
FTcp.Stop;
if G.CATTcpEnabled then
begin
@@ -442,4 +462,17 @@ begin FSyncBool := V; FController.Invoke(@SyncCTun); end;
procedure TCATAdapter.SyncCTun;
begin FController.SetCTun(FSyncBool); end;
// ── Andromeda: форвард в панель (сама маршалит действия в поток контроллера) ──
procedure TCATAdapter.PanelVfoStep(V: Integer);
begin if Assigned(FPanel) then FPanel.HandleVfoStep(V); end;
procedure TCATAdapter.PanelEncoder(Enc, Step: Integer);
begin if Assigned(FPanel) then FPanel.HandleEncoder(Enc, Step); end;
procedure TCATAdapter.PanelButton(Btn: Integer; State, LongPress: Boolean);
begin if Assigned(FPanel) then FPanel.HandleButton(Btn, State, LongPress); end;
procedure TCATAdapter.PanelVersion(V: Integer);
begin if Assigned(FPanel) then FPanel.HandleVersion(V); end;
end.
+73 -5
View File
@@ -48,6 +48,9 @@ type
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;
@@ -106,6 +109,11 @@ type
SetAGCTop: TCATSetIntProc; // AGC-T порог, dB
GetCTun: TCATGetBoolFunc; // Click-tune (CTUN) вкл/выкл
SetCTun: TCATSetBoolProc;
// 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
@@ -321,6 +329,8 @@ type
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;
@@ -1348,8 +1358,8 @@ begin
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 := '' // STUB: system D
else if ext = 'ZE' then ans := '' // STUB: system E
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)
@@ -2242,20 +2252,78 @@ begin if Length(s) > 0 then Result := '' else Result := '0'; end;
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;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
// 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;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
// 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;
begin if Length(s) > 0 then Result := '' else Result := '0'; end;
// 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;
+19
View File
@@ -47,6 +47,7 @@ type
DataBits: Integer; // 7 or 8
StopBits: Integer; // 1 or 2
Parity: TCATSerialParity;
Andromeda: Boolean; // порт — передняя панель Andromeda/G2 (push ZZZI наружу)
end;
{$IFDEF CAT_SERIAL_STUB}
@@ -95,12 +96,16 @@ type
private
FPorts: array[0..CAT_SERIAL_MAX_PORTS-1] of TCATSerialPort;
FEngine: TCATEngine;
FAndromedaPort: TCATSerialPort; // первый активный порт с Andromeda=true (или nil)
public
constructor Create(AEngine: TCATEngine);
destructor Destroy; override;
procedure ApplyConfig(const Configs: array of TCATSerialConfig);
procedure StopAll;
function ActiveCount: Integer;
// Передняя панель: есть ли активный Andromeda-порт и отправка строки в него.
function HasAndromeda: Boolean;
procedure SendToAndromeda(const S: string); // сигнатура совместима с TAndromedaSend
end;
implementation
@@ -267,6 +272,7 @@ var i: Integer;
begin
inherited Create;
FEngine := AEngine;
FAndromedaPort := nil;
for i := 0 to CAT_SERIAL_MAX_PORTS-1 do
FPorts[i] := nil;
end;
@@ -288,12 +294,25 @@ begin
for i := 0 to cnt-1 do begin
FPorts[i] := TCATSerialPort.Create(FEngine, Configs[i]);
if Configs[i].Enabled then FPorts[i].Start;
if Configs[i].Enabled and Configs[i].Andromeda and (FAndromedaPort = nil) then
FAndromedaPort := FPorts[i];
end;
end;
function TCATSerialManager.HasAndromeda: Boolean;
begin
Result := Assigned(FAndromedaPort);
end;
procedure TCATSerialManager.SendToAndromeda(const S: string);
begin
if Assigned(FAndromedaPort) then FAndromedaPort.SendStr(S);
end;
procedure TCATSerialManager.StopAll;
var i: Integer;
begin
FAndromedaPort := nil; // указывает в FPorts[] — обнулить ДО освобождения
for i := 0 to CAT_SERIAL_MAX_PORTS-1 do begin
if Assigned(FPorts[i]) then begin
FPorts[i].Stop;
+5
View File
@@ -476,6 +476,7 @@ type
const SerEnabled: array of Boolean;
const SerPort: array of string;
const SerBaud, SerDataBits, SerStopBits, SerParity: array of Integer;
const SerAndromeda: array of Boolean;
TcpEnabled: Boolean; TcpPort: Integer);
procedure OnAlexSettingsChange(const A: TAlexSettings);
procedure OnXvtrSettingsChange(const X: TXvtrSettings);
@@ -5872,6 +5873,7 @@ begin
FCATLastGlobal.CATSerialDataBits,
FCATLastGlobal.CATSerialStopBits,
FCATLastGlobal.CATSerialParity,
FCATLastGlobal.CATSerialAndromeda,
FCATLastGlobal.CATTcpEnabled,
FCATLastGlobal.CATTcpPort);
// Перечисляем PA устройства
@@ -5940,6 +5942,7 @@ procedure TMainForm.OnCATSettingsChange(
const SerEnabled: array of Boolean;
const SerPort: array of string;
const SerBaud, SerDataBits, SerStopBits, SerParity: array of Integer;
const SerAndromeda: array of Boolean;
TcpEnabled: Boolean; TcpPort: Integer);
var i: Integer;
begin
@@ -5951,6 +5954,7 @@ begin
if i <= High(SerDataBits) then FCATLastGlobal.CATSerialDataBits[i] := SerDataBits[i];
if i <= High(SerStopBits) then FCATLastGlobal.CATSerialStopBits[i] := SerStopBits[i];
if i <= High(SerParity) then FCATLastGlobal.CATSerialParity[i] := SerParity[i];
if i <= High(SerAndromeda)then FCATLastGlobal.CATSerialAndromeda[i]:= SerAndromeda[i];
end;
FCATLastGlobal.CATTcpEnabled := TcpEnabled;
FCATLastGlobal.CATTcpPort := TcpPort;
@@ -5962,6 +5966,7 @@ begin
FController.FLoadedGlobal.CATSerialDataBits := FCATLastGlobal.CATSerialDataBits;
FController.FLoadedGlobal.CATSerialStopBits := FCATLastGlobal.CATSerialStopBits;
FController.FLoadedGlobal.CATSerialParity := FCATLastGlobal.CATSerialParity;
FController.FLoadedGlobal.CATSerialAndromeda := FCATLastGlobal.CATSerialAndromeda;
FController.FLoadedGlobal.CATTcpEnabled := FCATLastGlobal.CATTcpEnabled;
FController.FLoadedGlobal.CATTcpPort := FCATLastGlobal.CATTcpPort;
FCATAdapter.ApplySettings(FCATLastGlobal);
+14
View File
@@ -127,6 +127,7 @@ type
private
FOnInvoke: TUIInvokeEvent;
FOnStateChanged: TRadioStateEvent;
FStateListeners: array of TRadioStateEvent; // доп. наблюдатели (CAT/Andromeda), помимо FOnStateChanged (MainForm)
FOnAfterTune: TThreadMethod; // пост-тюн хук: канал-оркестрация после SetVfoA
FOnAudioConsume: TAudioConsumeEvent; // web-перехват RX-аудио (если активен клиент)
FLocalAudio: Boolean; // локальный звук (динамик/мик ПК): GUI=True, демон=False
@@ -577,6 +578,9 @@ type
// ---- События наружу ----
property OnInvoke: TUIInvokeEvent read FOnInvoke write FOnInvoke;
property OnStateChanged: TRadioStateEvent read FOnStateChanged write FOnStateChanged;
// Доп. наблюдатели состояния (помимо единственного OnStateChanged, который
// занимает MainForm). Нужны CAT/Andromeda для push индикаторов панели.
procedure AddStateListener(L: TRadioStateEvent);
// Пост-тюн хук: вызывается в КОНЦЕ SetVfoA (после Changed(rfVfoA), т.е. вне
// рендера) — сюда вешается канальная оркестрация (ChannelController.OnVfoTuned),
// чтобы любой путь тюнинга (desktop/web/CAT/энкодер) был канал-aware headless.
@@ -676,8 +680,18 @@ begin
end;
procedure TRadioController.Changed(Field: TRadioField);
var i: Integer;
begin
if Assigned(FOnStateChanged) then FOnStateChanged(Self, Field);
for i := 0 to High(FStateListeners) do
if Assigned(FStateListeners[i]) then FStateListeners[i](Self, Field);
end;
procedure TRadioController.AddStateListener(L: TRadioStateEvent);
begin
if not Assigned(L) then Exit;
SetLength(FStateListeners, Length(FStateListeners) + 1);
FStateListeners[High(FStateListeners)] := L;
end;
procedure TRadioController.PushNetworkState;
+6
View File
@@ -284,6 +284,7 @@ type
CATSerialDataBits: array[0..3] of Integer; // 7 или 8
CATSerialStopBits: array[0..3] of Integer; // 1 или 2
CATSerialParity: array[0..3] of Integer; // 0=None, 1=Odd, 2=Even
CATSerialAndromeda:array[0..3] of Boolean; // порт — передняя панель Andromeda/G2
// TCP CAT сервер (один, многоклиентский)
CATTcpEnabled: Boolean;
CATTcpPort: Integer; // порт, по умолчанию 19090
@@ -464,6 +465,7 @@ begin
G.CATSerialDataBits[i] := 8;
G.CATSerialStopBits[i] := 1;
G.CATSerialParity[i] := 0; // None
G.CATSerialAndromeda[i]:= False;
end;
G.CATTcpEnabled := False;
G.CATTcpPort := 19090;
@@ -696,6 +698,7 @@ begin
G.CATSerialDataBits[i] := JI(GObj,'cat_serial_dbits_'+IntToStr(i), 8);
G.CATSerialStopBits[i] := JI(GObj,'cat_serial_sbits_'+IntToStr(i), 1);
G.CATSerialParity[i] := JI(GObj,'cat_serial_parity_'+IntToStr(i), 0);
G.CATSerialAndromeda[i]:= JB(GObj,'cat_serial_androm_'+IntToStr(i), False);
end;
G.CATTcpEnabled := JB(GObj,'cat_tcp_enabled', False);
G.CATTcpPort := JI(GObj,'cat_tcp_port', 19090);
@@ -796,6 +799,7 @@ begin
JW(O,'cat_serial_dbits_'+IntToStr(i),G.CATSerialDataBits[i]);
JW(O,'cat_serial_sbits_'+IntToStr(i),G.CATSerialStopBits[i]);
JW(O,'cat_serial_parity_'+IntToStr(i),G.CATSerialParity[i]);
JW(O,'cat_serial_androm_'+IntToStr(i),G.CATSerialAndromeda[i]);
end;
JW(O,'cat_tcp_enabled',G.CATTcpEnabled);
JW(O,'cat_tcp_port',G.CATTcpPort);
@@ -932,6 +936,7 @@ begin
JW(O, 'ser_dbits_' +IntToStr(i), G.CATSerialDataBits[i]);
JW(O, 'ser_sbits_' +IntToStr(i), G.CATSerialStopBits[i]);
JW(O, 'ser_parity_'+IntToStr(i), G.CATSerialParity[i]);
JW(O, 'ser_androm_'+IntToStr(i), G.CATSerialAndromeda[i]);
end;
end;
@@ -954,6 +959,7 @@ begin
G.CATSerialDataBits[i] := JI(O, 'ser_dbits_' +IntToStr(i), 8);
G.CATSerialStopBits[i] := JI(O, 'ser_sbits_' +IntToStr(i), 1);
G.CATSerialParity[i] := JI(O, 'ser_parity_'+IntToStr(i), 0);
G.CATSerialAndromeda[i]:= JB(O, 'ser_androm_'+IntToStr(i), False);
end;
end;
+21 -3
View File
@@ -67,6 +67,7 @@ type
const SerDataBits: array of Integer;
const SerStopBits: array of Integer;
const SerParity: array of Integer;
const SerAndromeda: array of Boolean;
TcpEnabled: Boolean; TcpPort: Integer) of object;
TOnAlexChange = procedure(const A: TAlexSettings) of object;
TOnXvtrChange = procedure(const X: TXvtrSettings) of object;
@@ -263,6 +264,7 @@ type
FCATSerialDataBits: array[0..3] of TFlatComboBox;
FCATSerialStopBits: array[0..3] of TFlatComboBox;
FCATSerialParity: array[0..3] of TFlatComboBox;
FCATSerialAndromeda:array[0..3] of TFlatCheckBox;
FCATTcpEn: TFlatCheckBox;
FCATTcpPort: TFlatSpinEdit;
@@ -415,6 +417,7 @@ type
const SerEnabled: array of Boolean;
const SerPort: array of string;
const SerBaud, SerDataBits, SerStopBits, SerParity: array of Integer;
const SerAndromeda: array of Boolean;
TcpEnabled: Boolean; TcpPort: Integer);
procedure LoadTXSettings(const T: TTXSettings; BoardType: Integer = 0);
procedure LoadAlexSettings(const A: TAlexSettings; BoardType: Integer);
@@ -2621,7 +2624,7 @@ const
LW = 120;
CX = PAD + LW + 8;
GRP_W = 320;
GRP_H = 240;
GRP_H = 272;
GRP_GAP = 16;
CMB_W = 140;
EDT_W = 160;
@@ -2712,6 +2715,16 @@ begin
Cmb.Items.Add('None'); Cmb.Items.Add('Odd'); Cmb.Items.Add('Even');
Cmb.ItemIndex := 0; // None
FCATSerialParity[i] := Cmb;
Chk := TFlatCheckBox.Create(Self);
Chk.Parent := Grp;
Chk.Caption := 'Andromeda / G2 front panel';
Chk.SetBounds(PAD, R1 + 6 * STEP, GRP_W - PAD * 2, 22);
Chk.Font.Color := CLR_TEXT;
Chk.Font.Name := UI_FONT; Chk.Font.Size := 9;
Chk.Tag := i;
Chk.OnChange := OnCATSerialChange;
FCATSerialAndromeda[i] := Chk;
end;
// TCP server group below the two rows of serial port panels
@@ -2747,6 +2760,7 @@ var
SerEn: array[0..3] of Boolean;
SerPort: array[0..3] of string;
SerBaud, SerData, SerStop, SerPar: array[0..3] of Integer;
SerAndr: array[0..3] of Boolean;
i, Idx: Integer;
begin
if FLoading then Exit;
@@ -2762,9 +2776,10 @@ begin
SerBaud[i] := 9600;
if FCATSerialDataBits[i].ItemIndex = 0 then SerData[i] := 7 else SerData[i] := 8;
if FCATSerialStopBits[i].ItemIndex = 1 then SerStop[i] := 2 else SerStop[i] := 1;
SerPar[i] := FCATSerialParity[i].ItemIndex;
SerPar[i] := FCATSerialParity[i].ItemIndex;
SerAndr[i] := FCATSerialAndromeda[i].Checked;
end;
FOnCATChange(SerEn, SerPort, SerBaud, SerData, SerStop, SerPar,
FOnCATChange(SerEn, SerPort, SerBaud, SerData, SerStop, SerPar, SerAndr,
FCATTcpEn.Checked, FCATTcpPort.Value);
end;
@@ -2782,6 +2797,7 @@ procedure TSettingsForm.LoadCATSettings(
const SerEnabled: array of Boolean;
const SerPort: array of string;
const SerBaud, SerDataBits, SerStopBits, SerParity: array of Integer;
const SerAndromeda: array of Boolean;
TcpEnabled: Boolean; TcpPort: Integer);
var
i, j, Idx: Integer;
@@ -2811,6 +2827,8 @@ begin
end;
if i <= High(SerParity) then
FCATSerialParity[i].ItemIndex := EnsureRange(SerParity[i], 0, 2);
if i <= High(SerAndromeda) then
FCATSerialAndromeda[i].Checked := SerAndromeda[i];
end;
FCATTcpEn.Checked := TcpEnabled;
FCATTcpPort.Value := EnsureRange(TcpPort, 1, 65535);
+177
View File
@@ -0,0 +1,177 @@
# Поддержка передней панели Andromeda / G2 в EWSDR
Ветка разработки: `feature/cat-commands-expansion`.
Дата последнего обновления: 2026-06-26.
Документ описывает поддержку аппаратной передней панели Apache Labs
**Andromeda / ANAN-G2** (поворотные энкодеры, кнопки, LED-индикаторы, LCD).
За эталон протокола взят Thetis (`Andromeda/Andromeda.cs`, `CAT/CATCommands.cs`).
См. также [CAT_STATUS.md](CAT_STATUS.md) — общий статус CAT-подсистемы.
---
## 1. Что такое Andromeda и чем отличается от обычного CAT
Andromeda — это **не отдельный набор команд**, а двунаправленный протокол панели
поверх CAT-фрейминга. В отличие от обычного CAT (реактивный «запрос → ответ»),
панель требует, чтобы хост **сам инициативно отправлял** обновления подсветки.
```
ZZZD/ZZZU/ZZZE/ZZZP/ZZZS (панель → хост: ввод)
┌─────────┐ ───────────────────────────► ┌──────────┐
│ Панель │ │ EWSDR │
│ G2 / │ ◄─────────────────────────── │ (хост) │
│Andromeda│ ZZZI / ZZMF / ZZZS; └──────────┘
└─────────┘ (хост → панель: подсветка, LCD, рукопожатие)
```
| Направление | Команды | Назначение |
|---|---|---|
| **Панель → хост** | `ZZZS<v>` | панель сообщает версию HW/SW (рукопожатие) |
| | `ZZZU<nn>` / `ZZZD<nn>` | энкодер VFO повёрнут вверх/вниз на nn шагов |
| | `ZZZE<eees>` | энкодер #ee повёрнут (01–20 = +шаг, 5170 = −шаг) |
| | `ZZZP<bbs>` | кнопка #bb: s=1 нажата, 2 long-press, 0 отпущена |
| **Хост → панель** | `ZZZI<nn><s>` | LED-индикатор #nn (0019) вкл (1)/выкл (0) |
| | `ZZMF<30 цифр>` | текст мультифункц. энкодера на LCD (пока **не реализован**) |
| | `ZZZS;` | запрос версии у панели |
---
## 2. Архитектура реализации
Логика панели вынесена в отдельные юниты; в существующих файлах — только тонкие
хуки. Зависимости однонаправленные, без циклов.
```
┌────────────────────┐
serial-порт ──────► │ TCATEngine │ разбор ZZZ* → generic callbacks
(Andromeda) │ (CATEngine.pas) │ (OnPanelVfoStep/Encoder/Button/Version)
└─────────┬──────────┘
│ callbacks
┌─────────▼──────────┐ создаёт/связывает
│ TCATAdapter │ ◄───────────────────┐
│ (CATAdapter.pas) │ │
└─────────┬──────────┘ │
SendProc (ZZZI/ZZZS;) │ ▲ AddStateListener │
на Andromeda-порт ▼ │ (push индикаторов) │
┌────────────────────┐ ┌────────────┴───────┐
│ TAndromedaPanel │ ───────►│ TRadioController │
│ (AndromedaPanel.pas)│ Invoke │ (ядро) │
│ использует ▼ │ действия└────────────────────┘
│ AndromedaMap.pas │ (таблицы раскладки — только данные)
└────────────────────┘
```
| Файл | Роль |
|---|---|
| **`AndromedaMap.pas`** | Только данные: enum'ы `TAndButton` / `TAndEncoder` / `TAndIndicator`, дефолтные таблицы раскладки кнопок/энкодеров и номера LED (`ZZZI`). Без логики и зависимостей. |
| **`AndromedaPanel.pas`** | `TAndromedaPanel` — «мозг»: входящие события → действия контроллера (свой маршалинг через `FController.Invoke`); подписка на состояние контроллера → построение `ZZZI` → отправка через callback `SendProc`. Зависит только от `RadioController` + `AndromedaMap`. |
| `CATEngine.pas` | Разбор входящих `ZZZD/ZZZU/ZZZE/ZZZP/ZZZS` в 4 generic-callback'а `TCATContext`. Движок остаётся чистым парсером, про Andromeda «не знает». |
| `CATAdapter.pas` | Тонкий клей: создаёт панель, маршрутизирует callback'и движка в неё, даёт `SendProc` на Andromeda-порт, регистрирует панель как слушателя состояния, инициирует рукопожатие. |
| `CATSerial.pas` | Флаг `Andromeda` в конфиге порта + `HasAndromeda` / `SendToAndromeda`. |
| `RadioController.pas` | `AddStateListener` — multicast-наблюдатель состояния (push не «ворует» единственный `OnStateChanged`, занятый MainForm). |
| `Settings.pas` / `SettingsForm.pas` / `MainForm.pas` | Поле `CATSerialAndromeda[0..3]`, персист, чекбокс на каждом serial-порту. |
### Потоки
- Входящие хэндлеры (`HandleVfoStep/Encoder/Button/Version`) зовутся из потока
CAT-транспорта и маршалят действия в поток контроллера через `FController.Invoke`
(GUI = `TThread.Synchronize`).
- Слушатель состояния и `SendProc` работают из потока, вызвавшего `Changed()`;
отправка в порт потокобезопасна (serial `SendStr` под своим локом).
---
## 3. Раскладка по умолчанию (AndromedaMap.pas)
Раскладка **зашита в коде** (первая версия). Правится в `AndromedaMap.pas`.
### Кнопки (`ZZZP`, индекс 0-based после разбора)
| # | Действие | # | Действие |
|---|---|---|---|
| 0 | MOX | 9 | SNB |
| 1 | Tune (TUN) | 10 | Split (TX VFO-B) |
| 2 | Band Up | 11 | VFO Swap (актив. A/B) |
| 3 | Band Down | 12 | CTUN |
| 4 | Mode Up | 13 | Mute |
| 5 | Mode Down | 14 | CTCSS |
| 6 | NB | 15 | Squelch on/off |
| 7 | NR | 16 | Filter Up |
| 8 | ANF | 17 | Filter Down |
### Энкодеры (`ZZZE`, индекс 0-based)
| # | Параметр | Шаг |
|---|---|---|
| 0 | AF Gain (громкость) | ±1 |
| 1 | AGC-T | ±1 dB |
| 2 | Drive (мощность) | ±1 % |
| 3 | Squelch level | ±1 |
| 4 | Filter width | ±50 Гц (`AND_BW_STEP_HZ`) |
| VFO | энкодер VFO (`ZZZU/ZZZD`) | ±10 Гц/шаг (`AND_VFO_STEP_HZ`) |
### Индикаторы (`ZZZI`, номера зеркалят дефолт Thetis)
| LED # | Индикатор | LED # | Индикатор |
|---|---|---|---|
| 1 | MOX | 12 | Split |
| 2 | ATU Ready *(нет в ewsdr)* | 13 | NB |
| 3 | Tune | 14 | NR |
| 6 | Shift *(нет)* | 15 | SNB |
| 7 | CTUN | 16 | ANF |
| 8/9 | RIT/XIT *(нет)* | 17 | Squelch |
| 10 | VFO A/B | | |
| 11 | VFO Lock *(нет)* | | |
Драйвятся реально: **MOX, Tune, CTUN, VFO A/B, Split, NB, NR, SNB, ANF, Squelch**.
Остальные слоты дефолтной таблицы Thetis (ATU/Shift/RIT/XIT/VFOLock) в ewsdr
функций не имеют и не зажигаются.
---
## 4. Как включить
1. Подключить панель как обычный serial CAT-порт (USB).
2. Settings → CAT → выбрать нужный Serial Port, поставить **Enable** и параметры
порта (скорость и т.д. — как требует панель).
3. Поставить галку **«Andromeda / G2 front panel»** на этом порту.
4. При применении настроек EWSDR пошлёт `ZZZS;`; панель ответит версией — после
этого включается push индикаторов и панель управляет радио.
Только **один** порт может быть Andromeda-портом (первый помеченный).
Настройки сохраняются ключами `cat_serial_androm_N` / `ser_androm_N`
(N = 0..3) в JSON-конфиге.
---
## 5. Что сделано / что осталось
### Сделано (первая версия)
- Транспорт: **serial** (USB CAT).
- Вход: `ZZZD/ZZZU` (VFO), `ZZZE` (энкодеры), `ZZZP` (кнопки), `ZZZS` (рукопожатие).
- Выход: **`ZZZI`** — LED-индикаторы (push от `OnStateChanged`, дедуп повторов).
- Раскладка — зашитый дефолт.
- UI-чекбокс на каждый serial-порт + персист.
### Осталось / возможные улучшения
| Задача | Заметки |
|---|---|
| **`ZZMF`** — текст на LCD мультифункц. энкодера | кодировка: 15 ASCII-символов парами цифр (код−32) |
| **TCP-транспорт (G2 V2 panel)** | сейчас только serial; нужен режим в `CATTcp` |
| **Настраиваемая раскладка** | сейчас зашита; вынести в конфиг/UI (как `AndromedaEditForm` в Thetis) |
| **Long-press кнопок** | принимается (`ZZZP` state=2), но действий пока нет — обрабатывается только нажатие (state=1) |
| **Индикаторы RIT/XIT/PureSignal/Diversity/ATU** | нет соответствующих функций в `TRadioController` |
| **Мультифункц. энкодер (`aeMulti`)** | назначение не задано |
---
## 6. Точки расширения (чек-лист)
- **Добавить действие кнопки/энкодера:** новый элемент в `TAndButton`/`TAndEncoder`
(AndromedaMap.pas) + ветка в `TAndromedaPanel.SyncButton`/`SyncEncoder`
(AndromedaPanel.pas) + правка таблицы `AND_BUTTON_MAP`/`AND_ENCODER_MAP`.
- **Добавить индикатор:** элемент `TAndIndicator` + номер в `AND_INDICATOR_NUM` +
push в `SyncAllIndicators` и в `OnControllerState` (по нужному `rf*`-полю).
- **Изменить раскладку:** только `AndromedaMap.pas` (таблицы-константы).
- Сверяться с форматами Thetis: `Andromeda/Andromeda.cs` (логика панели,
`EIndicatorActions`/`EButtonBarActions`/`EEncoderActions`) и
`CAT/CATCommands.cs` (`ZZZ*`).