# 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`: ```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) Не реализовано ничего из потоков; команды управления ими принимаются, но данные не идут. Что нужно сделать: 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`. Это осознанно — дубли идемпотентны, а рассылка нужна для тех случаев, когда значение поменял не клиент, а оператор.