Files
ewsdr/doc/TCI.md
T
ew8bakandClaude Opus 5 84c9e60b93 fix(tci): жизненный цикл, синхронизация слайсов и WebSocket по RFC
Разбор ревью ветки. Критичное — четыре отказа жизненного цикла и один
пробел синхронизации.

Use-after-free стора спотов: FTCIAdapter освобождается ДО FDXStore.
Команда SPOT/SPOT_DELETE, пришедшая между их гибелью, обращалась к
освобождённой памяти.

Bind-адрес: TCIParseIPv4 стал строгим (out + Boolean, ровно четыре
октета 0..255). Кривой адрес — отказ поднимать сокет, а не молчаливый
INADDR_ANY: авторизации в TCI нет. В UI порт и адрес применяются по
уходу фокуса и по Close, а не на каждую букву — набор «127.0.0.1» по
дороге проходил через «127.0.0.» и открывал порт наружу.

Остановка при висящем Synchronize: флаг Stopping (адаптер не начинает
новых Invoke), прокачка CheckSynchronize в цикле ожидания Stop и запрет
освобождать клиента, чей поток не вышел. Владение переделано: клиента
освобождает только тик-поток (ReapClients), клиентский лишь помечает
себя закрытым.

Отправка больше не блокирует вызывающего: Send/Broadcast кладут строку
в очередь клиента, в сокет пишет тик-поток вне общего лока, склеивая
очередь в общие кадры. Медленный клиент морозил UI на таймаут отправки
за каждое движение ручки VFO; теперь он просто вылетает.

Слайсы: в контроллере появилось rfSliceState (нагрузка — FSliceFreqId),
его шлют сами сеттеры слайса; SyncSetVfo зовёт SliceFreqChanged, как
CAT. Адаптер разворачивает Id в пару (приёмник, канал) и рассылает
состояние именно этого канала, а не канала 0 каждого пана.

WebSocket по RFC 6455: маска обязательна, FIN/continuation собираются,
RSV и незнакомые opcode рвут соединение, control-кадры ≤125 и только
целиком, 64-битная длина не сворачивается в отрицательный Integer,
пустой Sec-WebSocket-Key получает 400. Хвост пакета handshake больше не
выбрасывается — первая команда не теряется. Клиент после исключения в
разборе не остаётся висеть в массиве.

Ещё: DSP и squelch доп. приёмников читаются и пишутся из TCtrlSlice
(парные сеттеры сохраняли соседние поля значениями главного тракта);
параметры потоков — в TTCIClient, они клиентские по спецификации; эхо
под своим локом; ApplySettings возвращает результат, отказ старта виден
оператору; инициализация объявляет только существующие каналы;
SET_IN_FOCUS реализован через OnFocusRequest.

Осознанно не сделано и записано в doc/TCI.md §3.1: AGC_GAIN для
приёмников >0 (AGC-T один на тракт), цвет спота, KEYER, TX_FOOTSWITCH,
арбитраж нескольких клиентов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:49:31 +03:00

22 KiB
Raw Blame History

TCI в EWSDR — статус реализации

Ветка разработки: feature/tci-protocol. Дата последнего обновления: 2026-08-17.

Эталон протокола — «Протокол TCI, версия 2.0» Expert Electronics (doc/TCI Protocol_RU.pdf, 12 января 2024). EWSDR выступает сервером (как ExpertSDR3): порт слушаем мы, клиенты — логгеры, скиммеры, программы цифровых видов, внешние усилители и коммутаторы.


1. Архитектура

TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ──► TRadioController
логгер/скиммер/цифра        транспорт      команды +          ядро
                                            уведомления
