Files
ewsdr/doc/TCI.md
T
ew8bakandClaude Opus 5 9b77809a73 fix(tci,cat): резерв бюджета переезжает писателю; строгий разбор полей ZZ-команд
Три замечания по 79132f1.

1. [P2] Лимит памяти рекордеров обходился при SAVE. Take собирал сплошную
   копию записи, а DropData тут же возвращал исходные куски в бюджет —
   копия жила дальше в асинхронном TTCIWavWriter уже неучтённой. Один SAVE
   поднимал настоящее потребление примерно вдвое, а на медленном или
   зависшем сетевом каталоге очередь writer-потоков и их буферов росла
   мимо потолка вовсе. Теперь копия НАСЛЕДУЕТ резерв кусков, из которых
   собрана: Take отдаёт его out-параметром Reserved, писатель держит до
   конца записи и отпускает в ReleaseData — ровно один раз, сколько бы
   путей выхода ни было у Execute. Новых денег у бюджета копия не берёт,
   так что потолок теперь считает и очередь сохранений тоже.

2. [P2] Пять ZZ-команд проверяли длину поля, но не содержимое:
   StrToIntDef(s, 0) превращал любую нечисловую пару символов в индекс 0.
   ZZBSxx; переключал диапазон на нулевой вместо ?;, ZZBMxx; и
   ZZAUxx;/ZZBPxx; двигали VFO, ZZFIxx; выбирал фильтр 0. Разбор приведён
   к идиоме ZZFL/ZZFH: TryStrToInt, иначе ошибка формата.

3. [P3] Сообщение об отказе запуска TCI звало в Settings → Advanced, а
   настройки там уже не живут — вкладка CAT (переезд был в de0830f).

Стенд 194/194: Take отдаёт резерв размером с копию, после Take занят ровно
он, писатель возвращает его по окончании. Без фикса первая проверка
краснеет. GUI (--ws=qt6) и демон зелёные. doc/TCI.md §2.5 и сводка стенда,
doc/CAT_STATUS.md — таблица разбора.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:50:21 +03:00

93 KiB
Raw Blame History

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.
  • Слайсы сетевые потоки не читают вовсе. Снимок таблицы (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. recv = 0 — это конец связи, а не таймаут. Приёмный цикл клиента ждёт не дольше TCI_POLL_MS, поэтому «ошибка» EAGAIN для него — норма, но спрашивать errno разрешено только при отрицательном результате: ноль означает EOF, errno при нём не трогается и вполне может нести EAGAIN от прошлого истёкшего опроса. Пока проверка была общей (R <= 0), обычный TCP-разрыв без close-кадра — упавший клиент, выдернутый кабель — выглядел как таймаут: поток крутился впустую, слот не освобождался до остановки сервера, и после нескольких аварийных отключений новые клиенты упирались в TCI_MAX_CLIENTS. Места в буфере при этом всегда хватает (кадр крупнее буфера рвётся раньше), так что иного смысла у нуля нет.

  6. Браузерные клиенты не пускаются. Handshake с заголовком Origin получает 403. Origin шлёт только браузер, а авторизации в TCI нет: без этой проверки любая открытая вкладка дотягивалась бы по ws://127.0.0.1:40001 до TRX, TUNE и VFO. Своей web-странице нужен явный прокси, а не дыра по умолчанию.

  7. Блоки потоков нарезает и раскладывает DSP-поток. Тап зовётся прямо из потока WDSP, поэтому в TCIStreams нет ни одного ожидания: блок уходит в кольцо клиента (микросекунды под его локом), а в сокет его пишет, как и команды, поток самого клиента. Порядок локов везде один: FSliceLockFStreamLock (тап сначала выясняет, чей это слайс, и только потом ищет подписчиков) — обратный дал бы клин с потоком контроллера. Тапы навешивает и снимает только поток контроллера: снятие обязано дождаться выхода 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",
         "record_dir": "" }

