Files
ewsdr/doc/TCI.md
T
ew8bakandClaude Opus 5 4165cbe9a5 fix(tci): ревизия — потоки, валидация, арбитраж и синхронизация клиентов
Разбор семи проходов ревью ветки. Ниже — по сути, а не по списку.

Потоки. Сетевые потоки больше не читают модель контроллера напрямую. Слайсы
снимаются в потоке контроллера (RefreshSlices → FSliceSnap, на событиях
rfSliceFreq/rfSliceState/rfDevice/…), железо — тоже (RefreshDev → TTCIDevSnap:
имя платы, границы, число панов, HasTX). Копия TCtrlSlice из чужого потока
портила счётчик ссылок managed-строк, а BackendCaps и BoardDisplayName смотрят
в FNetwork, который UI освобождает на смене устройства. По той же причине
ActiveTXFreqHz переведён на GetSliceView. Sync-методы читают живую таблицу: они
уже в потоке контроллера.

Жизненный цикл. Stop ждёт выхода клиентских потоков БЕЗ таймаута, прокачивая
очередь Synchronize: выйти по таймауту нельзя — следом освобождаются и клиенты,
и сам сервер. OnDisconnect зовётся и при остановке (иначе захваты параметров
ушедших клиентов доживали до следующего запуска). Отправка переехала на поток
самого клиента (recv с TCI_POLL_MS): общий поток задерживал всех на таймаут
записи в один медленный сокет. WebUtils.SockSend шлёт с MSG_NOSIGNAL — SIGPIPE
убивал headless-процесс.

Транспорт. Слот протокола выдаётся только после Upgrade, а сокет до него живёт
по таймауту handshake: восемь молчащих соединений закрывали дверь настоящим
клиентам. Handshake с заголовком Origin получает 403 — авторизации в TCI нет, и
без этого открытая вкладка браузера дотягивалась до TRX и VFO. Заголовки
разбираются построчно, текстовые кадры проверяются на UTF-8, close длиной один
байт отвергается, на close отвечаем close.

Валидация. Все установки ходят через TCITryArg* — «vfo^0~0~abc» больше не
превращается в честный ноль. Частота проверяется дважды: в потоке клиента по
снимку и в SyncSetVfo/SyncSetCenter по живым границам (устройство успевают
сменить между разбором и исполнением). Границы теперь из ОДНОГО источника
(FreqLimits поверх VisibleFreqBounds) — тот же, что уходит в VFO_LIMITS; сами
VFO_LIMITS переобъявляются при смене железа, и их кэш ведётся независимо от
того, подключён ли кто-то. Слайс двигается только TuneSliceInBand, как у CAT:
прямой SetSliceTarget уводил TX-слайс в DUC на чужой диапазон без антенн и
фильтров. Параметры потоков сверяются со списками спецификации, а IQ_START и
прочие запуски честно отвечают ошибкой вместо молчания.

Синхронизация клиентов (§3.5). Появился захват параметра на 200 мс: два логгера
больше не перетягивают частоту. Пачка инициализации уходит под FClientLock —
изменение между строкой снимка и READY терялось навсегда. Глобальные величины
(tune_drive, cw_macros_*, split_enable, mon_volume) рассылаются всем, а правки
оператора приходят событиями: rfTXProfile, rfActiveVfo, rfMonVolume и новый
rfCWSettings. Создание и удаление слайса рассылается по rfDevice (сравнение
расстановки), у живого пана без слайсов канал A показывает центр — иначе клиент
навсегда оставался с частотой удалённого слайса.

Прочее. SliceFreqChanged переехал внутрь SetSliceTarget — один путь для мыши,
CAT и TCI (перетаскивание флага мимо клиентов проходило молча). VOLUME и
MON_VOLUME развели: SetVolume правит АКТИВНУЮ громкость, поэтому команда на
DUP-передаче уезжала в монитор — добавлен адресный SetRxVolume. Настройки
сохраняются только после успешного применения, при отказе поднимается прежний
слушатель. Время спота — UTC. Подписки на измерители читаются и пишутся под
локом клиента.