Файл Назначение
TCIProtocol.pas (~380 строк) Чистый слой протокола: разбор имя:арг1,арг2;, сборка строк, экранирование ^ ~ *, словарь видов связи, пересчёт громкости/порога в дБ. Зависит только от RTL + RadioModes.
TCIServer.pas (~660 строк) WebSocket-сервер: accept-поток, поток на клиента, HTTP-Upgrade, разбор фреймов, рассылка, тик 20 мс. Сокеты и фреймы переиспользованы из веб-подсистемы (WebUtils, WsClient).
TCIAdapter.pas (~1100 строк) Мост к TRadioController: реализация команд, пачка инициализации, уведомления об изменениях состояния, измерители.

Принципы те же, что у CAT (см. doc/CAT_STATUS.md):

  • TCI — равноправный клиент контроллера. Никаких обращений к MainForm; единственное исключение — стор спотов TDXSpotStore, который передаётся адаптеру ссылкой (команды SPOT/SPOT_DELETE/SPOT_CLEAR кладут споты в ту же базу, что и DX-кластер).
  • Потоки. Геттеры читают поля контроллера напрямую из потока клиента; сеттеры пишут параметр в scratch-поля под FLock и зовут FController.Invoke(SyncXxx) — исполнение в потоке контроллера (GUI = TThread.Synchronize).
  • Синхронизация клиентов. Изменение любого поля контроллера приходит в OnState (multicast-подписка AddStateListener) и рассылается всем подключённым — как того требует §3.5 спецификации. Отвечающий на команду клиент дополнительно получает прямой ответ.

1.1 Три правила, на которых держится транспорт

Всё это не украшения, а лечение конкретных отказов — менять с оглядкой.

  1. Отправка никогда не блокирует вызывающего. Send/Broadcast кладут строку в очередь клиента (микросекунды под его локом), в сокет пишет тик-поток (FlushClients, 20 мс, вне общего лока). Уведомления рождаются внутри Changed() контроллера, то есть в UI-потоке: писать оттуда прямо в сокет означало бы отдать интерфейс во власть самого медленного клиента (таймаут отправки × число клиентов на каждое движение ручки VFO). Переполнилась очередь (TCI_OUT_MAX) или не прошла запись — клиент выбрасывается, а не тормозит остальных.
  2. Объект клиента освобождает только тик-поток (ReapClients) и только после того, как клиентский поток честно вышел. Поэтому указатель, взятый кем угодно под FClientLock, гарантированно жив внутри лока.
  3. Stop прокачивает очередь Synchronize. Останавливает сервер поток контроллера (UI), а клиентский поток в этот момент может висеть как раз на Invoke в него же. Без прокачки это взаимный клин; по его таймауту сервер освобождал бы объекты из-под живых потоков. Дополнительно на время остановки взводится Stopping, и адаптер новых Invoke уже не начинает. Если поток всё же не вышел — объект НЕ освобождается: утечка на выходе дешевле обращения к освобождённой памяти.

1.2 Настройки

Секция tci в корне settings.json:

"tci": { "enabled": false, "port": 40001, "bind_addr": "127.0.0.1" }

UI — вкладка Advanced → TCI Server (галка, порт, интерфейс). В протоколе нет авторизации: открытый наружу порт означает полный доступ к трансиверу, поэтому умолчание слушает только петлю.

Отсюда же два правила вокруг адреса:

  • разбор bind_addr строгий (ровно четыре октета 0..255); всё непонятное — отказ поднимать сервер, а не молчаливый 0.0.0.0. Пустая строка и явный 0.0.0.0 — единственные способы попросить «все интерфейсы»;
  • порт и адрес применяются по уходу фокуса из поля и по кнопке Close, а не на каждое нажатие клавиши: иначе набор 127.0.0.1 по дороге проходил бы через «127.0.0.» и сервер успевал перезапуститься на всех интерфейсах.

Отказ старта (порт занят, адрес не разобран) виден оператору: ApplySettings возвращает результат, MainForm показывает сообщение.

1.3 Маппинг модели