record_dir — единственный каталог, куда TCI пишет записи линейного выхода; пусто = <каталог конфигурации>/records, каталог создаётся при первом SAVE. Поля в UI у него нет намеренно: это не рабочая настройка, а граница, и менять её осмысленно только руками в файле. Почему каталог вообще существует и почему путь из команды в него не попадает — §2.5, «Имя файла: каталог всегда наш».

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 = 7) 0 — главный тракт; N — слайс слота N1, то есть буквы B..G с флага, на каком бы пане он ни стоял. Потолок = 1 + MAX_SLICES
канал A/B (CHANNEL_COUNT = 2) у приёмника 0 — VFO A / VFO B; у приёмника-слайса канал один (A): второго саб-приёмника внутри слайса не бывает
панорама не адресуется: стала свойством приёмника — от неё берутся DDS и поток IQ
DDS центр панорамы, на которой стоит приёмник. Пишется только у приёмника 0 (SetCenter); у слайса читается
IF смещение канала от центра его панорамы
VFO абсолютная частота канала (у слайса — TuneSliceInBand)
передатчик (arg1 у TRX/TUNE) передатчик один, но arg1 называет, ЧЕЙ слайс должен в него попасть: 0 — главный VFO (как обычный CAT-порт), N — слайс слота N−1 через RequestSliceTx. См. §2.7
arg1 у DRIVE, TUNE_DRIVE мощность одна на радио — номер не адресует ничего

Почему приёмник — слайс, а не панорама. Сперва приёмником был панадаптер, как в ExpertSDR3, где приёмник = DDC. У нас это разошлось с жизнью сразу на двух концах. У Pluto панорама ровно одна (BackendCaps.MaxPans = 1), и второй приёмник существует там только как слайс главного пана — при нумерации по панам он был бы недоступен вообще, а TRX_COUNT навсегда равнялся единице. С другого конца — клиенты: у MSHV в настройках всего две модели, «TCI Client rx1» и «rx2», то есть приёмники 0 и 1, и никакого третьего номера ввести некуда (hvrigcontrol.cpp: netServPort[3]/[4], network.cpp: tci_trx). Значит «первый добавленный слайс» обязан быть приёмником 1 на любом железе.

Слот слайса даёт ровно это. Слоты глобальные и стабильные, у каждого своя буква на флаге (SliceIdBySlot, SliceSlotLetter), и по ним же раздаются порты слайс-CAT — то есть номер приёмника TCI совпадает с буквой, которую оператор видит на экране: B → 1, C → 2, … Первый созданный слайс занимает слот B независимо от того, где он живёт: на openHPSDR это может быть слайс второго пана, на Pluto — слайс главного, и в обоих случаях он приёмник 1.

Цена: панорама без единого слайса из TCI пропадает (слушать там нечего, но и её IQ клиенту недоступен), а второй слайс панорамы перестал быть «каналом B» и стал самостоятельным приёмником — что скорее выигрыш: у канала B в протоколе есть только частота, IF и громкость, а у приёмника — мода, фильтр, АРУ, шумодавы, squelch, свои потоки и TRX.


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 на каждый канал.

Приёмник живёт ровно столько, сколько его слайс. Удалили слайс — приёмник исчез вместе с ним: ни частоты, ни состояния, ни потоков. Создали снова — он займёт первый свободный слот, то есть, как правило, тот же номер (так же устроены и порты слайс-CAT, привязанные к слоту).

Приёмники, которых ещё нет, молчат. TRX_COUNT объявляет потолок (1 + 6 слотов слайсов) один раз и навсегда, а слот из этого потолка может быть пуст. Про пустой слот не шлётся ничего — ни vfo, ни dds, ни показания измерителей, и команды к нему тоже игнорируются целиком (LiveRx). Раньше уходили dds/vfo/if с нулём, и клиент принимал ноль за настоящую частоту; для MSHV это вообще фатально — именно ответом на vfo:<rx>,0; он завершает инициализацию, то есть подключился бы к пустоте. Состояние появляется в момент создания слайса: PushChannelMap рассылает картину нового приёмника целиком, включая tx_enable с его номером.

Список видов связи: 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 (приёмник 0) / RequestSliceTx (приёмник N)
TUNE SetTune (приёмник 0) / RequestSliceTx(…, Tune) (приёмник N)
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.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).

