Разбор ревью ветки. Критичное — четыре отказа жизненного цикла и один пробел синхронизации. 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>
22 KiB
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 Три правила, на которых держится транспорт
Всё это не украшения, а лечение конкретных отказов — менять с оглядкой.
- Отправка никогда не блокирует вызывающего.
Send/Broadcastкладут строку в очередь клиента (микросекунды под его локом), в сокет пишет тик-поток (FlushClients, 20 мс, вне общего лока). Уведомления рождаются внутриChanged()контроллера, то есть в UI-потоке: писать оттуда прямо в сокет означало бы отдать интерфейс во власть самого медленного клиента (таймаут отправки × число клиентов на каждое движение ручки VFO). Переполнилась очередь (TCI_OUT_MAX) или не прошла запись — клиент выбрасывается, а не тормозит остальных. - Объект клиента освобождает только тик-поток (
ReapClients) и только после того, как клиентский поток честно вышел. Поэтому указатель, взятый кем угодно подFClientLock, гарантированно жив внутри лока. 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_CLEAR (в TDXSpotStore, спот виден на всех
панадаптерах), 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)
Не реализовано ничего из потоков; команды управления ими принимаются, но данные не идут. Что нужно сделать:
- Заголовок блока уже описан —
TTCIStreamHeaderвTCIProtocol.pas(16 × uint32 + сэмплы). RX_AUDIO_STREAM— нужен multicast-тап RX-аудио в контроллере. Сейчас есть толькоOnAudioConsume— одиночный перехват, которым владеет веб-адаптер (он же глушит локальный звук). Для TCI нужен именно тап «послушать, не забирая», по образцуAddStateListener.TX_AUDIO_STREAM+TX_CHRONO— приём бинарных фреймов от клиента (сейчасTCIServerих отбрасывает; приёмный буферTWsClient— 4 КБ, под 16 КБ блоков его придётся растить) и подача в TX-тракт наравне с веб-микрофоном (PushMicSamples).IQ_STREAM— тап сырого IQ вTWDSPEngine.PushIQItemToDSP(как у декодера маяка), с децимацией доIQ_SAMPLERATE(48/96/192/384 кГц).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. Это осознанно — дубли идемпотентны, а рассылка нужна для тех
случаев, когда значение поменял не клиент, а оператор.