TCI EWSDR
приёмник (TRX_COUNT) панадаптер: 0 = главный тракт, 1.. = доп. DDC-паны. Число = BackendCaps.MaxPans
канал A/B (CHANNEL_COUNT = 2) у приёмника 0 — VFO A / VFO B; у панов 1.. — первый и второй слайс пана
DDS центр панадаптера (SetCenter / SetPanDDCFreq)
IF смещение канала от центра панорамы
VFO абсолютная частота канала
передатчик (arg1 у TRX/TUNE/DRIVE) всегда один — главный тракт

2. Что реализовано

2.1 Инициализация (§4.1)

PROTOCOL, DEVICE, RECEIVE_ONLY, TRX_COUNT, CHANNEL_COUNT, VFO_LIMITS, IF_LIMITS, MODULATIONS_LIST, READY.

READY шлётся после полного дампа состояния: клиент, дождавшийся его, уже знает всё. Границы частот берутся из BackendCaps; пока устройство не подключено — 10 кГц…30 МГц. IF_LIMITS = ±sample rate/2, пересылается при смене частоты дискретизации.

Список видов связи: am,sam,dsb,lsb,usb,cw,nfm,wfm,digl,digu,dmr,fmraw. dmr/fmraw — наше расширение (протокол расширяемый, §1.4). CWL/CWU схлопываются в cw; при установке cw боковая сохраняется, если уже телеграф, иначе выбирается по частоте (ниже 10 МГц — CWL).

2.2 Двунаправленное управление (§4.2)

Полностью проведено в контроллер:

Команда Куда легло
START / STOP SetRun
DDS SetCenter / SetPanDDCFreq
IF, VFO SetVfoA/B, SetSliceTarget
MODULATION SetMode / SetSliceMode
TRX SetMOX
TUNE SetTune
DRIVE SetDrive
TUNE_DRIVE FTXSettings.TUNLevel через SetTXSettings
SPLIT_ENABLE SetSplit
RX_FILTER_BAND SetFilterEdges / SetSliceFilter
VOLUME, MUTE SetVolume, SetMute (дБ ↔ 0..100)
RX_MUTE, RX_VOLUME громкость/мьют слайса
MON_VOLUME, MON_ENABLE SetTXMonVolume, SetRxMuteOnTx
AGC_MODE off→Off, fast→Fast, normal→Medium
AGC_GAIN SetAGCTop (AGC-T) — только приёмник 0, см. §3
RX_NR_ENABLE, RX_NB_ENABLE, RX_ANF_ENABLE SetNR/SetNB/SetANF, для панов — SetSliceDSP
LOCK SetVfoLock
SQL_ENABLE, SQL_LEVEL FM-шумоподавитель; дБ (-140..0) ↔ порог 0..100
CW_MACROS_SPEED, CW_KEYER_SPEED TCWSettings.Speed
CW_MACROS_DELAY TCWSettings.RFDelayMS

2.3 Однонаправленное управление (§4.3)

TX_ENABLE (по BackendCaps.HasTX + TXProhibited), CW_MACROS_SPEED_UP/DOWN, SET_IN_FOCUS (поднимает окно программы через OnFocusRequest — адаптер до окна не дотягивается, действие ставит MainForm), SPOT, SPOT_DELETE, SPOT_CLEARTDXSpotStore, спот виден на всех панадаптерах), RX_SENSORS_ENABLE, TX_SENSORS_ENABLE (период — на клиента), IQ_SAMPLERATE, AUDIO_SAMPLERATE, AUDIO_STREAM_*, TX_STREAM_AUDIO_BUFFERING (значения принимаются и подтверждаются; сами потоки — этап 2). Параметры потоков — настройки клиента, а не устройства: живут в TTCIClient, и один клиент не переопределяет их остальным.

2.4 Уведомления (§4.4, §4.5)

RX_CHANNEL_SENSORS + устаревшая RX_SENSORS (S-метр в дБм, период 30…1000 мс на клиента), TX_SENSORS, TX_FREQUENCY, VFO_LOCK, APP_FOCUS (активация/деактивация главного окна), RX_CLICKED_ON_SPOT + устаревшая CLICKED_ON_SPOT (клик по подписи спота на любом панадаптере), CALLSIGN_SEND (после CW_MSG).

