# TCI в EWSDR — статус реализации Ветка разработки: `feature/tci-protocol`. Дата последнего обновления: 2026-08-18. Эталон протокола — «Протокол 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` (~2600 строк) | Мост к `TRadioController`: реализация команд, пачка инициализации, уведомления об изменениях состояния, измерители, захват параметров (§3.5), подключение потоков к тапам аудио/IQ. | | `TCIStreams.pas` (~600 строк) | Бинарные потоки (§3.4): дециматор/интерполятор, нарезка блоков с заголовком, кольцо записи линейного выхода и писатель WAV. Зависит только от RTL + `TCIProtocol`/`TCIServer`. | Принципы те же, что у 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-странице нужен явный прокси, а не дыра по умолчанию. 6. **Блоки потоков нарезает и раскладывает DSP-поток.** Тап зовётся прямо из потока WDSP, поэтому в `TCIStreams` нет ни одного ожидания: блок уходит в кольцо клиента (микросекунды под его локом), а в сокет его пишет, как и команды, поток самого клиента. Порядок локов везде один: `FSliceLock` → `FStreamLock` (тап сначала выясняет, чей это слайс, и только потом ищет подписчиков) — обратный дал бы клин с потоком контроллера. Тапы навешивает и снимает **только поток контроллера**: снятие обязано дождаться выхода DSP-потока из вызова, иначе тот позвал бы метод освобождённого адаптера. Разбор 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 — вкладка **CAT → TCI Server**, справа от «TCP CAT Server» (галка, порт, интерфейс): TCI — такой же канал внешнего управления трансивером, что и CAT, и оператор ищет его там, а не в «Advanced». В протоколе **нет авторизации**: открытый наружу порт означает полный доступ к трансиверу, поэтому умолчание слушает только петлю. Отсюда же правила вокруг адреса: - разбор `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.4) Реализованы все четыре типа: `IQ_STREAM`, `RX_AUDIO_STREAM`, `LINEOUT_STREAM` (наружу) и `TX_AUDIO_STREAM` + `TX_CHRONO` (внутрь), плюс запись линейного выхода в файл. Команды: `IQ_START/STOP`, `AUDIO_START/STOP`, `LINE_OUT_START/STOP`, `LINE_OUT_RECORDER_START/SAVE/BREAK` и параметры `IQ_SAMPLERATE`, `AUDIO_SAMPLERATE`, `AUDIO_STREAM_SAMPLES/CHANNELS/SAMPLE_TYPE`, `TX_STREAM_AUDIO_BUFFERING`. **Откуда берётся звук.** Протокол различает «аудиопоток приёмника» и «поток линейного выхода», и это не одно и то же: | Поток TCI | Точка в ewsdr | Что это значит | |---|---|---| | `RX_AUDIO_STREAM` | `OnDemodAudioReady` (тап `rakDemod`) | выход демодулятора **до** громкости и мьюта, без сайдтона — то, что нужно скиммеру и цифре | | `LINEOUT_STREAM` | `OnAudioReady` (тап `rakLineOut`) | ровно то, что слышно: после громкости, с сайдтоном; MUTE его глушит (движок не зовёт `OnAudio` под мьютом) | | `IQ_STREAM` | `TWDSPEngine.PushIQItemToDSP` / `PushPanItemToDSP` | сырой IQ приёмника, один вызов тапа на накопленный блок | Тап — многоадресный и **ничего не забирает**, в отличие от `OnAudioConsume` (им владеет web-адаптер и им же глушит локальный звук). Иначе первый же TCI-клиент отобрал бы звук у динамика и у браузера. Приёмник в потоках — панадаптер, как и везде (§1.3). У доп. пана в потоках участвует только **канал A** (первый слайс): «аудиопоток приёмника» в протоколе один на приёмник, отдельного потока канала B нет. **Пересчёт частоты вниз — многоступенчатый.** Аудио 48 кГц → 8/12/24 и IQ до 48/96/192/384 кГц считает `TCIStreams`. Коэффициент раскладывается на множители, и каждая ступень фильтрует своё: одной ступенью большие коэффициенты не берутся, потому что длина FIR растёт вместе с коэффициентом и упирается в потолок. ★Это не теория: на верхнем пресете Pluto (5760 кГц, коэффициент 120 к 48 кГц) одноступенчатый дециматор давал **завал 1.3 дБ в полосе и подавление зеркала всего 16 дБ**, то есть поток IQ с мусором. Спецификацию фильтра каждой ступени задаёт **итоговая** полоса, а не её собственная: ранняя ступень обязана убрать лишь те узкие зоны, которые в конце сложатся в полезную полосу, поэтому её переходная полоса шире в десятки раз, а фильтр во столько же короче. Свёртка идёт со сложением симметричных пар (фильтр линейнофазовый). Замеры после переделки: −83 дБ по зеркалу на **любом** коэффициенте, полоса ровная, цена ≈10% ядра на потоке 5.76 МГц (было 20% — и с мусором) и ≈1% на 384 кГц. Вверх (TX-аудио клиента → 48 кГц тракта) — линейная интерполяция: её образы лежат за полосой TX-фильтра, а завал в полосе 0.2 дБ на 3 кГц. **Ответ на `IQ_SAMPLERATE` называет достижимое, а не запрошенное.** Просьбу клиента храним как есть (на другом устройстве она может стать выполнимой), а в ответ отдаём то, что он реально получит на главном приёмнике. Подтвердить «384000» и слать 192 кГц значило бы соврать в единственном месте, куда клиент и смотрит. Из-за этого же `iq_samplerate` **переобъявляется без запроса** при смене частоты дискретизации и устройства (`rfSampleRate`, `rfDevice`, `rfConnected`) — иначе клиент, попросивший 384 кГц на openHPSDR, после перехода на Pluto 576 кГц молча получал бы 192. По той же логике устроен и `IF_LIMITS`: §4.1 прямо требует высылать его «при подключении и изменении частоты дискретизации устройства», и он уходит на `rfSampleRate`. **Какая частота IQ достанется клиенту.** Набор протокола (48/96/192/384 кГц) и частоты дискретизации железа сходятся не всегда. У openHPSDR (48…384 кГц) сходятся все, а из пресетов Pluto (576/768/960/1536/2304/3072/3840/5760 кГц) на 384 не делятся 576 и 960. Поэтому отдаём **наибольшую законную** частоту, не выше запрошенной и делящую частоту источника нацело (`TCIPickIQRate`): на 576 и 960 кГц просьба «384» превращается в 192 кГц. Иначе клиент, попросивший 384, получал бы поток на 576/480 кГц — и не по протоколу, и вчетверо толще ожидаемого. Настоящая частота всегда стоит в заголовке блока; если законной не нашлось вовсе (чужой rate), уходит ближайшая достижимая — врать в заголовке мы не будем ни в каком случае. Смена rate устройства или DDC пана на ходу пересобирает прореживание (`SetSourceRate`). **Размер блока.** `AUDIO_STREAM_SAMPLES` — это сэмплы **на канал**, а в `Stream.length` уходит, как велит §3.4, количество вещественных отсчётов (`samples × channels`). Сходится и с умолчаниями ExpertSDR3 (2048 при 48 кГц даёт ~43 мс), и с потолком `data[16384]`: 2048 × 2 канала × float32 = ровно 16384 байта. Смена любого параметра потока на ходу **перезапускает** уже идущие потоки этого клиента: блок с новой частотой посреди старого потока клиенты разбирают как мусор. **Передача (§3.4, §4.2).** `TRX:0,true,tci` берёт модуляцию из аудиопотока клиента — но только если у него запущен `AUDIO_START` (буквально по документу: «работает, если включен аудиопоток по TCI»). В контроллере это отдельный флаг `TCIMicRequested`, который `SetMOX` читает при выборе микрофона, **впереди** web-клиента: явная просьба сильнее умолчания «подключён браузер». Дальше тик сервера гонит маркеры `TX_CHRONO` по часам (плюс разовая подушка `TX_STREAM_AUDIO_BUFFERING` на старте передачи), а приходящие блоки `TX_AUDIO_STREAM` разворачиваются в 48 кГц моно и кладутся в тот же ринг микрофона, что и web-аудио. Аудио от клиента, который не просил `tci`, отбрасывается молча: отвечать ошибкой на каждый чужой блок значит захлебнуться. **Запись линейного выхода.** Рекордер один на приёмник (а не на клиента): пишет он то, что слышно в аппарате. Кольцо на запрошенное время (потолок 300 с) в int16 48 кГц стерео — это ровно то, что уйдёт в WAV, и вдвое меньше памяти, чем float32. `SAVE` завершает запись и отдаёт кольцо отдельному потоку-писателю: файл бывает в десятки мегабайт, а команда пришла в потоке клиента, который в это время не читает свой сокет. MP3 не поддержан — кодера в проекте нет, и на `.mp3` уходит честный `tci_error`. **Потолок кадра.** Приёмный буфер соединения (`WsClient.WS_BUF_SIZE`) поднят с 4 до 32 КБ: блок TX-аудио — это 64 байта заголовка плюс `data[16384]`, а кадр крупнее буфера не собирается никогда (BufLen упирается в потолок и разбор встаёт). Кадр длиннее одного блока рвёт соединение — длиннее протокол не определяет. **Переполнение.** У очереди команд и у кольца блоков разная политика: команду терять нельзя (клиент выбрасывается), блок потока — можно и нужно (теряется самый старый, `TTCIClient.BinDropped` считает). Иначе отставший скиммер рвал бы себе и управление тоже. ### 2.6 Телеграф (§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 не делаем (см. §4) | ## 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) | | Потоки на передаче | RX-аудио и линейный выход на TX замолкают — движок не зовёт аудио-колбэки, пока идёт передача (кроме дуплекса с самоконтролем). То же и с IQ: RX-пакеты на TX дропаются на входе DSP. Это поведение приёмного тракта, а не потоков | | `MUTE` и линейный выход | глушит и поток: движок под мьютом не зовёт `OnAudio`. Аудиопоток приёмника (`AUDIO_START`) мьют не трогает — он снимается до громкости | | DIGL/DIGU, 2 канала | по §3.4 в цифровых модах два канала должны нести комплексный сигнал; у нас это обычное стерео с выхода WDSP. Комплексный вывод демодулятора наружу не выведен | | Поток канала B доп. пана | не бывает: в протоколе аудиопоток один на приёмник, и мы отдаём канал A (первый слайс) | | Формат IQ | всегда float32, два канала — `AUDIO_STREAM_SAMPLE_TYPE` относится к аудио (§4.3), а ExpertSDR3 IQ иначе и не шлёт | | `IQ_SAMPLERATE` 384 кГц на Pluto 576/960 кГц | нацело не делится, поэтому уходит 192 кГц (см. §2.5). Клиент обязан читать частоту из заголовка блока, а не считать её равной запрошенной | | MP3 у рекордера | не поддержан (кодера в проекте нет): `LINE_OUT_RECORDER_SAVE` с `.mp3` отвечает `tci_error` | Отдельно: у `TX_SENSORS` второй аргумент — уровень микрофона; измерителя микрофона в EWSDR нет, шлём нижнюю границу шкалы (-60 дБм), чтобы клиент не рисовал случайные значения. Третий аргумент (RMS) и четвёртый (пик) отдаём одинаковыми — в телеметрии платы одно значение forward power. У `TRX` третий аргумент разобран только для `tci` (см. §2.5). Значения `mic1`/`mic2`/`micpc`/`ecoder2` называют физические входы ExpertSDR3, которых у нас нет: они значат «микрофон, выбранный в программе», то есть ровно то же, что и отсутствие аргумента. --- ## 4. Что осталось Бинарные потоки (§3.4) реализованы целиком — см. §2.5. Открыто: 1. **`RX_CHANNEL_ENABLE` как реальное создание/удаление второго слайса пана.** Сейчас это эхо: канал B главного приёмника — VFO B, он есть всегда, а заводить слайс по команде клиента значит отдать ему управление раскладкой панорамы оператора. 2. **`KEYER`** — своего события «ключ нажат» у контроллера нет. 3. **TCI в демоне.** Юниты LCL-free (стенд собирает и гоняет их вместе с `TRadioController` без единого виджета), подключается одной строкой в `ewsdrd.lpr`, как web. 4. **Проверка на железе и с настоящим клиентом** — главное, см. конец §5. ## 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`, соединение живо) и неразрывность пачки инициализации под крутящейся ручкой. ### Стенд этапа 2 (бинарные потоки) — 112 проверок, все зелёные Отдельная программа (`tcitest.pas` в scratchpad, собирается тем же fpc без внешних библиотек) проверяет потоки на четырёх уровнях: - **Формат и математика:** коэффициенты прореживания (в том числе «просят выше, чем есть» и «нацело не делится»), умолчания размера блока, поля заголовка, сквозная упаковка/распаковка всех четырёх форматов сэмплов и клип на перегрузе, разбор пути с `|` вместо `:`. - **Пересчёт частоты:** единичное усиление дециматора на постоянке, тон 1 кГц проходит, тон 20 кГц при 48→12 кГц давится больше чем на 40 дБ (то самое зеркало, ради которого и стоит фильтр), прозрачность при коэффициенте 1, счёт отсчётов у интерполятора и отсутствие выбросов. Отдельно — каскад на всех интересных коэффициентах (192→48, 384→48 у openHPSDR; 576→48, 1536→48, 5760→48 и 5760→384 у Pluto): полоса ровная, зеркало давится больше чем на 60 дБ. И выбор законной частоты IQ: 576 и 960 кГц на просьбу «384» отдают 192 кГц, 768 кГц отдаёт 384 кГц, чужой rate возвращает просьбу как есть. - **Блок до сокета** (пара сокетов вместо сети): четыре блока RX-аудио 12 кГц стерео float32 с верным заголовком и длиной; IQ 384→48 кГц уходит только целым блоком (полблока не отправляется); моно int16; пересчёт после смены rate источника; переполнение кольца теряет старые блоки, но клиент жив. - **Рекордер и WAV:** кольцо ограничено запрошенным временем, после заворота первым идёт самый старый отсчёт, `Take` опустошает, файл получает верные RIFF/fmt/data и длину. - **Команды на живом сервере** (настоящий `TRadioController`, WS-клиент на сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент, отказ на старт потока с незапущенного пана, подтверждение и отбраковка параметров, `SAVE` без записи и `SAVE` в `.mp3` отвечают ошибкой, `TRX:0,true,tci` без аудиопотока модуляцию не берёт, а с потоком берёт и снимает её по `TRX:0,false` и по уходу клиента; чужой бинарный блок не рвёт соединение; без передачи маркеров `TX_CHRONO` нет. Отдельно — согласование частот: на источнике 192 кГц просьба «384» подтверждается как 192, смена rate устройства сама переобъявляет и `iq_samplerate`, и `if_limits`, а на 576 кГц (Pluto) та же просьба даёт законные 192 кГц. - **Сквозной прогон через живой WDSP:** синтетический 24-битный IQ подаётся в движок, а клиент по WebSocket получает блоки RX-аудио 12 кГц и IQ 48 кГц с верными заголовками; после `AUDIO_STOP`/`IQ_STOP` блоки прекращаются. Передача: `TRX:0,true,tci` поднимает маркеры `TX_CHRONO`, а присланное клиентом аудио 12 кГц доходит до конца тракта (появляются блоки TX-IQ). Чего стенд этапа 2 не проверяет: доп. паны (для них нужен живой DDC), одновременную работу нескольких клиентов на одном потоке и длительный прогон (дрейф пейсинга TX_CHRONO виден только на минутах). Чего стенд не проверяет: поведение пана без слайсов и рассылку каналов при создании/удалении слайса — для них нужен живой 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). ★Пробная сборка стенда: `-Mobjfpc` обязателен. С `-Mdelphi` в командной строке вложенные комментарии выключаются, и `{$MODE Delphi}` внутри шапки `WebUtils.pas` закрывает комментарий раньше времени — компиляция падает на «illegal character». **На реальном железе и с реальным клиентом (Log4OM/N1MM/WSJT-X/CW Skimmer) не проверялось — ни команды, ни потоки.** Ответ на команду-установку клиент получает дважды: прямым ответом и рассылкой из `OnState`. Это осознанно — дубли идемпотентны, а рассылка нужна для тех случаев, когда значение поменял не клиент, а оператор.