Files
ewsdr/doc/TCI.md
T
ew8bakandClaude Opus 5 7aae0fdfcd fix(tci): SAVE отдаёт писателю сами куски записи, а не сплошную копию
Потолок 128 МБ всё ещё пробивался примерно вдвое на пике: Take собирал
линейную копию ДО освобождения FChunks, поэтому при полном бюджете рядом
жили ~128 МБ кусков и ~128 МБ копии, а счётчик показывал 128. Передача
резерва писателю (9b77809) закрывала учёт, но не сам пик.

Копии больше нет вовсе. Take отдаёт куски КАК ЕСТЬ — новый TTCIRecTake
(Chunks + Chunk + Count + Reserved), — и вместе с ними уезжает их место в
бюджете целиком. TTCIWavWriter пишет куски в файл подряд: в WAV сэмплы и
так лежат встык, а хвост последнего куска за Count просто не наш. Место
отпускается в ReleaseData, ровно один раз на любом пути выхода Execute.

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

Стенд 199/199: 2.5 с записи отдаются ТРЕМЯ кусками по секунде (свёрнутая
копия дала бы один), счёт бюджета при Take не меняется, резерва хватает
на отданные куски, склеенные куски дают непрерывный звук, писатель
возвращает резерв по окончании. Проверено, что копирующая реализация Take
краснит четыре проверки. GUI (--ws=qt6) и демон зелёные.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 13:18:20 +03:00

94 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 отдаёт писателю САМИ КУСКИ (TTCIRecTake) вместе с их местом в бюджете, а не сплошную копию. Копия была бы худшим из вариантов ровно там, где памяти меньше всего: при полном бюджете рядом жили бы 128 МБ кусков и 128 МБ копии, а счётчик показывал бы 128. Куски и так лежат встык, поэтому TTCIWavWriter пишет их в файл подряд (хвост последнего за Count — не данные), а место отпускает в ReleaseData — ровно один раз, сколько бы путей выхода ни было у Execute. Пока писатель ждёт медленный или зависший сетевой каталог, эти байты остаются занятыми: потолок держит и очередь сохранений, а не только сами записи.
  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 отдаёт САМИ КУСКИ (2.5 с записи = три куска по секунде, а не один свёрнутый — сплошной копии не появляется ни на миг), счёт бюджета при этом не меняется, склеенные куски дают непрерывный звук, и писатель возвращает резерв по окончании записи. ★Имя файла: простое имя ложится в каталог записей, а каталог из просьбы отбрасывается — абсолютный путь, .. и буква диска наружу не выводят; пусто, .., не-.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. Это осознанно — дубли идемпотентны, а рассылка нужна для тех случаев, когда значение поменял не клиент, а оператор.