Восемь дефектов, найденных прогоном настоящего TCI-клиента (три приёмника: NFM, DIGU, FMRAW) и его отчётом. 1. UI доп. панорам не перерисовывался: rfSliceState рассылался, но ветки в MainForm.OnControllerState не было (частоту несёт отдельный rfSliceFreq). 2. Stream.length у аудио — сэмплы НА КАНАЛ (§4.3), у IQ — вещественные отсчёты (§3.4: комплексных = length/channels). Было ×каналы везде, у стерео получалось вдвое больше. Развилка в TCIFillHeader + разбор TX-аудио в HandleBinary. 3+4. Дыры в маршрутах аудио движка: demod-тап звался только для DMR/FMRAW (у DIGU не было RX_AUDIO), а пост-громкостный — только для нецифровых (у FMRAW не было LINEOUT). Плюс мьют слайса больше не убивает RX_AUDIO: движку сообщают SetAudioTapsActive. 5. Клиент, поставивший TRX, уходил — MOX оставался. FTrxOwner + StopTxOf; TCIMicRequested снимается и по окончании любой передачи. 6. Гонка снятия IQ-тапа: SetIQTap(nil) возвращался раньше, чем DSP-поток выходил из вызова. FIQTapLock (порядок FSliceLock → FIQTapLock). 7. Рекордер был кольцом «последние N секунд», а §4.3 говорит про МАКСИМАЛЬНОЕ время записи с удалением по истечении. Переделан в линейный буфер с окном по часам от START. 8. TCIServer.HandleClient считал recv = 0 таймаутом: ноль — это EOF, errno при нём не трогается и несёт EAGAIN от прошлого истёкшего TCI_POLL_MS. Обычный TCP-разрыв без close-кадра не освобождал слот до остановки сервера, и после нескольких аварийных отключений новые клиенты упирались в TCI_MAX_CLIENTS. Теперь R = 0 рвёт связь безусловно, errno спрашивается только при R < 0. Попутно: MainForm.RecreateDSPEngine (смена sample rate до START) терял внутренние колбэки контроллера — введён AttachEngineCallbacks. Стенд test/tci заведён в репозиторий (run.sh, 126/126 зелёных, включая сквозной прогон через живой WDSP), доп. проверки на оба пути отключения клиента. doc/TCI.md приведена в соответствие: правило про recv = 0 в §1.1, единицы Stream.length, линейный буфер рекордера. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
65 KiB
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 Правила, на которых держится транспорт
Всё это не украшения, а лечение конкретных отказов — менять с оглядкой.
-
Отправка никогда не блокирует ни вызывающего, ни соседей.
Send/Broadcastкладут строку в очередь клиента (микросекунды под его локом), а в сокет её пишет собственный поток клиента: егоrecvпросыпается каждыеTCI_POLL_MS(20 мс) и сливает очередь. Уведомления рождаются внутриChanged()контроллера, то есть в UI-потоке — писать оттуда прямо в сокет означало бы отдать интерфейс во власть самого медленного клиента. Общий поток отправки был лишь половиной решения: одинSockSendждёт доTCI_SEND_TIMEOUT(300 мс), и восемь клиентов давали секунды задержки всем остальным. Теперь медленный клиент задерживает только себя; переполнилась его очередь (TCI_OUT_MAX) или не прошла запись — он выбрасывается. -
Объект клиента освобождает только тик-поток (
ReapClients) и только после того, как клиентский поток честно вышел. Поэтому указатель, взятый кем угодно подFClientLock, гарантированно жив внутри лока. Единственное исключение —Stop, но он к этому моменту уже дождался всех потоков. И в том и в другом случае наверх уходитOnDisconnect(единая точкаDisconnected): иначе после остановки у адаптера оставались висеть захваты параметров ушедших клиентов. -
Stopждёт выхода клиентских потоков без таймаута и прокачивает при этом очередьSynchronize. Останавливает сервер поток контроллера (UI), а клиентский поток в этот момент может висеть как раз наInvokeв него же: без прокачки это взаимный клин. Выйти по таймауту нельзя — следом освобождаются и клиенты, и сам сервер с адаптером, а не вышедший поток вернулся бы в эту память. Поэтому:Stopping(адаптер новыхInvokeне начинает) + закрытые сокеты + повторныйshutdownраз в полсекунды, и ожидание гарантированно конечно. -
Слот протокола выдаётся только после handshake. Соединение до
Upgradeживёт в общем массиве (TCI_MAX_SOCKETS= 32) и обязано уложиться вTCI_HANDSHAKE_MS(5 с), иначе закрывается по таймауту сокета; восемь слотовTCI_MAX_CLIENTSсчитаются только среди поднявшихся. Раньше восемь молчащих TCP-соединений навсегда закрывали дверь настоящим клиентам. -
recv= 0 — это конец связи, а не таймаут. Приёмный цикл клиента ждёт не дольшеTCI_POLL_MS, поэтому «ошибка»EAGAINдля него — норма, но спрашиватьerrnoразрешено только при отрицательном результате: ноль означает EOF,errnoпри нём не трогается и вполне может нестиEAGAINот прошлого истёкшего опроса. Пока проверка была общей (R <= 0), обычный TCP-разрыв без close-кадра — упавший клиент, выдернутый кабель — выглядел как таймаут: поток крутился впустую, слот не освобождался до остановки сервера, и после нескольких аварийных отключений новые клиенты упирались вTCI_MAX_CLIENTS. Места в буфере при этом всегда хватает (кадр крупнее буфера рвётся раньше), так что иного смысла у нуля нет. -
Браузерные клиенты не пускаются. Handshake с заголовком
Originполучает 403. Origin шлёт только браузер, а авторизации в TCI нет: без этой проверки любая открытая вкладка дотягивалась бы поws://127.0.0.1:40001доTRX,TUNEиVFO. Своей web-странице нужен явный прокси, а не дыра по умолчанию. -
Блоки потоков нарезает и раскладывает 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:
"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: §4.3 говорит про arg1
«количество сэмплов, указываемое в поле Stream.length», а умолчание 2048 при
48 кГц сходится с потолком data[16384] только так — 2048 × 2 канала ×
float32 = ровно 16384 байта, то есть length считает сэмплы, а не отсчёты.
У IQ единица другая: §3.4 определяет число комплексных отсчётов как
length / channels, поэтому там в length уходит samples × channels.
Развилку держит TCIFillHeader (по типу потока), обратную — разбор
TX_AUDIO_STREAM в HandleBinary. Смена любого параметра потока на ходу
перезапускает уже идущие потоки этого клиента: блок с новой частотой
посреди старого потока клиенты разбирают как мусор.
Передача (§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. ★Буфер линейный, не кольцевой: §4.3 называет arg2
максимальным временем записи и прямо говорит, что по его истечении запись
удаляется, а чтобы сохранить файл, SAVE нужно прислать внутри интервала.
Поэтому START открывает окно длиной arg2, пишем от его начала, а по концу
окна данные выбрасываются вместе с памятью (DropData из Feed и из Take).
Срок считается по часам от START, а не по накопленным сэмплам: линейный
выход может молчать (мьют, стоящий приёмник), а время записи всё равно идёт.
Кольцо «последние N секунд» вело себя иначе в обе стороны — начало записи
затирало само себя, а SAVE через час после START отдавал файл, которого у
ExpertSDR3 давно бы не было.
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. Открыто:
RX_CHANNEL_ENABLEкак реальное создание/удаление второго слайса пана. Сейчас это эхо: канал B главного приёмника — VFO B, он есть всегда, а заводить слайс по команде клиента значит отдать ему управление раскладкой панорамы оператора.KEYER— своего события «ключ нажат» у контроллера нет.- TCI в демоне. Юниты LCL-free (стенд собирает и гоняет их вместе с
TRadioControllerбез единого виджета), подключается одной строкой вewsdrd.lpr, как web. - Проверка на железе и с настоящим клиентом — главное, см. конец §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 девятому; освобождение слотов после отключения — обоими путями, и штатным close-кадром, и обычным TCP-разрывом без него (см. правило 5 в §1.1); молчащий сокет уходит по таймауту 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 проверок, все зелёные
Отдельная программа (test/tci/tcitest.pas, прогон — test/tci/run.sh,
внешних библиотек не требует) проверяет потоки на четырёх уровнях:
- Формат и математика: коэффициенты прореживания (в том числе «просят выше,
чем есть» и «нацело не делится»), умолчания размера блока, поля заголовка,
сквозная упаковка/распаковка всех четырёх форматов сэмплов и клип на
перегрузе, разбор пути с
|вместо:. - Пересчёт частоты: единичное усиление дециматора на постоянке, тон 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).
★Про сборку стенда — test/tci/README.md: там записаны обе грабли (обязательный
-Mobjfpc и отдельный каталог .ppu с абсолютным путём, иначе линковка тянет
PlatformUtils из GUI-сборки, собранный с LCL).
На реальном железе и с реальным клиентом (Log4OM/N1MM/WSJT-X/CW Skimmer) не проверялось — ни команды, ни потоки.
Ответ на команду-установку клиент получает дважды: прямым ответом и рассылкой
из OnState. Это осознанно — дубли идемпотентны, а рассылка нужна для тех
случаев, когда значение поменял не клиент, а оператор.