Размер блока. Две разные величины, которые §4.3 называет одним словом. AUDIO_STREAM_SAMPLES — это кадры на канал (умолчание 2048 при 48 кГц = 42.7 мс), а Stream.length — вещественные отсчёты всего блока, то есть кадры × channels, и так у всех типов потока. Единицу length задаёт §3.4 для IQ («количество вещественных отсчётов… комплексных = length/channels») и тут же распространяет на аудио: «аудиопоток приёмника полностью повторяет IQ поток», отличия перечислены и их ровно три — каналы, формат сэмплов, число сэмплов в пакете. Формулировка §4.3 «arg1 — количество сэмплов, указываемое в поле Stream.length» верна только для моно, и однажды она уже увела нас в развилку по типу потока: у стерео клиент разбирал половину блока (звук с дырами 50%, FT4/FT8 не декодировались вовсе). Что arg1 считает именно кадры на канал, видно из самой же §4.3: рекомендуемый минимум 512/256/128/100 сэмплов на 48/24/12/8 кГц даёт «не меньше 10 мс» только при счёте на канал, и потолок data[16384] сходится тем же счётом — 2048 × 2 канала × float32 = ровно 16384 байта. Единое правило держит TCIFillHeader, обратное — разбор TX_AUDIO_STREAM в HandleBinary; TX_CHRONO замыкает круг (клиент шлёт столько отсчётов, сколько названо в length маркера). Смена любого параметра потока на ходу перезапускает уже идущие потоки этого клиента: блок с новой частотой посреди старого потока клиенты разбирают как мусор.

