Files
ewsdr/doc/TCI.md
T
ew8bakandClaude Opus 5 dc6f996e70 fix(tci): дефекты живого прогона — length аудио, маршруты тапов, MOX, рекордер, EOF сокета
Восемь дефектов, найденных прогоном настоящего TCI-клиента (три приёмника:
NFM, DIGU, FMRAW) и его отчётом.

1. UI доп. панорам не перерисовывался: rfSliceState рассылался, но ветки в
   MainForm.OnControllerState не было (частоту несёт отдельный rfSliceFreq).
2. Stream.length у аудио — сэмплы НА КАНАЛ (§4.3), у IQ — вещественные
   отсчёты (§3.4: комплексных = length/channels). Было ×каналы везде, у
   стерео получалось вдвое больше. Развилка в TCIFillHeader + разбор
   TX-аудио в HandleBinary.
3+4. Дыры в маршрутах аудио движка: demod-тап звался только для DMR/FMRAW
   (у DIGU не было RX_AUDIO), а пост-громкостный — только для нецифровых
   (у FMRAW не было LINEOUT). Плюс мьют слайса больше не убивает RX_AUDIO:
   движку сообщают SetAudioTapsActive.
5. Клиент, поставивший TRX, уходил — MOX оставался. FTrxOwner + StopTxOf;
   TCIMicRequested снимается и по окончании любой передачи.
6. Гонка снятия IQ-тапа: SetIQTap(nil) возвращался раньше, чем DSP-поток
   выходил из вызова. FIQTapLock (порядок FSliceLock → FIQTapLock).
7. Рекордер был кольцом «последние N секунд», а §4.3 говорит про
   МАКСИМАЛЬНОЕ время записи с удалением по истечении. Переделан в линейный
   буфер с окном по часам от START.
8. TCIServer.HandleClient считал recv = 0 таймаутом: ноль — это EOF, errno
   при нём не трогается и несёт EAGAIN от прошлого истёкшего TCI_POLL_MS.
   Обычный TCP-разрыв без close-кадра не освобождал слот до остановки
   сервера, и после нескольких аварийных отключений новые клиенты упирались
   в TCI_MAX_CLIENTS. Теперь R = 0 рвёт связь безусловно, errno спрашивается
   только при R < 0.

Попутно: MainForm.RecreateDSPEngine (смена sample rate до START) терял
внутренние колбэки контроллера — введён AttachEngineCallbacks.

Стенд test/tci заведён в репозиторий (run.sh, 126/126 зелёных, включая
сквозной прогон через живой WDSP), доп. проверки на оба пути отключения
клиента. doc/TCI.md приведена в соответствие: правило про recv = 0 в §1.1,
единицы Stream.length, линейный буфер рекордера.

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