Отдельная история — доп. приёмники. У главного тракта на каждое поле есть своё rfXxx, а у слайсов не было ничего: правка слайса не доходила ни до UI, ни до остальных клиентов. Поэтому в контроллере появилось rfSliceState (полезная нагрузка — FSliceFreqId, как у rfSliceFreq), и его шлют сами сеттеры слайса: SetSliceMode, SetSliceFilter, SetSliceAGCMode, SetSliceVolume, SetSliceMute, SetSliceDSP, SetSliceFMSquelch. Адаптер разворачивает Id обратно в пару (приёмник, канал) и рассылает состояние именно этого канала. Частоту слайса, поставленную по TCI, тоже сопровождает SliceFreqChanged — как это делает CAT.

2.5 Телеграф (§3.2)

CW_MACROS, CW_MSG, CW_MACROS_STOP, CW_TERMINAL.

Текст приводится к тому, что понимает передатчик текста ewsdr (CWXSend): экранирование ^ ~ * снимается, CALL$N разворачивается в N повторов позывного, префикс/суффикс _ считаются пустыми. Не поддержано: шаг скорости внутри текста (< / >) и слитная передача аббревиатур (|SK|) — эти символы просто снимаются, потому что TCWSender работает на одной скорости и не знает прос-знаков. Доотправка позывного (cw_msg:arg1;) игнорируется: уже отданный в очередь текст не редактируется.


3. Осознанные заглушки («эхо»)

Значения принимаются, хранятся в адаптере и рассылаются клиентам — так синхронизация между несколькими клиентами остаётся честной, но на радио они не влияют, потому что соответствующего тракта в EWSDR нет:

Команда Причина
RIT_ENABLE, RIT_OFFSET, XIT_ENABLE, XIT_OFFSET расстройки RX/TX в контроллере нет вообще
RX_BIN_ENABLE псевдостерео не реализовано
RX_ANC_ENABLE, RX_APF_ENABLE, RX_DSE_ENABLE, RX_NF_ENABLE таких блоков в тракте нет
RX_NB_PARAM параметры NB в WDSP наружу не выведены
RX_BALANCE баланса каналов у слайса нет
DIGL_OFFSET, DIGU_OFFSET смещения цифровых мод не реализованы
RX_CHANNEL_ENABLE канал B главного приёмника — это VFO B, он есть всегда; создание второго слайса на пане по TCI — этап 2

3.1 Ограничения, о которых честнее знать заранее

Что Как ведёт себя
AGC_GAIN у приёмника > 0 AGC-T в ewsdr один на приёмный тракт, у слайса своего нет. Команда от имени доп. приёмника игнорируется (раньше молча правила главный), в ответ уходит текущее значение
Цвет спота (SPOT, arg4 ARGB) не читается: TDXSpot цвета не хранит, подписи красятся по моде/возрасту
KEYER, TX_FOOTSWITCH не реализованы: своего ключа-уведомления и опроса педали наружу у контроллера нет
Арбитраж клиентов (§3.5, захват параметра ~200 мс) нет. Команды разных клиентов идут подряд, последняя побеждает. С двумя активными логгерами возможна «перетяжка» частоты или моды
Мода, фильтр, АРУ, шумодавы у канала B доп. пана в TCI это свойства приёмника, а не канала: они относятся к каналу A. У канала B по протоколу есть только частота, IF и громкость
Команды конфигурации потоков подтверждаются как принятые, хотя самих потоков нет (этап 2). Клиент по ответу может решить, что функция доступна

Отдельно: у TX_SENSORS второй аргумент — уровень микрофона; измерителя микрофона в EWSDR нет, шлём нижнюю границу шкалы (-60 дБм), чтобы клиент не рисовал случайные значения. Третий аргумент (RMS) и четвёртый (пик) отдаём одинаковыми — в телеметрии платы одно значение forward power.

