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

288 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`. Это осознанно — дубли идемпотентны, а рассылка нужна для тех
случаев, когда значение поменял не клиент, а оператор.