691 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TCI в EWSDR — статус реализации
Ветка разработки: `feature/tci-protocol`.
Дата последнего обновления: 2026-08-18.
Эталон протокола — «Протокол TCI, версия 2.0» Expert Electronics
(`doc/TCI Protocol_RU.pdf`, 12 января 2024). EWSDR выступает **сервером**
(как ExpertSDR3): порт слушаем мы, клиенты — логгеры, скиммеры, программы
цифровых видов, внешние усилители и коммутаторы.
---
## 1. Архитектура
```
TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ──► TRadioController
логгер/скиммер/цифра транспорт команды + ядро
уведомления
```
| Файл | Назначение |
|---|---|
| `TCIProtocol.pas` (~380 строк) | Чистый слой протокола: разбор `имя:арг1,арг2;`, сборка строк, экранирование `^ ~ *`, словарь видов связи, пересчёт громкости/порога в дБ. Зависит только от RTL + `RadioModes`. |
| `TCIServer.pas` (~1250 строк) | WebSocket-сервер: accept-поток, поток на клиента, HTTP-Upgrade, разбор фреймов, рассылка, тик 20 мс. Сокеты и фреймы переиспользованы из веб-подсистемы (`WebUtils`, `WsClient`). |
| `TCIAdapter.pas` (~2600 строк) | Мост к `TRadioController`: реализация команд, пачка инициализации, уведомления об изменениях состояния, измерители, захват параметров (§3.5), подключение потоков к тапам аудио/IQ. |
| `TCIStreams.pas` (~600 строк) | Бинарные потоки (§3.4): дециматор/интерполятор, нарезка блоков с заголовком, буфер записи линейного выхода и писатель WAV. Зависит только от RTL + `TCIProtocol`/`TCIServer`. |
Принципы те же, что у CAT (см. `doc/CAT_STATUS.md`):
- **TCI — равноправный клиент контроллера.** Никаких обращений к `MainForm`;
единственное исключение — стор спотов `TDXSpotStore`, который передаётся
адаптеру ссылкой (команды `SPOT`/`SPOT_DELETE`/`SPOT_CLEAR` кладут споты в
ту же базу, что и DX-кластер).
- **Потоки.** Геттеры читают поля контроллера напрямую из потока клиента;
сеттеры пишут параметр в scratch-поля под `FLock` и зовут
`FController.Invoke(SyncXxx)` — исполнение в потоке контроллера
(GUI = `TThread.Synchronize`).
- **Железо сетевые потоки не читают вовсе.** `BackendCaps` и
`BoardDisplayName` смотрят в `FNetwork`, а его UI освобождает при смене типа
устройства — обращение туда из потока клиента (да ещё и с копированием
строки имени платы) означало бы чтение освобождённой памяти прямо во время
подключения TCI-клиента. Поэтому адаптер держит снимок `TTCIDevSnap` (имя
платы, границы настройки, число панов, наличие TX, sample rate), который
обновляет **поток контроллера** (`RefreshDev`: конструктор, `ApplySettings`,
`OnState` на `rfDevice`/`rfConnected`/`rfDeviceList`/`rfXvtr`/`rfBand`/
`rfSampleRate`). Строка имени принадлежит адаптеру и присваивается только под
`FDevLock`. По той же причине `TRadioController.ActiveTXFreqHz` перешёл на
`GetSliceView`: он тоже вызывается снаружи и раньше копировал `TCtrlSlice`.
- **Слайсы сетевые потоки не читают вовсе.** Снимок таблицы
(`RefreshSlices``FSliceSnap`) делает **поток контроллера**: в конструкторе
адаптера, в `ApplySettings` и в `OnState` на каждое событие, которое слайсов
касается (`rfSliceFreq`, `rfSliceState`, `rfDevice`, `rfPanFreq`,
`rfSampleRate`, `rfBand`, `rfXvtr`, `rfCenterFreq`) — там писателей нет, и
каждая запись снимается целиком. Команды и измерители читают уже снимок под
`FSliceLock`. Прямое чтение `FSlices` из чужого потока давало не только порчу
managed-строк (`DevName`/`InDevName`), но и смесь полей одного слайса:
частота новая, мода ещё старая. Единственная оставшаяся цена — снимок может
отставать на такт. `Sync`-методы (они уже в потоке контроллера) читают живую
таблицу: команда на установку обязана попасть в тот слайс, который есть
сейчас.
- **Синхронизация клиентов.** Изменение любого поля контроллера приходит в
`OnState` (multicast-подписка `AddStateListener`) и рассылается всем
подключённым — как того требует §3.5 спецификации. Отвечающий на команду
клиент дополнительно получает прямой ответ.
### 1.1 Правила, на которых держится транспорт
Всё это не украшения, а лечение конкретных отказов — менять с оглядкой.
1. **Отправка никогда не блокирует ни вызывающего, ни соседей.**
`Send`/`Broadcast` кладут строку в очередь клиента (микросекунды под его
локом), а в сокет её пишет **собственный поток клиента**: его `recv`
просыпается каждые `TCI_POLL_MS` (20 мс) и сливает очередь. Уведомления
рождаются внутри `Changed()` контроллера, то есть в UI-потоке — писать
оттуда прямо в сокет означало бы отдать интерфейс во власть самого
медленного клиента. Общий поток отправки был лишь половиной решения: один
`SockSend` ждёт до `TCI_SEND_TIMEOUT` (300 мс), и восемь клиентов давали
секунды задержки всем остальным. Теперь медленный клиент задерживает только
себя; переполнилась его очередь (`TCI_OUT_MAX`) или не прошла запись — он
выбрасывается.
2. **Объект клиента освобождает только тик-поток** (`ReapClients`) и только
после того, как клиентский поток честно вышел. Поэтому указатель, взятый
кем угодно под `FClientLock`, гарантированно жив внутри лока. Единственное
исключение — `Stop`, но он к этому моменту уже дождался всех потоков. И в
том и в другом случае наверх уходит `OnDisconnect` (единая точка
`Disconnected`): иначе после остановки у адаптера оставались висеть захваты
параметров ушедших клиентов.
3. **`Stop` ждёт выхода клиентских потоков без таймаута** и прокачивает при
этом очередь `Synchronize`. Останавливает сервер поток контроллера (UI), а
клиентский поток в этот момент может висеть как раз на `Invoke` в него же:
без прокачки это взаимный клин. Выйти по таймауту нельзя — следом
освобождаются и клиенты, и сам сервер с адаптером, а не вышедший поток
вернулся бы в эту память. Поэтому: `Stopping` (адаптер новых `Invoke` не
начинает) + закрытые сокеты + повторный `shutdown` раз в полсекунды, и
ожидание гарантированно конечно.
4. **Слот протокола выдаётся только после handshake.** Соединение до
`Upgrade` живёт в общем массиве (`TCI_MAX_SOCKETS` = 32) и обязано
уложиться в `TCI_HANDSHAKE_MS` (5 с), иначе закрывается по таймауту сокета;
восемь слотов `TCI_MAX_CLIENTS` считаются только среди поднявшихся. Раньше
восемь молчащих TCP-соединений навсегда закрывали дверь настоящим клиентам.
5. **`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` нет ни одного ожидания: блок уходит в
кольцо клиента (микросекунды под его локом), а в сокет его пишет, как и
команды, поток самого клиента. Порядок локов везде один: `FSliceLock`
`FStreamLock` (тап сначала выясняет, чей это слайс, и только потом ищет
подписчиков) — обратный дал бы клин с потоком контроллера. Тапы навешивает
и снимает **только поток контроллера**: снятие обязано дождаться выхода
DSP-потока из вызова, иначе тот позвал бы метод освобождённого адаптера.
Разбор HTTP — построчный (`TCIHttpHeader`), а не поиском подстроки
«`upgrade: websocket`»: заголовок с табуляцией или без пробела после
двоеточия валиден. Close-кадр подтверждается ответным close с тем же кодом
(RFC 6455 §5.5.1); полезная нагрузка close длиной ровно один байт невалидна и
рвёт соединение. Текстовые сообщения проверяются на UTF-8 (`TCIValidUTF8`,
§5.6): обрыв последовательности, избыточно длинная форма, суррогаты и всё
выше U+10FFFF закрывают соединение, а не уходят в разбор команд.
### 1.2 Настройки
Секция `tci` в корне `settings.json`:
```json
"tci": { "enabled": false, "port": 40001, "bind_addr": "127.0.0.1" }
```
UI — вкладка **CAT → TCI Server**, справа от «TCP CAT Server» (галка, порт,
интерфейс): TCI — такой же канал внешнего управления трансивером, что и CAT,
и оператор ищет его там, а не в «Advanced». В протоколе
**нет авторизации**: открытый наружу порт означает полный доступ к трансиверу,
поэтому умолчание слушает только петлю.
Отсюда же правила вокруг адреса:
- разбор `bind_addr` строгий (ровно четыре октета 0..255); всё непонятное —
отказ поднимать сервер, а не молчаливый `0.0.0.0`. Пустая строка и явный
`0.0.0.0` — единственные способы попросить «все интерфейсы»;
- порт и адрес применяются по уходу фокуса из поля и по кнопке Close, а не на
каждое нажатие клавиши: иначе набор `127.0.0.1` по дороге проходил бы через
«`127.0.0.`» и сервер успевал перезапуститься на всех интерфейсах.
**Сначала применяем, потом сохраняем.** `ApplySettings` проверяет новую
конфигурацию ДО остановки работающего сервера, а если новый слушатель не
поднялся (порт занят) — возвращает прежний. `MainForm.ApplyTCISettings` пишет
`settings.json` только после успеха и на отказе возвращает поля окна к тому,
что реально работает. Иначе занятый порт оставлял оператора вообще без TCI, да
ещё и с нерабочей конфигурацией на следующий запуск.
### 1.3 Маппинг модели
| TCI | EWSDR |
|---|---|
| приёмник (`TRX_COUNT`) | панадаптер: 0 = главный тракт, 1.. = доп. DDC-паны. Число = `BackendCaps.MaxPans` |
| канал A/B (`CHANNEL_COUNT` = 2) | у приёмника 0 — VFO A / VFO B; у панов 1.. — первый и второй слайс пана |
| `DDS` | центр панадаптера (`SetCenter` / `SetPanDDCFreq`) |
| `IF` | смещение канала от центра панорамы |
| `VFO` | абсолютная частота канала |
| передатчик (`arg1` у TRX/TUNE/DRIVE) | всегда один — главный тракт |
---
## 2. Что реализовано
### 2.1 Инициализация (§4.1)
`PROTOCOL`, `DEVICE`, `RECEIVE_ONLY`, `TRX_COUNT`, `CHANNEL_COUNT`,
`VFO_LIMITS`, `IF_LIMITS`, `MODULATIONS_LIST`, `READY`.
`READY` шлётся **после** полного дампа состояния: клиент, дождавшийся его,
уже знает всё. `IF_LIMITS` = ±sample rate/2, пересылается при смене частоты
дискретизации.
`VFO_LIMITS` **переобъявляются на лету**: при `rfDevice`/`rfXvtr`/`rfBand`
адаптер пересчитывает границы и, если они изменились, рассылает `vfo_limits`
заново (дедуп по последнему известному значению — `rfDevice` приходит и на
создание слайса). Кэш границ ведётся **независимо от того, есть ли клиенты**:
иначе радио, отвалившееся в момент, когда не подключён никто, осталось бы для
кэша незамеченным, и после его возвращения рассылка подавилась бы — новый
клиент навсегда остался бы с запасным диапазоном из своей пачки инициализации. Перезапросить границы клиент не может, а сценарий обычный: логгер
подключился до радио и получил запасные 10 кГц…30 МГц, потом появился Pluto
или включился трансвертер — и его представление о пределах устарело, хотя
команды уже отбраковываются по новым.
`VFO_LIMITS` и проверка частоты в командах берутся из **одного** источника —
`FreqLimits` поверх `VisibleFreqBounds`: под трансвертером это диапазон его
слота, иначе пределы устройства, а пока устройства нет — объявленный запасной
диапазон 10 кГц…30 МГц. Раньше источников было два, и они расходились: клиенту
объявлялись пределы АЦП, а команда под трансвертером принимала любое число.
Для `DDS` это опаснее, чем для `VFO`: `SetCenter`/`SetPanDDCFreq` ничего не
клампят и отдают частоту прямо в backend.
В дампе состояния есть и то, что иначе клиент не получил бы часами:
`TX_FREQUENCY` (уведомление шлётся по изменению, а на стабильном радио его нет
— особенно важно при split и TX-слайсе), текущий `APP_FOCUS` и `VFO_LOCK` на
каждый канал.
**У живого пана канал A есть всегда.** Выключить канал A в TCI нечем — он
существует по определению, поэтому пан без слайсов показывает канал A на своём
центре (`DDS`), а не хранит частоту удалённого слайса. Команды на такой канал
игнорируются: слайса под ним нет. Как только слайс появится, канал станет
настоящим.
**Приёмники, которых ещё нет, молчат.** `TRX_COUNT` объявляет потолок железа
(`BackendCaps.MaxPans`) один раз и навсегда, а пан из этого потолка может быть
не создан. Для несуществующего пана не шлётся ничего (раньше уходили
`dds`/`vfo`/`if` с нулём, и клиент принимал ноль за настоящую частоту);
состояние приходит, когда пан появится — с `rfPanFreq`/`rfSliceState`. У
существующего пана без слайсов есть только `DDS`. Показания измерителей для
таких приёмников тоже не отправляются.
Список видов связи: `am,sam,dsb,lsb,usb,cw,nfm,wfm,digl,digu,dmr,fmraw`.
`dmr`/`fmraw` — наше расширение (протокол расширяемый, §1.4). `CWL`/`CWU`
схлопываются в `cw`; при установке `cw` боковая сохраняется, если уже
телеграф, иначе выбирается по частоте (ниже 10 МГц — CWL).
### 2.2 Двунаправленное управление (§4.2)
Общее для всех установок:
- **аргумент разбирается строго** (`TCITryArgInt/Float/Bool`). Не разобрался —
параметр не трогаем и отвечаем текущим значением. Раньше `vfo:0,0,abc;`
превращалось в честный ноль и уводило приёмник на 0 Гц, а любой мусор в
Boolean-командах читался как `false`;
- **частота проверяется дважды**. В потоке клиента — `FreqSane` по тем же
границам, что объявлены в `VFO_LIMITS` (см. §2.1): ноль и отрицательные —
отказ. И ещё раз в потоке контроллера, уже по живым границам (`FreqSaneLive`
в `SyncSetVfo`/`SyncSetCenter`): между разбором команды и её исполнением
оператор успевает сменить устройство, и снимок, по которому частоту
пропустили, описывает уже не то радио, куда она уедет. Ни `SetVfoA`, ни
`SetCenter`, ни `SetPanDDCFreq` границ не клампят — число уходит прямо в
backend. Кромки фильтра обязаны разбираться обе и идти по возрастанию;
- **слайс перестраивается только `TuneSliceInBand`** — тем же путём, что у
CAT-порта слайса: внутри включённого диапазона он ходит свободно, за
захваченную полосу окно DDC переедет само, а за границы диапазона команда
отбрасывается. Прямой `SetSliceTarget` (как было) уводил слайс куда угодно, и
для TX-слайса эта частота попадала прямо в DUC — то есть в эфир на чужом
диапазоне, без переключения антенн и фильтров. Смена диапазона остаётся
решением оператора, а не управляющего ПО;
- **параметр захватывается на 200 мс** (§3.5, `Claim`). Пока клиент крутит
частоту, второй логгер её не перебьёт; изменение от оператора захватывает
параметр так же, но у клиента, который им прямо сейчас управляет, не
отбирает. Захваты клиента снимаются при его отключении.
Полностью проведено в контроллер:
| Команда | Куда легло |
|---|---|
| `START` / `STOP` | `SetRun` |
| `DDS` | `SetCenter` / `SetPanDDCFreq` |
| `IF`, `VFO` | `SetVfoA/B`, `TuneSliceInBand` |
| `MODULATION` | `SetMode` / `SetSliceMode` |
| `TRX` | `SetMOX` |
| `TUNE` | `SetTune` |
| `DRIVE` | `SetDrive` |
| `TUNE_DRIVE` | `FTXSettings.TUNLevel` через `SetTXSettings` |
| `SPLIT_ENABLE` | `SetSplit` |
| `RX_FILTER_BAND` | `SetFilterEdges` / `SetSliceFilter` |
| `VOLUME`, `MUTE` | `SetRxVolume`, `SetMute` (дБ ↔ 0..100) |
| `RX_MUTE`, `RX_VOLUME` | громкость/мьют слайса |
| `MON_VOLUME`, `MON_ENABLE` | `SetTXMonVolume`, `SetRxMuteOnTx` |
Громкость приёма и громкость самоконтроля — **разные** величины, и в TCI это
разные команды. У слайдера программы они одна: `SetVolume` правит ту, что
сейчас звучит (на передаче в DUP с выключенным RX MUTE — монитор). Поэтому
`VOLUME` и `RX_VOLUME` ходят через адресный `SetRxVolume`: иначе команда,
пришедшая на передаче, уезжала бы в громкость монитора, а в ответ клиент
получал бы нетронутый `FVolume`. Обратный путь тоже разделён — у монитора
появилось своё событие `rfMonVolume`, раньше его правка рассылалась клиентам
как обычный `volume`.
| `AGC_MODE` | `off`→Off, `fast`→Fast, `normal`→Medium |
| `AGC_GAIN` | `SetAGCTop` (AGC-T) — **только приёмник 0**, см. §3 |
| `RX_NR_ENABLE`, `RX_NB_ENABLE`, `RX_ANF_ENABLE` | `SetNR/SetNB/SetANF`, для панов — `SetSliceDSP` |
| `LOCK` | `SetVfoLock` |
| `SQL_ENABLE`, `SQL_LEVEL` | FM-шумоподавитель; дБ (-140..0) ↔ порог 0..100 |
| `CW_MACROS_SPEED`, `CW_KEYER_SPEED` | `TCWSettings.Speed` |
| `CW_MACROS_DELAY` | `TCWSettings.RFDelayMS` |
### 2.3 Однонаправленное управление (§4.3)
`TX_ENABLE` (по `BackendCaps.HasTX` + `TXProhibited`), `CW_MACROS_SPEED_UP/DOWN`,
`SET_IN_FOCUS` (поднимает окно программы через `OnFocusRequest` — адаптер до
окна не дотягивается, действие ставит MainForm),
`SPOT`, `SPOT_DELETE`, `SPOT_CLEAR``TDXSpotStore`, спот виден на всех
панадаптерах; время спота — UTC, как у кластера, а не местное),
`RX_SENSORS_ENABLE`, `TX_SENSORS_ENABLE` (период — на клиента),
`IQ_SAMPLERATE`, `AUDIO_SAMPLERATE`, `AUDIO_STREAM_*`, `TX_STREAM_AUDIO_BUFFERING`
(значения принимаются и подтверждаются; сами потоки — этап 2). Параметры
потоков — настройки **клиента**, а не устройства: живут в `TTCIClient`, и один
клиент не переопределяет их остальным; подписки и параметры читаются/пишутся
под локом клиента, потому что пишет их его поток, а читает тик-поток.
Значения сверяются со списками протокола: `IQ_SAMPLERATE` — 48/96/192/384 кГц,
`AUDIO_SAMPLERATE` — 8/12/24/48 кГц, `AUDIO_STREAM_SAMPLE_TYPE`
int16/int24/int32/float32. Чужое значение не принимается, в ответе уходит
действующее.
Команды **запуска** потоков (`IQ_START`/`IQ_STOP`, `AUDIO_START`/`AUDIO_STOP`,
`LINE_OUT_*`) отвечают `tci_error:<команда>,binary streams are not implemented`.
Молчать нельзя: клиент решил бы, что поток пошёл, и ждал бы данных бесконечно.
### 2.4 Уведомления (§4.4, §4.5)
`RX_CHANNEL_SENSORS` + устаревшая `RX_SENSORS` (S-метр в дБм, период 30…1000 мс
на клиента), `TX_SENSORS`, `TX_FREQUENCY`, `VFO_LOCK`, `APP_FOCUS`
(активация/деактивация главного окна), `RX_CLICKED_ON_SPOT` + устаревшая
`CLICKED_ON_SPOT` (клик по подписи спота на любом панадаптере),
`CALLSIGN_SEND` (после `CW_MSG`).
**Пачка инициализации уходит одним куском.** Сервер зовёт `OnConnect` под
`FClientLock`, то есть рассылка ждёт, пока весь дамп не уложен в очередь
клиента. Без этого изменение, случившееся после строки снимка, но до
`Ready = True`, пропадало навсегда: `Broadcast` пропускает не-Ready клиента, а
тот считал инициализацию завершённой и оставался со старым значением. Отсюда
запрет для `HandleConnect`: никаких `Invoke` в поток контроллера — он сам может
стоять на этом локе внутри `Broadcast`.
**Создание и удаление слайса.** `AddSlice`/`RemoveSlice` шлют только `rfDevice`,
и по нему адаптер сравнивает расстановку до и после (`SliceMapSig`: id и пан
каждого слайса по порядку слотов). Изменилась — уходит полная картина каналов
каждого живого доп. приёмника (`PushChannelMap`). Иначе подключённый клиент не
узнавал ни о появлении канала, ни о его исчезновении, а при удалении первого
слайса второй молча становился каналом A. Сказать «приёмника больше нет» в
TCI 2.0 нечем — про канал B есть `RX_CHANNEL_ENABLE`, про сам приёмник ничего;
это ограничение протокола, а не наше упрощение.
**Глобальные величины рассылаются всем.** `TUNE_DRIVE`, `CW_MACROS_SPEED`,
`CW_MACROS_DELAY` и `SPLIT_ENABLE` — свойства радио, а не клиента, поэтому
команда отвечает `Broadcast`, а не `Reply` (автор входит в рассылку). Правки от
оператора приходят событиями: `rfTXProfile` для уровня TUN, `rfActiveVfo` для
split, `rfMonVolume` для громкости самоконтроля и новый `rfCWSettings`, который
теперь шлёт `SetCWSettings` — своего события у телеграфа не было вовсе, и
клиенты о смене скорости из окна настроек не узнавали.
Отдельная история — **доп. приёмники**. У главного тракта на каждое поле есть
своё `rfXxx`, а у слайсов не было ничего: правка слайса не доходила ни до UI,
ни до остальных клиентов. Поэтому в контроллере появилось `rfSliceState`
(полезная нагрузка — `FSliceFreqId`, как у `rfSliceFreq`), и его шлют сами
сеттеры слайса: `SetSliceMode`, `SetSliceFilter`, `SetSliceAGCMode`,
`SetSliceVolume`, `SetSliceMute`, `SetSliceDSP`, `SetSliceFMSquelch`.
Адаптер разворачивает Id обратно в пару (приёмник, канал) и рассылает
состояние именно этого канала.
Частоту слайса объявляет сам `SetSliceTarget`: `SliceFreqChanged` живёт
**внутри** него, а не у каждого вызывающего. Раньше об этом помнили CAT и TCI,
но не UI — перетаскивание флага мышью обновляло только свой пан, и TCI-клиенты
оставались на старой частоте. Дублирующие вызовы у вызывающих (в том числе
ручные `PushSliceFlagState`/`LayoutFlags` в MainForm) убраны: теперь один
путь на всех — мышь, колесо, CAT, TCI, бэнд-логика.
### 2.5 Бинарные потоки (§3.4)
Реализованы все четыре типа: `IQ_STREAM`, `RX_AUDIO_STREAM`, `LINEOUT_STREAM`
(наружу) и `TX_AUDIO_STREAM` + `TX_CHRONO` (внутрь), плюс запись линейного
выхода в файл. Команды: `IQ_START/STOP`, `AUDIO_START/STOP`,
`LINE_OUT_START/STOP`, `LINE_OUT_RECORDER_START/SAVE/BREAK` и параметры
`IQ_SAMPLERATE`, `AUDIO_SAMPLERATE`, `AUDIO_STREAM_SAMPLES/CHANNELS/SAMPLE_TYPE`,
`TX_STREAM_AUDIO_BUFFERING`.
**Откуда берётся звук.** Протокол различает «аудиопоток приёмника» и «поток
линейного выхода», и это не одно и то же:
| Поток TCI | Точка в ewsdr | Что это значит |
|---|---|---|
| `RX_AUDIO_STREAM` | `OnDemodAudioReady` (тап `rakDemod`) | выход демодулятора **до** громкости и мьюта, без сайдтона — то, что нужно скиммеру и цифре |
| `LINEOUT_STREAM` | `OnAudioReady` (тап `rakLineOut`) | ровно то, что слышно: после громкости, с сайдтоном; MUTE его глушит (движок не зовёт `OnAudio` под мьютом) |
| `IQ_STREAM` | `TWDSPEngine.PushIQItemToDSP` / `PushPanItemToDSP` | сырой IQ приёмника, один вызов тапа на накопленный блок |
Тап — многоадресный и **ничего не забирает**, в отличие от `OnAudioConsume`
(им владеет web-адаптер и им же глушит локальный звук). Иначе первый же
TCI-клиент отобрал бы звук у динамика и у браузера.
Приёмник в потоках — панадаптер, как и везде (§1.3). У доп. пана в потоках
участвует только **канал A** (первый слайс): «аудиопоток приёмника» в протоколе
один на приёмник, отдельного потока канала B нет.
**Пересчёт частоты вниз — многоступенчатый.** Аудио 48 кГц → 8/12/24 и IQ
до 48/96/192/384 кГц считает `TCIStreams`. Коэффициент раскладывается на
множители, и каждая ступень фильтрует своё: одной ступенью большие
коэффициенты не берутся, потому что длина FIR растёт вместе с коэффициентом и
упирается в потолок. ★Это не теория: на верхнем пресете Pluto (5760 кГц,
коэффициент 120 к 48 кГц) одноступенчатый дециматор давал **завал 1.3 дБ в
полосе и подавление зеркала всего 16 дБ**, то есть поток IQ с мусором.
Спецификацию фильтра каждой ступени задаёт **итоговая** полоса, а не её
собственная: ранняя ступень обязана убрать лишь те узкие зоны, которые в конце
сложатся в полезную полосу, поэтому её переходная полоса шире в десятки раз, а
фильтр во столько же короче. Свёртка идёт со сложением симметричных пар
(фильтр линейнофазовый). Замеры после переделки: −83 дБ по зеркалу на **любом**
коэффициенте, полоса ровная, цена ≈10% ядра на потоке 5.76 МГц (было 20% —
и с мусором) и ≈1% на 384 кГц.
Вверх (TX-аудио клиента → 48 кГц тракта) — линейная интерполяция: её образы
лежат за полосой TX-фильтра, а завал в полосе 0.2 дБ на 3 кГц.
**Ответ на `IQ_SAMPLERATE` называет достижимое, а не запрошенное.** Просьбу
клиента храним как есть (на другом устройстве она может стать выполнимой), а в
ответ отдаём то, что он реально получит на главном приёмнике. Подтвердить
«384000» и слать 192 кГц значило бы соврать в единственном месте, куда клиент
и смотрит. Из-за этого же `iq_samplerate` **переобъявляется без запроса** при
смене частоты дискретизации и устройства (`rfSampleRate`, `rfDevice`,
`rfConnected`) — иначе клиент, попросивший 384 кГц на openHPSDR, после
перехода на Pluto 576 кГц молча получал бы 192. По той же логике устроен и
`IF_LIMITS`: §4.1 прямо требует высылать его «при подключении и изменении
частоты дискретизации устройства», и он уходит на `rfSampleRate`.
**Какая частота IQ достанется клиенту.** Набор протокола (48/96/192/384 кГц)
и частоты дискретизации железа сходятся не всегда. У openHPSDR (48…384 кГц)
сходятся все, а из пресетов Pluto (576/768/960/1536/2304/3072/3840/5760 кГц)
на 384 не делятся 576 и 960. Поэтому отдаём **наибольшую законную** частоту,
не выше запрошенной и делящую частоту источника нацело (`TCIPickIQRate`):
на 576 и 960 кГц просьба «384» превращается в 192 кГц. Иначе клиент,
попросивший 384, получал бы поток на 576/480 кГц — и не по протоколу, и вчетверо
толще ожидаемого. Настоящая частота всегда стоит в заголовке блока; если
законной не нашлось вовсе (чужой rate), уходит ближайшая достижимая — врать
в заголовке мы не будем ни в каком случае. Смена rate устройства или DDC пана
на ходу пересобирает прореживание (`SetSourceRate`).
**Размер блока.** `AUDIO_STREAM_SAMPLES` — это сэмплы **на канал**, и у
аудиопотоков ровно это число уходит в `Stream.length`: §4.3 говорит про arg1
«количество сэмплов, указываемое в поле Stream.length», а умолчание 2048 при
48 кГц сходится с потолком `data[16384]` только так — 2048 × 2 канала ×
float32 = ровно 16384 байта, то есть `length` считает сэмплы, а не отсчёты.
У **IQ** единица другая: §3.4 определяет число комплексных отсчётов как
`length / channels`, поэтому там в `length` уходит `samples × channels`.
Развилку держит `TCIFillHeader` (по типу потока), обратную — разбор
`TX_AUDIO_STREAM` в `HandleBinary`. Смена любого параметра потока на ходу
**перезапускает** уже идущие потоки этого клиента: блок с новой частотой
посреди старого потока клиенты разбирают как мусор.
**Передача (§3.4, §4.2).** `TRX:0,true,tci` берёт модуляцию из аудиопотока
клиента — но только если у него запущен `AUDIO_START` (буквально по документу:
«работает, если включен аудиопоток по TCI»). В контроллере это отдельный флаг
`TCIMicRequested`, который `SetMOX` читает при выборе микрофона, **впереди**
web-клиента: явная просьба сильнее умолчания «подключён браузер». Дальше тик
сервера гонит маркеры `TX_CHRONO` по часам (плюс разовая подушка
`TX_STREAM_AUDIO_BUFFERING` на старте передачи), а приходящие блоки
`TX_AUDIO_STREAM` разворачиваются в 48 кГц моно и кладутся в тот же ринг
микрофона, что и web-аудио. Аудио от клиента, который не просил `tci`,
отбрасывается молча: отвечать ошибкой на каждый чужой блок значит захлебнуться.
**Запись линейного выхода.** Рекордер один на приёмник (а не на клиента):
пишет он то, что слышно в аппарате. Буфер на запрошенное время (потолок 300 с)
в int16 48 кГц стерео — это ровно то, что уйдёт в WAV, и вдвое меньше памяти,
чем float32. ★Буфер **линейный, не кольцевой**: §4.3 называет arg2
максимальным временем записи и прямо говорит, что по его истечении запись
удаляется, а чтобы сохранить файл, `SAVE` нужно прислать внутри интервала.
Поэтому `START` открывает окно длиной arg2, пишем от его начала, а по концу
окна данные выбрасываются вместе с памятью (`DropData` из `Feed` и из `Take`).
Срок считается **по часам** от `START`, а не по накопленным сэмплам: линейный
выход может молчать (мьют, стоящий приёмник), а время записи всё равно идёт.
Кольцо «последние N секунд» вело себя иначе в обе стороны — начало записи
затирало само себя, а `SAVE` через час после `START` отдавал файл, которого у
ExpertSDR3 давно бы не было.
`SAVE` завершает запись и отдаёт буфер отдельному потоку-писателю:
файл бывает в десятки мегабайт, а команда пришла в потоке клиента, который в
это время не читает свой сокет. MP3 не поддержан — кодера в проекте нет,
и на `.mp3` уходит честный `tci_error`.
**Потолок кадра.** Приёмный буфер соединения (`WsClient.WS_BUF_SIZE`) поднят
с 4 до 32 КБ: блок TX-аудио — это 64 байта заголовка плюс `data[16384]`, а
кадр крупнее буфера не собирается никогда (BufLen упирается в потолок и разбор
встаёт). Кадр длиннее одного блока рвёт соединение — длиннее протокол не
определяет.
**Переполнение.** У очереди команд и у кольца блоков разная политика: команду
терять нельзя (клиент выбрасывается), блок потока — можно и нужно (теряется
самый старый, `TTCIClient.BinDropped` считает). Иначе отставший скиммер рвал бы
себе и управление тоже.
### 2.6 Телеграф (§3.2)
`CW_MACROS`, `CW_MSG`, `CW_MACROS_STOP`, `CW_TERMINAL`.
Текст приводится к тому, что понимает передатчик текста ewsdr (`CWXSend`):
экранирование `^ ~ *` снимается, `CALL$N` разворачивается в N повторов
позывного, префикс/суффикс `_` считаются пустыми. **Не поддержано:** шаг
скорости внутри текста (`<` / `>`) и слитная передача аббревиатур (`|SK|`) —
эти символы просто снимаются, потому что `TCWSender` работает на одной
скорости и не знает прос-знаков. Доотправка позывного (`cw_msg:arg1;`)
игнорируется: уже отданный в очередь текст не редактируется.
---
## 3. Осознанные заглушки («эхо»)
Значения принимаются, хранятся в адаптере и рассылаются клиентам — так
синхронизация между несколькими клиентами остаётся честной, но на радио они
не влияют, потому что соответствующего тракта в EWSDR нет:
| Команда | Причина |
|---|---|
| `RIT_ENABLE`, `RIT_OFFSET`, `XIT_ENABLE`, `XIT_OFFSET` | расстройки RX/TX в контроллере нет вообще |
| `RX_BIN_ENABLE` | псевдостерео не реализовано |
| `RX_ANC_ENABLE`, `RX_APF_ENABLE`, `RX_DSE_ENABLE`, `RX_NF_ENABLE` | таких блоков в тракте нет |
| `RX_NB_PARAM` | параметры NB в WDSP наружу не выведены |
| `RX_BALANCE` | баланса каналов у слайса нет |
| `DIGL_OFFSET`, `DIGU_OFFSET` | смещения цифровых мод не реализованы |
| `RX_CHANNEL_ENABLE` | канал B главного приёмника — это VFO B, он есть всегда; создание второго слайса на пане по TCI не делаем (см. §4) |
## 3.1 Ограничения, о которых честнее знать заранее
| Что | Как ведёт себя |
|---|---|
| `AGC_GAIN` у приёмника > 0 | AGC-T в ewsdr один на приёмный тракт, у слайса своего нет. Команда от имени доп. приёмника **игнорируется** (раньше молча правила главный), в ответ уходит текущее значение |
| Цвет спота (`SPOT`, arg4 ARGB) | не читается: `TDXSpot` цвета не хранит, подписи красятся по моде/возрасту |
| `KEYER`, `TX_FOOTSWITCH` | не реализованы: своего ключа-уведомления и опроса педали наружу у контроллера нет |
| Мода, фильтр, АРУ, шумодавы у канала B доп. пана | в TCI это свойства **приёмника**, а не канала: они относятся к каналу A. У канала B по протоколу есть только частота, IF и громкость |
| Захват параметра (§3.5) | реализован для того, что клиенты действительно перетягивают (частота, DDS, мода, фильтр, TRX/TUNE/DRIVE, split, громкости, АРУ, шумодавы, squelch, скорость CW). Эхо-параметры (RIT/XIT, BIN/ANC/…) не захватываются: на радио они не влияют |
| Браузерные клиенты | отвергаются по `Origin` (403), см. §1.1. Web-интерфейсу ewsdr TCI не нужен — у него свой канал |
| `TRX_COUNT` | равен `BackendCaps.MaxPans`, а не числу живых панов: протокол объявляет его один раз. Про несуществующий приёмник просто ничего не шлётся (§2.1) |
| Потоки на передаче | RX-аудио и линейный выход на TX замолкают — движок не зовёт аудио-колбэки, пока идёт передача (кроме дуплекса с самоконтролем). То же и с IQ: RX-пакеты на TX дропаются на входе DSP. Это поведение приёмного тракта, а не потоков |
| `MUTE` и линейный выход | глушит и поток: движок под мьютом не зовёт `OnAudio`. Аудиопоток приёмника (`AUDIO_START`) мьют не трогает — он снимается до громкости |
| DIGL/DIGU, 2 канала | по §3.4 в цифровых модах два канала должны нести комплексный сигнал; у нас это обычное стерео с выхода WDSP. Комплексный вывод демодулятора наружу не выведен |
| Поток канала B доп. пана | не бывает: в протоколе аудиопоток один на приёмник, и мы отдаём канал A (первый слайс) |
| Формат IQ | всегда float32, два канала — `AUDIO_STREAM_SAMPLE_TYPE` относится к аудио (§4.3), а ExpertSDR3 IQ иначе и не шлёт |
| `IQ_SAMPLERATE` 384 кГц на Pluto 576/960 кГц | нацело не делится, поэтому уходит 192 кГц (см. §2.5). Клиент обязан читать частоту из заголовка блока, а не считать её равной запрошенной |
| MP3 у рекордера | не поддержан (кодера в проекте нет): `LINE_OUT_RECORDER_SAVE` с `.mp3` отвечает `tci_error` |
Отдельно: у `TX_SENSORS` второй аргумент — уровень микрофона; измерителя
микрофона в EWSDR нет, шлём нижнюю границу шкалы (-60 дБм), чтобы клиент не
рисовал случайные значения. Третий аргумент (RMS) и четвёртый (пик) отдаём
одинаковыми — в телеметрии платы одно значение forward power.
У `TRX` третий аргумент разобран только для `tci` (см. §2.5). Значения
`mic1`/`mic2`/`micpc`/`ecoder2` называют физические входы ExpertSDR3, которых у
нас нет: они значат «микрофон, выбранный в программе», то есть ровно то же, что
и отсутствие аргумента.
---
## 4. Что осталось
Бинарные потоки (§3.4) реализованы целиком — см. §2.5. Открыто:
1. **`RX_CHANNEL_ENABLE` как реальное создание/удаление второго слайса пана.**
Сейчас это эхо: канал B главного приёмника — VFO B, он есть всегда, а
заводить слайс по команде клиента значит отдать ему управление раскладкой
панорамы оператора.
2. **`KEYER`** — своего события «ключ нажат» у контроллера нет.
3. **TCI в демоне.** Юниты LCL-free (стенд собирает и гоняет их вместе с
`TRadioController` без единого виджета), подключается одной строкой в
`ewsdrd.lpr`, как web.
4. **Проверка на железе и с настоящим клиентом** — главное, см. конец §5.
## 5. Проверено
Стендом (WS-клиент на сыром сокете, без внешних библиотек):
- **Транспорт:** строгий разбор bind-адреса и отказ подниматься на кривом;
первый кадр, приклеенный к пакету handshake; сборка фрагментированного
сообщения; несколько команд в одном кадре; отказ от незамаскированных кадров
с разрывом соединения; ping/pong; рассылка двум клиентам; чистая остановка с
живыми клиентами; медленный клиент (5000 рассылок не блокируют вызывающего,
клиент вылетает сам); остановка сервера в тот момент, когда команда клиента
висит в `Synchronize` у потока контроллера.
- **Сквозной прогон** с настоящим `TRadioController` (движки созданы, железо не
подключено): пачка инициализации со всеми обязательными
командами и `READY` в конце; `VFO`, `MODULATION` (`cw` на 7 МГц дал CWL),
`RX_FILTER_BAND`, `DRIVE`, `VOLUME`, `MUTE`, `AGC_GAIN`, `LOCK`,
`CW_MACROS_SPEED`, `SPOT`, `RIT_*` — состояние контроллера после прогона
совпало с посланным; чтение (`vfo:0,1;`) отвечает; `RX_SENSORS_ENABLE:true,100`
даёт поток `rx_channel_sensors` с заданным периодом.
- **Устойчивость:** исключение в обработке команды не рвёт соединение — клиенту
уходит `tci_error:<команда>,<сообщение>;`, остальные команды продолжают
работать (проверено на контроллере без движков, где `SetMode`/`SetCWSettings`
падают с AV на неинициализированном `FNetwork`).
Прогон после ревизии (49 проверок, все зелёные):
- **Протокол:** строгий разбор (`abc`, пустой аргумент, переполнение Integer,
дробная запись), списки частот потоков и форматов сэмплов, построчный разбор
HTTP-заголовков.
- **Транспорт:** 101 на заголовки с табуляцией и без пробела после двоеточия;
отказ 403 при `Origin`; ровно восемь поднявшихся клиентов и 503 девятому;
освобождение слотов после отключения — **обоими** путями, и штатным
close-кадром, и обычным TCP-разрывом без него (см. правило 5 в §1.1);
молчащий сокет уходит по таймауту handshake; ответный close-кадр;
`Stop` с живым клиентом.
- **Адаптер:** `vfo:0,0,abc`, `vfo:0,0,-1`, `dds:0,broken`,
`rx_filter_band:0,x,y` не меняют ничего, а годная частота проходит; захват
параметра (второй клиент не перебивает первого раньше 200 мс и перебивает
позже); `iq_samplerate:44100` отвергается, `iq_start` отвечает ошибкой;
в пачке состояния есть `tx_frequency`, `app_focus`, поканальный `vfo_lock`
и нет частот несуществующего пана; отказ применения настроек (кривой адрес,
занятый порт) возвращает прежний работающий слушатель.
- **Остановка под `Synchronize`:** команда клиента висит в очереди главного
потока, `Ad.Free` в этот момент — сервер прокачивает очередь, команда
доисполняется, зависания нет.
Прогон после второй ревизии (60 проверок, все зелёные) добавил к этому:
проверку UTF-8 (обрыв, избыточная форма, суррогат — и то же кадром в эфире),
отказ на однобайтовый close, `OnDisconnect` при остановке сервера, отбраковку
частоты за пределами `VFO_LIMITS` (в том числе `DDS`), переобъявление
`vfo_limits` после появления устройства (в том числе когда радио пропадало и
возвращалось, пока клиентов не было) и изоляцию медленного клиента: четыре
тысячи рассылок в молчащий сокет не мешают соседу получить ответ быстрее
секунды. Всего 73 проверки — добавились отбраковка частоты и центра живыми
границами, когда снимок ещё разрешает (устройство «пропало» без событий),
рассылка `tune_drive`, `mon_volume` и
`split_enable` соседнему клиенту, адресность `VOLUME` на передаче с
самоконтролем, устойчивость к падению сеттера CW (на стенде `SetCWSettings` без
движков и сети валится с AV — клиент получает `tci_error`, соединение живо) и
неразрывность пачки инициализации под крутящейся ручкой.
### Стенд этапа 2 (бинарные потоки) — 112 проверок, все зелёные
Отдельная программа (`test/tci/tcitest.pas`, прогон — `test/tci/run.sh`,
внешних библиотек не требует) проверяет потоки на четырёх уровнях:
- **Формат и математика:** коэффициенты прореживания (в том числе «просят выше,
чем есть» и «нацело не делится»), умолчания размера блока, поля заголовка,
сквозная упаковка/распаковка всех четырёх форматов сэмплов и клип на
перегрузе, разбор пути с `|` вместо `:`.
- **Пересчёт частоты:** единичное усиление дециматора на постоянке, тон 1 кГц
проходит, тон 20 кГц при 48→12 кГц давится больше чем на 40 дБ (то самое
зеркало, ради которого и стоит фильтр), прозрачность при коэффициенте 1,
счёт отсчётов у интерполятора и отсутствие выбросов. Отдельно — каскад на
всех интересных коэффициентах (192→48, 384→48 у openHPSDR; 576→48, 1536→48,
5760→48 и 5760→384 у Pluto): полоса ровная, зеркало давится больше чем на
60 дБ. И выбор законной частоты IQ: 576 и 960 кГц на просьбу «384» отдают
192 кГц, 768 кГц отдаёт 384 кГц, чужой rate возвращает просьбу как есть.
- **Блок до сокета** (пара сокетов вместо сети): четыре блока RX-аудио 12 кГц
стерео float32 с верным заголовком и длиной; IQ 384→48 кГц уходит только
целым блоком (полблока не отправляется); моно int16; пересчёт после смены
rate источника; переполнение кольца теряет старые блоки, но клиент жив.
- **Рекордер и WAV:** буфер ограничен запрошенным временем и пишется с начала
окна (переполнение отбрасывает новое, а не затирает старое), до срока запись
есть, после срока её нет и истёкший рекордер не оживает, `Take` завершает
запись, файл получает верные RIFF/fmt/data и длину.
- **Команды на живом сервере** (настоящий `TRadioController`, WS-клиент на
сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент,
отказ на старт потока с незапущенного пана, подтверждение и отбраковка
параметров, `SAVE` без записи и `SAVE` в `.mp3` отвечают ошибкой,
`TRX:0,true,tci` без аудиопотока модуляцию не берёт, а с потоком берёт и
снимает её по `TRX:0,false` и по уходу клиента; чужой бинарный блок не рвёт
соединение; без передачи маркеров `TX_CHRONO` нет. Отдельно — согласование
частот: на источнике 192 кГц просьба «384» подтверждается как 192, смена
rate устройства сама переобъявляет и `iq_samplerate`, и `if_limits`, а на
576 кГц (Pluto) та же просьба даёт законные 192 кГц.
- **Сквозной прогон через живой WDSP:** синтетический 24-битный IQ подаётся
в движок, а клиент по WebSocket получает блоки RX-аудио 12 кГц и IQ 48 кГц
с верными заголовками; после `AUDIO_STOP`/`IQ_STOP` блоки прекращаются.
Передача: `TRX:0,true,tci` поднимает маркеры `TX_CHRONO`, а присланное
клиентом аудио 12 кГц доходит до конца тракта (появляются блоки TX-IQ).
Чего стенд этапа 2 не проверяет: доп. паны (для них нужен живой DDC),
одновременную работу нескольких клиентов на одном потоке и длительный прогон
(дрейф пейсинга TX_CHRONO виден только на минутах).
Чего стенд не проверяет: поведение пана без слайсов и рассылку каналов при
создании/удалении слайса — для них нужен живой DSP-движок, которого на стенде
нет. Остаётся и известное окно: показания измерителей читают `FDSPEngine` из
тик-потока, и смена устройства в этот момент теоретически может застать его уже
освобождённым (та же схема, что у web- и CAT-подсистем).
Попутно стенд поймал ещё одно: запись в сокет, закрытый клиентом, приносила
SIGPIPE, а он по умолчанию убивает процесс (в GUI сигнал гасит виджетсет, а
демону гасить некому). `WebUtils.SockSend` теперь шлёт с `MSG_NOSIGNAL`
send просто возвращает EPIPE, и клиент выбрасывается штатно. Правка общая
с web-подсистемой: пишет в сокеты клиентов она тем же вызовом.
Сборка: `lazbuild -B --ws=qt6 ewsdr.lpr` и `./build-ewsdrd.sh` (демон собирается,
TCI в его граф пока не заведён — юниты LCL-free, подключается одной строкой в
`ewsdrd.lpr`, как web).
★Про сборку стенда — `test/tci/README.md`: там записаны обе грабли (обязательный
`-Mobjfpc` и отдельный каталог `.ppu` с абсолютным путём, иначе линковка тянет
`PlatformUtils` из GUI-сборки, собранный с LCL).
**На реальном железе и с реальным клиентом (Log4OM/N1MM/WSJT-X/CW Skimmer) не
проверялось — ни команды, ни потоки.**
Ответ на команду-установку клиент получает дважды: прямым ответом и рассылкой
из `OnState`. Это осознанно — дубли идемпотентны, а рассылка нужна для тех
случаев, когда значение поменял не клиент, а оператор.