Передача (§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.

★Память рекордера — три замка, и все три нужны. Авторизации в протоколе нет (§3.1), поэтому «сколько памяти займёт одна строка из сети» — это вопрос не об аккуратности, а о живучести процесса. Раньше START выделял буфер целиком: 300 с × 48 кГц × 2 канала × int16 = 57.6 МБ на команду, а приёмников 1 + MAX_SLICES = 7, то есть 403 МБ семью строками, и держать их можно было сколько угодно.

  1. Память набирается кусками по секунде, а не вся сразу. START не стоит ни байта; молчащий, замьюченный или просто не звучащий приёмник не стоит ничего вовсе. Куски не перевыделяются (никакого realloc в DSP-потоке) и склеиваются один раз — в Take, а он идёт уже после того, как рекордер вынут из таблицы, то есть без DSP-потока на плечах.
  2. Общий бюджет TCI_RECORD_MAX_BYTES (128 МБ) на все рекордеры сразу. Спрашивается при выделении каждого куска. Отказ не рушит запись: набранное остаётся сохраняемым, просто дальше она не растёт — иначе клиент терял бы уже записанное из-за чужой записи на другом приёмнике. ★SAVE бюджет не обходит: Take собирает куски в сплошную копию и передаёт ей их резерв (out-параметр Reserved), а не возвращает его сразу — копия живёт дальше в TTCIWavWriter, и отпускает место он же, когда данные больше не нужны (ReleaseData — ровно один раз, сколько бы путей выхода ни было у Execute). Иначе один SAVE поднимал бы настоящее потребление вдвое, а на медленном или зависшем сетевом каталоге очередь writer-потоков росла бы мимо потолка вовсе.
  3. Освобождение по трём событиям, а не по одному. Раньше срок проверял только Feed, то есть DSP-поток — а к мёртвому приёмнику он не приходит никогда, и START на несуществующий номер оставлял память навсегда. Теперь: START требует живого приёмника (RxActive, ответ receiver is not running — как у потоков); тик сервера подметает истёкшие окна по часам (SweepRecorders, окно закрывается от START и без единого блока звука); уход клиента забирает его записи (DropClientRecorders — рекордер живёт на приёмнике, но платит за него тот, кто нажал START, иначе пары «подключился, START, отключился» набивали бы бюджет до потолка).

★Имя файла: каталог всегда наш. LINE_OUT_RECORDER_SAVE в §4.3 принимает «полное имя файла», и раньше оно почти без изменений уходило в fmCreate — то есть любой, кто дотянулся до порта (а bind наружу разрешён), писал файлы от имени EWSDR куда угодно и затирал существующие. Теперь из строки берётся только имя файла (TCIRecordPath), а каталог — настроенный каталог записей (tci.record_dir, пусто = <каталог конфигурации>/records). Полный путь на СЕРВЕРЕ клиенту всё равно бесполезен: файл ложится не на его машину, а на нашу, поэтому каталог из просьбы отбрасывается молча — клиент получает разумный файл, а не ошибку. Само имя проверяется: пусто, ., .., управляющие символы, :, длиннее 120 и расширение не .wav — отказ (bad file name). Выйти за каталог после ExtractFileName нечем: разделителей в имени уже не осталось, а .. не проходит проверку. Файл создаётся эксклюзивно (TCICreateNewFile: O_EXCL or O_NOFOLLOW на Unix, CREATE_NEW на Windows) — существующий не перезаписывается и симлинк не уводит наружу, причём одним вызовом ядра, без окна между FileExists и созданием. Клиенту про уже занятое имя отвечаем file exists до постановки задачи писателю.

Потолок кадра. Приёмный буфер соединения (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;) игнорируется: уже отданный в очередь текст не редактируется.

2.7 Кто именно уходит в эфир (TRX, TUNE)

arg1 у этих двух команд — номер передатчика, и он не декорация. Клиент живёт на своём приёмнике: скиммер на пане 2, цифра на пане 1, логгер на главном. Нажимая передачу, он просит эфир своему слайсу — а не тому, который оператор выбрал мышкой минуту назад. Пока номер игнорировался, trx:2,true уводил в эфир чужой слайс: другая частота, а с кросс-бандовым мультислайс-TX — и другой диапазон, то есть чужие антенна и фильтры. Клиент об этом не узнавал никак (ответ всегда говорил trx:0,…), а оператор видел передачу там, где её никто не просил.

Как устроено теперь:

  • номер разбирается и проверяется, как у всех остальных команд: не число или вне TRX_COUNTtci_error … bad receiver; номер в потолке, но пана под ним нет — receiver is not running. Свести такую просьбу к главному VFO нельзя: молча передать не на той частоте хуже, чем отказать;
  • приёмник 0 — это «радио целиком», то есть главный VFO. Ведём себя как обычный CAT-порт: в эфир уходит выбранный оператором TX-источник;
  • приёмник N > 0 адресует слайс слота N−1, и заявка идёт через TRadioController.RequestSliceTx — ту же дверь, что у CAT-порта слайса (см. doc/CAT_SLICES.md). Оттуда бесплатно достаются оба правила: «в эфире одновременно только один» (чужую передачу не перехватываем) и уважение к флагу Auto TX (без него слайс сам себя источником не назначает);
  • TUNE ходит той же дорогой — RequestSliceTx(…, Tune := True): несущая настройки обязана вставать на тот же слайс, что и модуляция;
  • ответ автору называет ЕГО номер приёмника, а состояние в нём — «в эфире именно твой слайс» (FTransmitting and (TxRx = Rx)). Так надо потому, что клиенты фильтруют входящие строки по arg1: MSHV, например, отбрасывает всё, что адресовано не его приёмнику (network.cpp: if (ls2.at(0)!=tci_trx) continue;), — и отказ, названный чужим номером, до него бы просто не дошёл. В рассылку уходит номер того, чей слайс сейчас источник передачи (TxRx по TxSliceId): клиент на приёмнике 1 по ней видит, что в эфире не он. Смена TX-источника оператором доходит до клиентов сама: SetTxSlice заканчивается Changed(rfTransmitting);
  • ключ захвата (§3.5) — один на передатчик, TRX/0/0: за него спорят и TRX, и TUNE, и изменения от оператора (rfTransmitting, rfTuning). Отдельный ключ TUNE делал защиту дырявой — TUN поверх чужого MOX не спорил ни с кем;
  • чужую передачу не трогаем вовсе. Пока передатчик занят (оператор, другой клиент, аппаратная PTT), TRX:…,true и TUNE:…,true не делают ничего — то же правило, что внутри RequestSliceTx, только теперь оно работает и для приёмника 0. Дырок было две: SetMOX(True) не выходит рано на уже идущей передаче — он заново выбирает источник модуляции, и trx:0,true,tci посреди передачи оператора уводил её в ринг TCI (а уход клиента оставлял в эфире несущую с тишиной: хозяином он при этом не становился, снимать было некому); tune:0,true таким же образом подмешивал настроечный тон в чужую передачу и оставался в эфире после ухода;
  • хозяином эфира (FTrxOwner, чей уход эфир снимает) клиент записывается, только если передача началась именно от его команды. Знает об этом лишь сам Sync-метод — он один видит состояние передатчика до и после, на потоке контроллера. Снимок «шла ли передача», взятый в потоке клиента до Invoke, врал бы: между разбором команды и её исполнением оператор успевает нажать PTT, и хозяином стал бы не тот. Поэтому решение принимается внутри, а наружу возвращается один флаг (FsRes, под тем же FLock, что и аргументы);
  • по той же причине отказ откатывает TCIMicRequested: иначе следующая PTT оператора ушла бы в эфир с микрофоном ушедшего клиента.

Попутно на этой дороге нашлась Access violation, не связанная с TCI: SendDUCSpecificFromSettings трогала FNetwork без Assigned, а зовут её по любому PTT/TUN (SetMOXSyncCWKeyer → она), в том числе до подключения устройства. В GUI падение маскировалось, у TCI ошибку глотал обработчик команды — и «передача» жила только в ответе клиенту. Теперь там обычная проверка, как в остальных двенадцати местах контроллера.


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)

