mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 19:45:09 +00:00
Разбор ревью ветки. Критичное — четыре отказа жизненного цикла и один пробел синхронизации. 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>
288 lines
22 KiB
Markdown
288 lines
22 KiB
Markdown
# 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`. Это осознанно — дубли идемпотентны, а рассылка нужна для тех
|
||
случаев, когда значение поменял не клиент, а оператор.
|