Проверено стендом (сырой WS-клиент + живой TRadioController без железа):
73 проверки, включая изоляцию медленного клиента, остановку под Synchronize,
арбитраж до и после 200 мс, отбраковку по живым границам и переобъявление
VFO_LIMITS. На реальном железе и с реальным клиентом по-прежнему не гонялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:53:32 +03:00

503 lines
44 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` (~1250 строк) | WebSocket-сервер: accept-поток, поток на клиента, HTTP-Upgrade, разбор фреймов, рассылка, тик 20 мс. Сокеты и фреймы переиспользованы из веб-подсистемы (`WebUtils`, `WsClient`). |
| `TCIAdapter.pas` (~2100 строк) | Мост к `TRadioController`: реализация команд, пачка инициализации, уведомления об изменениях состояния, измерители, захват параметров (§3.5). |
Принципы те же, что у CAT (см. `doc/CAT_STATUS.md`):
- **TCI — равноправный клиент контроллера.** Никаких обращений к `MainForm`;
единственное исключение — стор спотов `TDXSpotStore`, который передаётся
адаптеру ссылкой (команды `SPOT`/`SPOT_DELETE`/`SPOT_CLEAR` кладут споты в
ту же базу, что и DX-кластер).
- **Потоки.** Геттеры читают поля контроллера напрямую из потока клиента;
сеттеры пишут параметр в scratch-поля под `FLock` и зовут
`FController.Invoke(SyncXxx)` — исполнение в потоке контроллера
(GUI = `TThread.Synchronize`).
- **Железо сетевые потоки не читают вовсе.** `BackendCaps` и
`BoardDisplayName` смотрят в `FNetwork`, а его UI освобождает при смене типа
устройства — обращение туда из потока клиента (да ещё и с копированием
строки имени платы) означало бы чтение освобождённой памяти прямо во время
подключения TCI-клиента. Поэтому адаптер держит снимок `TTCIDevSnap` (имя
платы, границы настройки, число панов, наличие TX, sample rate), который
обновляет **поток контроллера** (`RefreshDev`: конструктор, `ApplySettings`,
`OnState` на `rfDevice`/`rfConnected`/`rfDeviceList`/`rfXvtr`/`rfBand`/
`rfSampleRate`). Строка имени принадлежит адаптеру и присваивается только под
`FDevLock`. По той же причине `TRadioController.ActiveTXFreqHz` перешёл на
`GetSliceView`: он тоже вызывается снаружи и раньше копировал `TCtrlSlice`.
- **Слайсы сетевые потоки не читают вовсе.** Снимок таблицы
(`RefreshSlices``FSliceSnap`) делает **поток контроллера**: в конструкторе
адаптера, в `ApplySettings` и в `OnState` на каждое событие, которое слайсов
касается (`rfSliceFreq`, `rfSliceState`, `rfDevice`, `rfPanFreq`,
`rfSampleRate`, `rfBand`, `rfXvtr`, `rfCenterFreq`) — там писателей нет, и
каждая запись снимается целиком. Команды и измерители читают уже снимок под
`FSliceLock`. Прямое чтение `FSlices` из чужого потока давало не только порчу
managed-строк (`DevName`/`InDevName`), но и смесь полей одного слайса:
частота новая, мода ещё старая. Единственная оставшаяся цена — снимок может
отставать на такт. `Sync`-методы (они уже в потоке контроллера) читают живую
таблицу: команда на установку обязана попасть в тот слайс, который есть
сейчас.
- **Синхронизация клиентов.** Изменение любого поля контроллера приходит в
`OnState` (multicast-подписка `AddStateListener`) и рассылается всем
подключённым — как того требует §3.5 спецификации. Отвечающий на команду
клиент дополнительно получает прямой ответ.
### 1.1 Правила, на которых держится транспорт
Всё это не украшения, а лечение конкретных отказов — менять с оглядкой.
1. **Отправка никогда не блокирует ни вызывающего, ни соседей.**
`Send`/`Broadcast` кладут строку в очередь клиента (микросекунды под его
локом), а в сокет её пишет **собственный поток клиента**: его `recv`
просыпается каждые `TCI_POLL_MS` (20 мс) и сливает очередь. Уведомления
рождаются внутри `Changed()` контроллера, то есть в UI-потоке — писать
оттуда прямо в сокет означало бы отдать интерфейс во власть самого
медленного клиента. Общий поток отправки был лишь половиной решения: один
`SockSend` ждёт до `TCI_SEND_TIMEOUT` (300 мс), и восемь клиентов давали
секунды задержки всем остальным. Теперь медленный клиент задерживает только
себя; переполнилась его очередь (`TCI_OUT_MAX`) или не прошла запись — он
выбрасывается.
2. **Объект клиента освобождает только тик-поток** (`ReapClients`) и только
после того, как клиентский поток честно вышел. Поэтому указатель, взятый
кем угодно под `FClientLock`, гарантированно жив внутри лока. Единственное
исключение — `Stop`, но он к этому моменту уже дождался всех потоков. И в
том и в другом случае наверх уходит `OnDisconnect` (единая точка
`Disconnected`): иначе после остановки у адаптера оставались висеть захваты
параметров ушедших клиентов.
3. **`Stop` ждёт выхода клиентских потоков без таймаута** и прокачивает при
этом очередь `Synchronize`. Останавливает сервер поток контроллера (UI), а
клиентский поток в этот момент может висеть как раз на `Invoke` в него же:
без прокачки это взаимный клин. Выйти по таймауту нельзя — следом
освобождаются и клиенты, и сам сервер с адаптером, а не вышедший поток
вернулся бы в эту память. Поэтому: `Stopping` (адаптер новых `Invoke` не
начинает) + закрытые сокеты + повторный `shutdown` раз в полсекунды, и
ожидание гарантированно конечно.
4. **Слот протокола выдаётся только после handshake.** Соединение до
`Upgrade` живёт в общем массиве (`TCI_MAX_SOCKETS` = 32) и обязано
уложиться в `TCI_HANDSHAKE_MS` (5 с), иначе закрывается по таймауту сокета;
восемь слотов `TCI_MAX_CLIENTS` считаются только среди поднявшихся. Раньше
восемь молчащих TCP-соединений навсегда закрывали дверь настоящим клиентам.
5. **Браузерные клиенты не пускаются.** Handshake с заголовком `Origin`
получает 403. Origin шлёт только браузер, а авторизации в TCI нет: без этой
проверки любая открытая вкладка дотягивалась бы по `ws://127.0.0.1:40001`
до `TRX`, `TUNE` и `VFO`. Своей web-странице нужен явный прокси, а не дыра
по умолчанию.
Разбор HTTP — построчный (`TCIHttpHeader`), а не поиском подстроки
«`upgrade: websocket`»: заголовок с табуляцией или без пробела после
двоеточия валиден. Close-кадр подтверждается ответным close с тем же кодом
(RFC 6455 §5.5.1); полезная нагрузка close длиной ровно один байт невалидна и
рвёт соединение. Текстовые сообщения проверяются на UTF-8 (`TCIValidUTF8`,
§5.6): обрыв последовательности, избыточно длинная форма, суррогаты и всё
выше U+10FFFF закрывают соединение, а не уходят в разбор команд.
### 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.ApplyTCISettings` пишет
`settings.json` только после успеха и на отказе возвращает поля окна к тому,
что реально работает. Иначе занятый порт оставлял оператора вообще без TCI, да
ещё и с нерабочей конфигурацией на следующий запуск.
### 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` шлётся **после** полного дампа состояния: клиент, дождавшийся его,
уже знает всё. `IF_LIMITS` = ±sample rate/2, пересылается при смене частоты
дискретизации.
`VFO_LIMITS` **переобъявляются на лету**: при `rfDevice`/`rfXvtr`/`rfBand`
адаптер пересчитывает границы и, если они изменились, рассылает `vfo_limits`
заново (дедуп по последнему известному значению — `rfDevice` приходит и на
создание слайса). Кэш границ ведётся **независимо от того, есть ли клиенты**:
иначе радио, отвалившееся в момент, когда не подключён никто, осталось бы для
кэша незамеченным, и после его возвращения рассылка подавилась бы — новый
клиент навсегда остался бы с запасным диапазоном из своей пачки инициализации. Перезапросить границы клиент не может, а сценарий обычный: логгер
подключился до радио и получил запасные 10 кГц…30 МГц, потом появился Pluto
или включился трансвертер — и его представление о пределах устарело, хотя
команды уже отбраковываются по новым.
`VFO_LIMITS` и проверка частоты в командах берутся из **одного** источника —
`FreqLimits` поверх `VisibleFreqBounds`: под трансвертером это диапазон его
слота, иначе пределы устройства, а пока устройства нет — объявленный запасной
диапазон 10 кГц…30 МГц. Раньше источников было два, и они расходились: клиенту
объявлялись пределы АЦП, а команда под трансвертером принимала любое число.
Для `DDS` это опаснее, чем для `VFO`: `SetCenter`/`SetPanDDCFreq` ничего не
клампят и отдают частоту прямо в backend.
В дампе состояния есть и то, что иначе клиент не получил бы часами:
`TX_FREQUENCY` (уведомление шлётся по изменению, а на стабильном радио его нет
— особенно важно при split и TX-слайсе), текущий `APP_FOCUS` и `VFO_LOCK` на
каждый канал.
**У живого пана канал A есть всегда.** Выключить канал A в TCI нечем — он
существует по определению, поэтому пан без слайсов показывает канал A на своём
центре (`DDS`), а не хранит частоту удалённого слайса. Команды на такой канал
игнорируются: слайса под ним нет. Как только слайс появится, канал станет
настоящим.
**Приёмники, которых ещё нет, молчат.** `TRX_COUNT` объявляет потолок железа
(`BackendCaps.MaxPans`) один раз и навсегда, а пан из этого потолка может быть
не создан. Для несуществующего пана не шлётся ничего (раньше уходили
`dds`/`vfo`/`if` с нулём, и клиент принимал ноль за настоящую частоту);
состояние приходит, когда пан появится — с `rfPanFreq`/`rfSliceState`. У
существующего пана без слайсов есть только `DDS`. Показания измерителей для
таких приёмников тоже не отправляются.
Список видов связи: `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)
Общее для всех установок:
- **аргумент разбирается строго** (`TCITryArgInt/Float/Bool`). Не разобрался —
параметр не трогаем и отвечаем текущим значением. Раньше `vfo:0,0,abc;`
превращалось в честный ноль и уводило приёмник на 0 Гц, а любой мусор в
Boolean-командах читался как `false`;
- **частота проверяется дважды**. В потоке клиента — `FreqSane` по тем же
границам, что объявлены в `VFO_LIMITS` (см. §2.1): ноль и отрицательные —
отказ. И ещё раз в потоке контроллера, уже по живым границам (`FreqSaneLive`
в `SyncSetVfo`/`SyncSetCenter`): между разбором команды и её исполнением
оператор успевает сменить устройство, и снимок, по которому частоту
пропустили, описывает уже не то радио, куда она уедет. Ни `SetVfoA`, ни
`SetCenter`, ни `SetPanDDCFreq` границ не клампят — число уходит прямо в
backend. Кромки фильтра обязаны разбираться обе и идти по возрастанию;
- **слайс перестраивается только `TuneSliceInBand`** — тем же путём, что у
CAT-порта слайса: внутри включённого диапазона он ходит свободно, за
захваченную полосу окно DDC переедет само, а за границы диапазона команда
отбрасывается. Прямой `SetSliceTarget` (как было) уводил слайс куда угодно, и
для TX-слайса эта частота попадала прямо в DUC — то есть в эфир на чужом
диапазоне, без переключения антенн и фильтров. Смена диапазона остаётся
решением оператора, а не управляющего ПО;
- **параметр захватывается на 200 мс** (§3.5, `Claim`). Пока клиент крутит
частоту, второй логгер её не перебьёт; изменение от оператора захватывает
параметр так же, но у клиента, который им прямо сейчас управляет, не
отбирает. Захваты клиента снимаются при его отключении.
Полностью проведено в контроллер:
| Команда | Куда легло |
|---|---|
| `START` / `STOP` | `SetRun` |
| `DDS` | `SetCenter` / `SetPanDDCFreq` |
| `IF`, `VFO` | `SetVfoA/B`, `TuneSliceInBand` |
| `MODULATION` | `SetMode` / `SetSliceMode` |
| `TRX` | `SetMOX` |
| `TUNE` | `SetTune` |
| `DRIVE` | `SetDrive` |
| `TUNE_DRIVE` | `FTXSettings.TUNLevel` через `SetTXSettings` |
| `SPLIT_ENABLE` | `SetSplit` |
| `RX_FILTER_BAND` | `SetFilterEdges` / `SetSliceFilter` |
| `VOLUME`, `MUTE` | `SetRxVolume`, `SetMute` (дБ ↔ 0..100) |
| `RX_MUTE`, `RX_VOLUME` | громкость/мьют слайса |
| `MON_VOLUME`, `MON_ENABLE` | `SetTXMonVolume`, `SetRxMuteOnTx` |
Громкость приёма и громкость самоконтроля — **разные** величины, и в TCI это
разные команды. У слайдера программы они одна: `SetVolume` правит ту, что
сейчас звучит (на передаче в DUP с выключенным RX MUTE — монитор). Поэтому
`VOLUME` и `RX_VOLUME` ходят через адресный `SetRxVolume`: иначе команда,
пришедшая на передаче, уезжала бы в громкость монитора, а в ответ клиент
получал бы нетронутый `FVolume`. Обратный путь тоже разделён — у монитора
появилось своё событие `rfMonVolume`, раньше его правка рассылалась клиентам
как обычный `volume`.
| `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`, спот виден на всех
панадаптерах; время спота — UTC, как у кластера, а не местное),
`RX_SENSORS_ENABLE`, `TX_SENSORS_ENABLE` (период — на клиента),
`IQ_SAMPLERATE`, `AUDIO_SAMPLERATE`, `AUDIO_STREAM_*`, `TX_STREAM_AUDIO_BUFFERING`
(значения принимаются и подтверждаются; сами потоки — этап 2). Параметры
потоков — настройки **клиента**, а не устройства: живут в `TTCIClient`, и один
клиент не переопределяет их остальным; подписки и параметры читаются/пишутся
под локом клиента, потому что пишет их его поток, а читает тик-поток.
Значения сверяются со списками протокола: `IQ_SAMPLERATE` — 48/96/192/384 кГц,
`AUDIO_SAMPLERATE` — 8/12/24/48 кГц, `AUDIO_STREAM_SAMPLE_TYPE`
int16/int24/int32/float32. Чужое значение не принимается, в ответе уходит
действующее.
Команды **запуска** потоков (`IQ_START`/`IQ_STOP`, `AUDIO_START`/`AUDIO_STOP`,
`LINE_OUT_*`) отвечают `tci_error:<команда>,binary streams are not implemented`.
Молчать нельзя: клиент решил бы, что поток пошёл, и ждал бы данных бесконечно.
### 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`).
**Пачка инициализации уходит одним куском.** Сервер зовёт `OnConnect` под
`FClientLock`, то есть рассылка ждёт, пока весь дамп не уложен в очередь
клиента. Без этого изменение, случившееся после строки снимка, но до
`Ready = True`, пропадало навсегда: `Broadcast` пропускает не-Ready клиента, а
тот считал инициализацию завершённой и оставался со старым значением. Отсюда
запрет для `HandleConnect`: никаких `Invoke` в поток контроллера — он сам может
стоять на этом локе внутри `Broadcast`.
**Создание и удаление слайса.** `AddSlice`/`RemoveSlice` шлют только `rfDevice`,
и по нему адаптер сравнивает расстановку до и после (`SliceMapSig`: id и пан
каждого слайса по порядку слотов). Изменилась — уходит полная картина каналов
каждого живого доп. приёмника (`PushChannelMap`). Иначе подключённый клиент не
узнавал ни о появлении канала, ни о его исчезновении, а при удалении первого
слайса второй молча становился каналом A. Сказать «приёмника больше нет» в
TCI 2.0 нечем — про канал B есть `RX_CHANNEL_ENABLE`, про сам приёмник ничего;
это ограничение протокола, а не наше упрощение.
**Глобальные величины рассылаются всем.** `TUNE_DRIVE`, `CW_MACROS_SPEED`,
`CW_MACROS_DELAY` и `SPLIT_ENABLE` — свойства радио, а не клиента, поэтому
команда отвечает `Broadcast`, а не `Reply` (автор входит в рассылку). Правки от
оператора приходят событиями: `rfTXProfile` для уровня TUN, `rfActiveVfo` для
split, `rfMonVolume` для громкости самоконтроля и новый `rfCWSettings`, который
теперь шлёт `SetCWSettings` — своего события у телеграфа не было вовсе, и
клиенты о смене скорости из окна настроек не узнавали.
Отдельная история — **доп. приёмники**. У главного тракта на каждое поле есть
своё `rfXxx`, а у слайсов не было ничего: правка слайса не доходила ни до UI,
ни до остальных клиентов. Поэтому в контроллере появилось `rfSliceState`
(полезная нагрузка — `FSliceFreqId`, как у `rfSliceFreq`), и его шлют сами
сеттеры слайса: `SetSliceMode`, `SetSliceFilter`, `SetSliceAGCMode`,
`SetSliceVolume`, `SetSliceMute`, `SetSliceDSP`, `SetSliceFMSquelch`.
Адаптер разворачивает Id обратно в пару (приёмник, канал) и рассылает
состояние именно этого канала.
Частоту слайса объявляет сам `SetSliceTarget`: `SliceFreqChanged` живёт
**внутри** него, а не у каждого вызывающего. Раньше об этом помнили CAT и TCI,
но не UI — перетаскивание флага мышью обновляло только свой пан, и TCI-клиенты
оставались на старой частоте. Дублирующие вызовы у вызывающих (в том числе
ручные `PushSliceFlagState`/`LayoutFlags` в MainForm) убраны: теперь один
путь на всех — мышь, колесо, CAT, TCI, бэнд-логика.
### 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` | не реализованы: своего ключа-уведомления и опроса педали наружу у контроллера нет |
| Мода, фильтр, АРУ, шумодавы у канала B доп. пана | в TCI это свойства **приёмника**, а не канала: они относятся к каналу A. У канала B по протоколу есть только частота, IF и громкость |
| Захват параметра (§3.5) | реализован для того, что клиенты действительно перетягивают (частота, DDS, мода, фильтр, TRX/TUNE/DRIVE, split, громкости, АРУ, шумодавы, squelch, скорость CW). Эхо-параметры (RIT/XIT, BIN/ANC/…) не захватываются: на радио они не влияют |
| Браузерные клиенты | отвергаются по `Origin` (403), см. §1.1. Web-интерфейсу ewsdr TCI не нужен — у него свой канал |
| `TRX_COUNT` | равен `BackendCaps.MaxPans`, а не числу живых панов: протокол объявляет его один раз. Про несуществующий приёмник просто ничего не шлётся (§2.1) |
Отдельно: у `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`.
Приёмный буфер `TWsClient` — 4 КБ, и сейчас это жёсткий потолок: кадр крупнее
рвёт соединение (команд такой длины у TCI нет). Под TX-аудио его придётся
растить вместе с этапом 2.
---
## 5. Проверено
Стендом (WS-клиент на сыром сокете, без внешних библиотек):
- **Транспорт:** строгий разбор bind-адреса и отказ подниматься на кривом;
первый кадр, приклеенный к пакету handshake; сборка фрагментированного
сообщения; несколько команд в одном кадре; отказ от незамаскированных кадров
с разрывом соединения; ping/pong; рассылка двум клиентам; чистая остановка с
живыми клиентами; медленный клиент (5000 рассылок не блокируют вызывающего,
клиент вылетает сам); остановка сервера в тот момент, когда команда клиента
висит в `Synchronize` у потока контроллера.
- **Сквозной прогон** с настоящим `TRadioController` (движки созданы, железо не
подключено): пачка инициализации со всеми обязательными
командами и `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`).
Прогон после ревизии (49 проверок, все зелёные):
- **Протокол:** строгий разбор (`abc`, пустой аргумент, переполнение Integer,
дробная запись), списки частот потоков и форматов сэмплов, построчный разбор
HTTP-заголовков.
- **Транспорт:** 101 на заголовки с табуляцией и без пробела после двоеточия;
отказ 403 при `Origin`; ровно восемь поднявшихся клиентов и 503 девятому;
освобождение слотов после отключения; молчащий сокет уходит по таймауту
handshake; ответный close-кадр; `Stop` с живым клиентом.
- **Адаптер:** `vfo:0,0,abc`, `vfo:0,0,-1`, `dds:0,broken`,
`rx_filter_band:0,x,y` не меняют ничего, а годная частота проходит; захват
параметра (второй клиент не перебивает первого раньше 200 мс и перебивает
позже); `iq_samplerate:44100` отвергается, `iq_start` отвечает ошибкой;
в пачке состояния есть `tx_frequency`, `app_focus`, поканальный `vfo_lock`
и нет частот несуществующего пана; отказ применения настроек (кривой адрес,
занятый порт) возвращает прежний работающий слушатель.
- **Остановка под `Synchronize`:** команда клиента висит в очереди главного
потока, `Ad.Free` в этот момент — сервер прокачивает очередь, команда
доисполняется, зависания нет.
Прогон после второй ревизии (60 проверок, все зелёные) добавил к этому:
проверку UTF-8 (обрыв, избыточная форма, суррогат — и то же кадром в эфире),
отказ на однобайтовый close, `OnDisconnect` при остановке сервера, отбраковку
частоты за пределами `VFO_LIMITS` (в том числе `DDS`), переобъявление
`vfo_limits` после появления устройства (в том числе когда радио пропадало и
возвращалось, пока клиентов не было) и изоляцию медленного клиента: четыре
тысячи рассылок в молчащий сокет не мешают соседу получить ответ быстрее
секунды. Всего 73 проверки — добавились отбраковка частоты и центра живыми
границами, когда снимок ещё разрешает (устройство «пропало» без событий),
рассылка `tune_drive`, `mon_volume` и
`split_enable` соседнему клиенту, адресность `VOLUME` на передаче с
самоконтролем, устойчивость к падению сеттера CW (на стенде `SetCWSettings` без
движков и сети валится с AV — клиент получает `tci_error`, соединение живо) и
неразрывность пачки инициализации под крутящейся ручкой.
Чего стенд не проверяет: поведение пана без слайсов и рассылку каналов при
создании/удалении слайса — для них нужен живой DSP-движок, которого на стенде
нет. Остаётся и известное окно: показания измерителей читают `FDSPEngine` из
тик-потока, и смена устройства в этот момент теоретически может застать его уже
освобождённым (та же схема, что у web- и CAT-подсистем).
Попутно стенд поймал ещё одно: запись в сокет, закрытый клиентом, приносила
SIGPIPE, а он по умолчанию убивает процесс (в GUI сигнал гасит виджетсет, а
демону гасить некому). `WebUtils.SockSend` теперь шлёт с `MSG_NOSIGNAL`
send просто возвращает EPIPE, и клиент выбрасывается штатно. Правка общая
с web-подсистемой: пишет в сокеты клиентов она тем же вызовом.
Сборка: `lazbuild -B --ws=qt6 ewsdr.lpr` и `./build-ewsdrd.sh` (демон собирается,
TCI в его граф пока не заведён — юниты LCL-free, подключается одной строкой в
`ewsdrd.lpr`, как web).
**На реальном железе и с реальным клиентом (Log4OM/N1MM/WSJT-X/CW Skimmer) не
проверялось.**
Ответ на команду-установку клиент получает дважды: прямым ответом и рассылкой
из `OnState`. Это осознанно — дубли идемпотентны, а рассылка нужна для тех
случаев, когда значение поменял не клиент, а оператор.