Почему именно заглушки, а не «сделать по-быстрому». Правило одно: TCI не заводит в радио состояние, которого оператор не видит на экране и не может снять. Клиент подключается и отключается когда ему вздумается, а оставленный им перекос остаётся в аппарате навсегда — искать его будет человек, у которого нет ни кнопки, ни индикации. Поэтому настоящими сделаны ровно те параметры, у которых в интерфейсе есть орган управления (NR/NB/ANF, АРУ, фильтр, громкость, squelch, скорость CW), а остальные приняты, сохранены и разосланы ради честной синхронизации клиентов между собой — и только.

Дальше — построчно, потому что причины у них разные:

  • RIT/XIT. Расстройка — не кнопка, а сквозной сдвиг: VFO → DDC/DUC, панорама, флаг слайса, CAT, каналы памяти. Сделанная только ради TCI, она сразу разошлась бы с тем, что показывает экран и что отвечает CAT-логгеру, — и это худший вид ошибки в аппарате: слышно одно, написано другое. Заводить надо целиком, как фичу радио, тогда TCI получит её даром.
  • RX_BIN_ENABLE, RX_BALANCE. Единственные два, где тракт-то есть: SetRXAPanelBinaural и SetRXAPanelPan в биндингах WDSP лежат готовые. Но в EWSDR нет ни органа управления, ни поля в профиле, ни персиста, — включённое клиентом псевдостерео или уехавший баланс оператор не найдёт чем вернуть. Порядок обратный: сперва UI, потом TCI.
  • RX_ANC_ENABLE, RX_APF_ENABLE, RX_DSE_ENABLE, RX_NF_ENABLE. Этих узлов в тракте нет физически. У нас NR/NR2 (EMNR), NB/NB2 (ANB/SNBA) и ANF — они в TCI настоящие. ANC и DSE — блоки собственной обработки Expert, не WDSP; APF и NF были бы новыми узлами в цепочке.
  • RX_NB_PARAM. Пороги шумодава у нас — подобранные константы FILTER_NB_*, одинаковые для всех каналов и не выведенные даже в интерфейс. Клиент, покрутивший их по TCI, оставил бы шумодав расстроенным насовсем.
  • DIGL_OFFSET, DIGU_OFFSET. DIGU/DIGL у нас — обычные SSB-моды со своим фильтром, отдельного смещения в тракте нет. Введённое ради одной команды, оно опять развело бы частоту на панораме с той, что слышно.

3.1 Ограничения, о которых честнее знать заранее