У TRX третий аргумент (источник сигнала tci/mic1/…) игнорируется: аудио по TCI ещё нет, модуляция берётся из выбранного в программе входа.


4. Этап 2 — бинарные потоки (§3.4)

Не реализовано ничего из потоков; команды управления ими принимаются, но данные не идут. Что нужно сделать:

  1. Заголовок блока уже описан — TTCIStreamHeader в TCIProtocol.pas (16 × uint32 + сэмплы).
  2. RX_AUDIO_STREAM — нужен multicast-тап RX-аудио в контроллере. Сейчас есть только OnAudioConsume — одиночный перехват, которым владеет веб-адаптер (он же глушит локальный звук). Для TCI нужен именно тап «послушать, не забирая», по образцу AddStateListener.
  3. TX_AUDIO_STREAM + TX_CHRONO — приём бинарных фреймов от клиента (сейчас TCIServer их отбрасывает; приёмный буфер TWsClient — 4 КБ, под 16 КБ блоков его придётся растить) и подача в TX-тракт наравне с веб-микрофоном (PushMicSamples).
  4. IQ_STREAM — тап сырого IQ в TWDSPEngine.PushIQItemToDSP (как у декодера маяка), с децимацией до IQ_SAMPLERATE (48/96/192/384 кГц).
  5. LINEOUT_STREAM + LINE_OUT_RECORDER_* — запись в WAV/MP3.

Также в очереди: RX_CHANNEL_ENABLE как реальное создание/удаление второго слайса пана, KEYER и арбитраж нескольких клиентов (§3.5).

Приёмный буфер TWsClient — 4 КБ, и сейчас это жёсткий потолок: кадр крупнее рвёт соединение (команд такой длины у TCI нет). Под TX-аудио его придётся растить вместе с этапом 2.


5. Проверено

Стендом (WS-клиент на сыром сокете, без внешних библиотек):

  • Транспорт: строгий разбор bind-адреса и отказ подниматься на кривом; первый кадр, приклеенный к пакету handshake; сборка фрагментированного сообщения; несколько команд в одном кадре; отказ от незамаскированных кадров с разрывом соединения; ping/pong; рассылка двум клиентам; чистая остановка с живыми клиентами; медленный клиент (5000 рассылок не блокируют вызывающего, клиент вылетает сам); остановка сервера в тот момент, когда команда клиента висит в Synchronize у потока контроллера.
  • Сквозной прогон с настоящим TRadioController (движки созданы, железо не подключено): пачка инициализации из 57 строк со всеми обязательными командами и READY в конце; VFO, MODULATION (cw на 7 МГц дал CWL), RX_FILTER_BAND, DRIVE, VOLUME, MUTE, AGC_GAIN, LOCK, CW_MACROS_SPEED, SPOT, RIT_* — состояние контроллера после прогона совпало с посланным; чтение (vfo:0,1;) отвечает; RX_SENSORS_ENABLE:true,100 даёт поток rx_channel_sensors с заданным периодом.
  • Устойчивость: исключение в обработке команды не рвёт соединение — клиенту уходит tci_error:<команда>,<сообщение>;, остальные команды продолжают работать (проверено на контроллере без движков, где SetMode/SetCWSettings падают с AV на неинициализированном FNetwork).

Сборка: lazbuild -B --ws=qt6 ewsdr.lpr и ./build-ewsdrd.sh (демон собирается, TCI в его граф пока не заведён — юниты LCL-free, подключается одной строкой в ewsdrd.lpr, как web).

На реальном железе и с реальным клиентом (Log4OM/N1MM/WSJT-X/CW Skimmer) не проверялось.

Ответ на команду-установку клиент получает дважды: прямым ответом и рассылкой из OnState. Это осознанно — дубли идемпотентны, а рассылка нужна для тех случаев, когда значение поменял не клиент, а оператор.