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

44 KiB
Raw Blame History

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.
  • Слайсы сетевые потоки не читают вовсе. Снимок таблицы (RefreshSlicesFSliceSnap) делает поток контроллера: в конструкторе адаптера, в 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:

"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_CLEARTDXSpotStore, спот виден на всех панадаптерах; время спота — 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. Это осознанно — дубли идемпотентны, а рассылка нужна для тех случаев, когда значение поменял не клиент, а оператор.