Что Как ведёт себя
AGC_GAIN у приёмника > 0 AGC-T в ewsdr один на приёмный тракт, у слайса своего нет. Команда от имени доп. приёмника игнорируется (раньше молча правила главный), в ответ уходит текущее значение
DDS у приёмника-слайса только читается (центр его панорамы): панорама под слайсом общая, и увести её по просьбе одного клиента значит утащить соседей по пану и картинку оператора. Слайсу двигаться незачем — за окном DDC следит TuneSliceInBand
Панорама без слайсов в TCI не видна вовсе: приёмник = слайс. Слушать там нечего, но и IQ такой панорамы клиенту недоступен
Цвет спота (SPOT, arg4 ARGB) не читается: TDXSpot цвета не хранит, подписи красятся по моде/возрасту
KEYER, TX_FOOTSWITCH не реализованы, причины разные — см. ниже под таблицей
Канал B есть только у приёмника 0 (VFO B). У приёмника-слайса канал один: слайс — это и есть «приёмник» целиком, со своими модой, фильтром, АРУ, шумодавами и потоками
Захват параметра (§3.5) реализован для того, что клиенты действительно перетягивают (частота, DDS, мода, фильтр, TRX/TUNE/DRIVE, split, громкости, АРУ, шумодавы, squelch, скорость CW). Эхо-параметры (RIT/XIT, BIN/ANC/…) не захватываются: на радио они не влияют
Браузерные клиенты отвергаются по Origin (403), см. §1.1. Web-интерфейсу ewsdr TCI не нужен — у него свой канал
TRX_COUNT равен потолку 1 + MAX_SLICES = 7, а не числу живых приёмников: протокол объявляет его один раз. Про несуществующий приёмник просто ничего не шлётся (§2.1)
Потоки на передаче RX-аудио и линейный выход на TX замолкают — движок не зовёт аудио-колбэки, пока идёт передача (кроме дуплекса с самоконтролем). То же и с IQ: RX-пакеты на TX дропаются на входе DSP. Это поведение приёмного тракта, а не потоков
MUTE и линейный выход глушит и поток: движок под мьютом не зовёт OnAudio. Аудиопоток приёмника (AUDIO_START) мьют не трогает — он снимается до громкости
DIGL/DIGU, 2 канала по §3.4 в цифровых модах два канала должны нести комплексный сигнал; у нас это обычное стерео с выхода WDSP — оба тапа стоят там, где сигнал уже вещественный: rakDemod сразу за демодулятором, rakLineOut за громкостью. Комплексный выход потребовал бы отдельной ветки панели RXA и третьего маршрута тапа, а цифровые клиенты берут IQ_START — он честно комплексный
Поток канала B не бывает: в протоколе аудиопоток один на приёмник, и он всегда про канал A
Формат IQ всегда float32, два канала — AUDIO_STREAM_SAMPLE_TYPE относится к аудио (§4.3), а ExpertSDR3 IQ иначе и не шлёт
IQ_SAMPLERATE 384 кГц на Pluto 576/960 кГц нацело не делится, поэтому уходит 192 кГц (см. §2.5). Клиент обязан читать частоту из заголовка блока, а не считать её равной запрошенной
MP3 у рекордера не поддержан: кодера в проекте нет, а тащить внешний (lame) ради рекордера — это новая зависимость и её лицензия в сборке, которых у ewsdr сейчас нигде нет. WAV пишется без потерь и открывается всем; на .mp3 уходит честный tci_error, а не молчаливый WAV с чужим расширением

KEYER и TX_FOOTSWITCH — почему их нет. Команды противоположные по направлению, и мешать их в один пункт «не сделано» неправильно.

KEYER шлёт клиент: это его «клоподав», прокинутый к нам, и третий аргумент — длительность предыдущего знака — существует ровно затем, чтобы телеграфное ядро воспроизвело тайминг точно. У нас несущую в CW даёт либо прошивка (бит CWX / аппаратный кейер), либо программный кейер для Pluto, и оба берут фронты как пришли. Прокинуть в них фронты из WebSocket значит отправить в эфир сетевой джиттер, а arg3 подставить некуда: команды «сыграй знак длиной 142 мс» у openHPSDR нет. Точка входа готова (CWXKeyEvent), но делать это надо вместе с очередью знаков по arg3 — и после того, как весь передающий тракт TCI пройдёт живого клиента (он ещё не проверялся).

TX_FOOTSWITCH шлёт сервер, и это просто не выведено: флаг у контроллера уже есть — HPS_PTT из HP-статуса ловится по фронту (FHPpHWPTTFHWPTTActive), — но события наружу нет. Плюс две оговорки, из-за которых уведомление было бы полуправдой: бит не отличает педаль от тангенты микрофона, а у Pluto такой линии нет вовсе. Работы тут — новый rf-эвент контроллера и строчка в адаптере.

Отдельно: у 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 как реальное создание/удаление второго слайса пана. Сейчас это эхо, и причин две. Первая техническая: слайс заводит MainForm (AddSliceAtFreq + CreateSliceFlag — флаг, раскладка, персист), у контроллера есть только RemoveSlice. Адаптер TCI, как и CAT, до MainForm не дотягивается и не имеет права: он обязан собираться в демоне, где LCL нет вовсе. Вторая по существу: канал B у ExpertSDR3 — второй приёмник внутри того же DDC, а у нас слайс — полноценная сущность со своим флагом, звуком, входом и CAT-портом. Клиент, «выключивший канал B», снёс бы оператору рабочий слайс вместе с его модой, фильтром и портом. Правильный порядок: сперва завести создание слайса в контроллер (от этого выиграет и демон), и только потом привязать к нему команду.
  2. KEYER — см. разбор под таблицей §3.1: нужна очередь знаков по arg3, а не проброс сетевых фронтов в ключ.
  3. TCI в демоне. Юниты LCL-free (стенд собирает и гоняет их вместе с TRadioController без единого виджета), подключается одной строкой в ewsdrd.lpr, как web. Не сделано намеренно и в этом порядке: в headless сперва попадает то, что уже прошло живого клиента в GUI, где отказ виден глазами. Цена ошибки в демоне выше — грабля с SIGPIPE (см. конец §5) убивала именно ewsdrd, целиком и молча.
  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 девятому; освобождение слотов после отключения — обоими путями, и штатным 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 (бинарные потоки) — 158 проверок, все зелёные

Отдельная программа (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 и длину. ★Память: START не выделяет ни байта, бюджет растёт кусками по мере звука и возвращается по Free, потолок берётся целиком и сверх него следует отказ, при отказе запись не рушится, окно истекает по часам без единого Feed и отдаёт память само, Take отдаёт резерв размером с копию и после него занят ровно он, а писатель возвращает этот резерв по окончании записи. ★Имя файла: простое имя ложится в каталог записей, а каталог из просьбы отбрасывается — абсолютный путь, .. и буква диска наружу не выводят; пусто, .., не-.wav, управляющий символ и отсутствие каталога записей дают отказ; существующий файл писатель не перезаписывает.
  • Команды на живом сервере (настоящий TRadioController, WS-клиент на сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент, отказ на старт потока с незапущенного пана, подтверждение и отбраковка параметров, SAVE без записи и SAVE в .mp3 отвечают ошибкой, LINE_OUT_RECORDER_START на мёртвом приёмнике и с чужим номером отвечает ошибкой и не стоит памяти, SAVE с чужим путём отбивается по имени; запись, начатая ушедшим клиентом, освобождается вместе с ним (видно насквозь по общему бюджету в части E); TRX:0,true,tci без аудиопотока модуляцию не берёт, а с потоком берёт, реально поднимает передачу и снимает её по TRX:0,false и по уходу клиента; чужой бинарный блок не рвёт соединение; без передачи маркеров TX_CHRONO нет. Номер передатчика (§2.7): trx:9, trx:abc и tune:9 отвечают ошибкой и не поднимают эфир, приёмник без живого пана — тоже; ответ называет передающий приёмник; TUNE через TCI включается и гасится, не оставляя за собой ни тона, ни поднятой PTT. Чужая передача: поверх MOX оператора TRX не уводит микрофон и не снимает передачу, TUNE не включает тон, а уход такого клиента передачу оператора не гасит. Конкурирующий TCI-клиент не получает TX_CHRONO чужой передачи, а его уход не снимает эфир первого клиента. Отдельно — согласование частот: на источнике 192 кГц просьба «384» подтверждается как 192, смена rate устройства сама переобъявляет и iq_samplerate, и if_limits, а на 576 кГц (Pluto) та же просьба даёт законные 192 кГц.
  • Слайс как приёмник (часть E, живой движок): созданный на ГЛАВНОМ пане слайс становится приёмником 1, отвечает на vfo:1,0; своей частотой (это и есть вся инициализация MSHV), слушается командой vfo:1,0,<Гц>, отдаёт своё аудио блоками с receiver = 1, а после удаления слайса приёмник 1 замолкает целиком — вместо прежнего vfo:1,0,0.
  • Сквозной прогон через живой 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. Это осознанно — дубли идемпотентны, а рассылка нужна для тех случаев, когда значение поменял не клиент, а оператор.