Files
ewsdr/doc/TCI.md
T
ew8bakandClaude Opus 5 b62369ec6b fix(tci): callsign_send слал размноженный позывной, tci_error — неэкранированное имя
Два расхождения со спекой, оба в том, что уходит клиенту.

callsign_send (§3.2.2). Команда должна нести «финальный вариант позывного,
переданного в эфир», а несла его вместе с повторами: для «cw_msg:0,_,RA6LH$2,
599 004;» уезжало «callsign_send:RA6LH RA6LH;», потому что переменная Call к
этому моменту уже была затёрта развёрнутым текстом сообщения. Теперь позывной
запоминается ДО развёртки. Заодно: подтверждение уходит АВТОРУ сообщения, а не
broadcast'ом (у остальных клиентов своих сообщений нет), и через TCIEscape —
позывной пришёл от клиента, и символы ^ ~ * после снятия экранирования
превращались в сырые : , ; прямо посреди кадра.

Момент отправки остаётся расхождением, теперь честно описанным в doc/TCI.md:
документ шлёт команду по факту окончания передачи позывного, а наш передатчик
текста моментов внутри очереди не отмечает. Доотправка позывного (cw_msg:arg1;)
всё равно не поддержана, так что финальный вариант известен уже в момент
постановки в очередь и позже не изменится.

tci_error. Имя команды возвращается клиенту как ПЕРВЫЙ АРГУМЕНТ, то есть обязано
экранироваться, — и общий обработчик (HandleCommand) это делает, а пятнадцать
точечных отказов пропускали его сырым. Имя приходит от клиента, TCIParse режет
его только по ':', так что «foo,bar:1;» возвращался как «tci_error:foo,bar,bad
receiver;» — три аргумента вместо двух.

Заодно в doc/TCI.md: правило 3 §1.1 теперь говорит про ВСЕ потоки сервера
(accept и тик тоже, общий JoinPumped), а не только про клиентские, и счётчик
проверок стенда приведён к нынешним 244.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:21:58 +03:00

1133 lines
113 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` в него же:
без прокачки это взаимный клин. ★Это относится и к accept- с тик-потоком
(общий `JoinPumped`), а не только к клиентским: тик-поток ходит в контроллер
через `ReapClients``Disconnected``Invoke`, и проверка `CanInvoke` от
клина не спасает — она читает `Stopping` ДО входа в `Synchronize`, так что
клиент, отвалившийся ровно в момент остановки, успевает проскочить в это
окно. Выйти по таймауту нельзя — следом
освобождаются и клиенты, и сам сервер с адаптером, а не вышедший поток
вернулся бы в эту память. Поэтому: `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",
"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_CLEAR``TDXSpotStore`, спот виден на всех
панадаптерах; время спота — UTC, как у кластера, а не местное),
`RX_SENSORS_ENABLE`, `TX_SENSORS_ENABLE` (период — на клиента),
`KEYER` (чужой ключ — см. §2.6),
`IQ_SAMPLERATE`, `AUDIO_SAMPLERATE`, `AUDIO_STREAM_*`, `TX_STREAM_AUDIO_BUFFERING`
(значения принимаются и подтверждаются). Параметры
потоков — настройки **клиента**, а не устройства: живут в `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_*`) разобраны в §2.5 — этап 2 сделан, и потоки настоящие.
### 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`,
отбрасывается молча: отвечать ошибкой на каждый чужой блок значит захлебнуться.
**★Маркер `TX_CHRONO` называет приёмник КЛИЕНТА, а не нулевой.** Клиент шлёт
TX-аудио не по своей воле, а строго в ответ на маркер, и все входящие бинарные
блоки фильтрует по номеру приёмника — у MSHV это первая строка обработчика
(`network.cpp`: `if (pStream->receiver != tci_trx) return;`, ветка `TxChrono`
там же собирает и отправляет блок). С жёстким нулём в заголовке клиент,
сидящий на **втором слайсе** (`tci_trx = 1`), маркеров не видел вовсе: эфир по
`trx:1,true,tci` поднимался, а звука не было ни одного блока. Поэтому вместе с
клиентом-модулятором запоминается номер приёмника из его же `TRX`
(`FTxRx`), и маркеры идут под ним. ★Пара «клиент + приёмник» меняется **только
когда команда и правда что-то сделала** (`Started`, тот же признак, по которому
назначается хозяин эфира): иначе клиент, уже передающий с приёмника 1, своим же
`trx:0,true,tci` — командой-пустышкой, потому что передатчик занят, — перевёл бы
маркеры идущей передачи на номер 0 и замолчал.
**`rfTransmitting` — не всегда «эфир изменился».** Контроллер шлёт это поле и
просто «перерисуй TX-бейджи»: `SetTxSlice` заканчивается `Changed(rfTransmitting)`,
хотя передачи ещё нет. Адаптер раньше понимал любой такой сигнал при
`FTransmitting = false` как «передача кончилась» и снимал `TCIMicRequested`
а приходил он **посередине нашей же команды**: `SyncSetTRX` ставит просьбу →
`RequestSliceTx``SetTxSlice``Changed` → просьба стёрта → `SetMOX` выбирает
микрофон уже без неё. На живом железе это выглядело так: клиент на слайсе
поднимает эфир, а модуляция идёт с микрофона оператора, то есть в эфир —
тишина (и TX-аудио клиента отбрасывалось, `TCIMicActive` не поднят). Теперь
ловится **фронт** «было → стало» (`FLastTxOn`), а не всякое уведомление.
**Запись линейного выхода.** Рекордер один на приёмник (а не на клиента):
пишет он то, что слышно в аппарате. Буфер на запрошенное время (потолок 300 с)
в int16 48 кГц стерео — это ровно то, что уйдёт в WAV, и вдвое меньше памяти,
чем float32. ★Буфер **линейный, не кольцевой**: §4.3 называет arg2
максимальным временем записи и прямо говорит, что по его истечении запись
удаляется, а чтобы сохранить файл, `SAVE` нужно прислать внутри интервала.
Поэтому `START` открывает окно длиной arg2, пишем от его начала, а по концу
окна данные выбрасываются вместе с памятью (`DropData` из `Feed` и из `Take`).
Срок считается **по часам** от `START`, а не по накопленным сэмплам: линейный
выход может молчать (мьют, стоящий приёмник), а время записи всё равно идёт.
Кольцо «последние N секунд» вело себя иначе в обе стороны — начало записи
затирало само себя, а `SAVE` через час после `START` отдавал файл, которого у
ExpertSDR3 давно бы не было.
`SAVE` завершает запись и ставит её в очередь писателю: файл бывает в десятки
мегабайт, а команда пришла в потоке клиента, который в это время не читает свой
сокет. MP3 не поддержан — кодера в проекте нет, и на `.mp3` уходит честный
`tci_error`.
**★Писатель — один поток с очередью, и он принадлежит адаптеру.** Раньше
`SAVE` был равен «создать поток с `FreeOnTerminate`»: его никто не держал и
никто не ждал. Отсюда две беды сразу.
- **Штатный выход из программы обрывал запись.** Замерено отдельным процессом:
при закрытии сразу после `SAVE` от ожидаемых 100000044 байт на диске
оставалось 40960 — то, что успел сбросить буфер файловой системы, — а
заголовок при этом заявлял полную длину.
- **Медленный (или зависший сетевой) каталог плодил потоки.** Идущие подряд
сохранения запускали их сотнями, и память их стеков в 128-МБ бюджет не
входила вовсе.
Теперь писатель один (`TTCIWavWriter`, заводится лениво — на первом `SAVE`),
очередь ограничена `TCI_RECORD_MAX_JOBS` = 16 заданиями (сверх — клиенту
`writer busy`, а место в бюджете возвращает `CmdRecorder`), а деструктор
адаптера гасит его через `TCIStopWriter`: закрыть приём заданий, дождаться,
пока очередь допишется, и освободить.
**★Срока у этого ожидания нет намеренно** — и это вывод из двух неверных
попыток его завести. Внутрипроцессный поток, стоящий в `write(2)` или `fsync`,
остановить нечем: `Terminate` ему не указ, а `Free` обязан сделать `WaitFor`
бросить живой `TThread` нельзя, он ходит в общий бюджет (`RecBudgetLock`),
который освобождает финализация юнита. Значит, срок **не ограничивает выход**:
на мёртвом каталоге программа всё равно стоит в syscall. Ограничивал он ровно
одно — сколько подтверждённых клиенту записей мы выбросим по дороге (сперва
весь остаток очереди по общему сроку, потом, с отсчётом от последнего
продвижения, — остаток очереди на каталоге, который тормозил дольше срока и
оживал). То есть не покупал ничего и стоил данных. Убран вместе с
`FProgress`/`Advance`/`Terminate` в остановке.
Так что при закрытии программы очередь **дописывается вся**, а поток ждётся
по-настоящему (`Destroy` = `Terminate` + `WaitFor`): ни брошенных потоков, ни
выброшенных записей после нас не остаётся. Цена названа прямо: на мёртвой
сетевой ФС выход подвиснет вместе с syscall'ом. Кому нужен гарантированно
ограниченный выход — писателя придётся выносить в отдельный процесс, который
гасится средствами ОС; одним `TThread` это не делается.
**★Файл появляется целиком или не появляется вовсе.** Прежний код не смотрел
на результат записи: `FileWrite` (как и `THandleStream.Write` под ним)
возвращает **число записанных байт** и при ошибке отдаёт 0 или -1, не поднимая
исключения, — то есть на полном диске файл дописывался «до конца» и оставался
огрызком с заголовком на полную длину. Теперь:
1. данные пишутся во **временный файл** рядом (`<имя>.<pid>-<n>.part`,
создаётся эксклюзивно и не по симлинку — `TCICreateNewFile`), циклом, с
проверкой каждого вызова (короткая запись законна, её дописываем; 0 или
-1 — провал). ★Имя перебирается (`TCICreateTempNear`): pid со счётчиком
уникальны внутри процесса, но не между запусками, и `.part`, оставшийся от
прошлой жизни, вместе с повторно выданным системой pid дал бы `EEXIST`
занятое имя не повод терять запись, а вот другая ошибка (нет прав, нет
каталога) перебором не лечится и обрывает попытку сразу;
2. перед публикацией идёт `FileFlush`: на ext4 с отложенным размещением «нет
места» приходит не в `write`, а именно здесь;
3. и только после этого файл появляется под целевым именем — **одним вызовом
ядра и сразу целиком** (`TCIPublishFile`).
**★Целевого имени до этого момента не существует вовсе, и публикация ничего не
заменяет.** Промежуточный вариант — занять имя пустым файлом заранее — был
хуже обоих: на медленном диске клиент всю запись видел WAV на 0 байт, а по
имени файла он вправе считать запись готовой.
Портируемо получить сразу три свойства — атомарное появление, запрет замены и
работу на любой файловой системе — нельзя, поэтому `TCIPublishFile` идёт по
списку и на последнем шаге честно отказывается:
1. **`renameat2(AT_FDCWD, tmp, AT_FDCWD, dst, RENAME_NOREPLACE)`** — ровно то,
что нужно, одним вызовом ядра. Обёртки в RTL нет, зовём напрямую через
`Do_SysCall` (номера ABI: x86_64 316, i386 353, aarch64 276, arm 382);
`EEXIST` — имя занято, это отказ по существу.
2. **`link(2)` + `unlink`** — если `renameat2` нет (старое ядро, другая
архитектура, ФС не умеет флаг: `ENOSYS`/`EINVAL`). Семантика та же: новое
имя обязано не существовать, иначе `EEXIST`, и на симлинк по этому имени
`link` тоже не пойдёт. Каталог у временного и целевого файла один
(`tci.record_dir`), так что `EXDEV` здесь не бывает.
3. **Отказ**, если нет и жёстких ссылок (FAT/exFAT, часть CIFS/SMB и FUSE).
`FileExists` + `rename` в этом месте недопустим: `rename` затирает то, что
лежит по имени СЕЙЧАС, а между проверкой и переносом туда может попасть что
угодно — это ровно то окно (TOCTOU), ради закрытия которого всё и
затевалось. Данные при этом не пропадают: писатель **оставляет их во
временном файле** `<имя>.wav.<pid>-<n>.part` — сказать клиенту уже нечем
(`SAVE` подтверждён давно), а стирать его десятки мегабайт из-за нашей
неспособности переименовать хуже, чем оставить их лежать. Лечится
настройкой `tci.record_dir` на обычную файловую систему.
На Windows нужной семантикой обладает `MoveFileW` без
`MOVEFILE_REPLACE_EXISTING`: существующее имя = отказ.
Провал записи (и занятое имя) убирает временный файл и не оставляет целевого;
единственное исключение — шаг 3 выше. Клиент, увидевший файл под запрошенным
именем, всегда вправе считать запись готовой.
**★Память рекордера — три замка, и все три нужны.** Авторизации в протоколе
нет (§3.1), поэтому «сколько памяти займёт одна строка из сети» — это вопрос
не об аккуратности, а о живучести процесса. Раньше `START` выделял буфер
целиком: 300 с × 48 кГц × 2 канала × int16 = 57.6 МБ на команду, а приёмников
1 + `MAX_SLICES` = 7, то есть 403 МБ семью строками, и держать их можно было
сколько угодно.
1. **Память набирается кусками по секунде, а не вся сразу.** `START` не стоит
ни байта; молчащий, замьюченный или просто не звучащий приёмник не стоит
ничего вовсе. Куски не перевыделяются (никакого `realloc` в DSP-потоке) и
**не склеиваются вовсе**: `Take` отдаёт их писателю как есть, а тот пишет
их в файл подряд. Сам `Take` идёт уже после того, как рекордер вынут из
таблицы, то есть без DSP-потока на плечах.
2. **Общий бюджет `TCI_RECORD_MAX_BYTES` (128 МБ) на все рекордеры сразу.**
Спрашивается при выделении каждого куска. Отказ не рушит запись: набранное
остаётся сохраняемым, просто дальше она не растёт — иначе клиент терял бы
уже записанное из-за чужой записи на другом приёмнике. ★`SAVE` бюджет не
обходит **и не удваивает пик**: `Take` отдаёт писателю САМИ КУСКИ
(`TTCIRecTake`) вместе с их местом в бюджете, а не сплошную копию. Копия
была бы худшим из вариантов ровно там, где памяти меньше всего: при полном
бюджете рядом жили бы 128 МБ кусков и 128 МБ копии, а счётчик показывал бы
128. Куски и так лежат встык, поэтому `TTCIWavWriter` пишет их в файл
подряд (хвост последнего за `Count` — не данные), а место отпускает в
`ReleaseJob` — ровно один раз, сколько бы путей выхода ни было у записи.
Пока писатель ждёт медленный или зависший сетевой каталог, эти байты
остаются занятыми: потолок держит и очередь сохранений, а не только сами
записи.
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` и созданием; существующий файл не
перезаписывается и при публикации (`renameat2(RENAME_NOREPLACE)`/`link`/
`MoveFileW` — все без замены, см. выше). Клиенту про уже занятое имя отвечаем
`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`, `KEYER`.
Текст приводится к тому, что понимает передатчик текста ewsdr (`CWXSend`):
экранирование `^ ~ *` снимается, `CALL$N` разворачивается в N повторов
позывного, префикс/суффикс `_` считаются пустыми. Подтверждение
`callsign_send` уходит **автору сообщения** и содержит позывной ДО развёртки
повторов (`RA6LH`, а не «RA6LH RA6LH»). ★Расхождение со спекой, осознанное:
документ шлёт эту команду по факту окончания передачи позывного, а наш
передатчик текста моментов внутри очереди не отмечает — подтверждаем сразу.
Доотправка всё равно не поддержана, так что финальный вариант известен уже
здесь и позже не изменится. **Не поддержано:** шаг
скорости внутри текста (`<` / `>`) и слитная передача аббревиатур (`|SK|`) —
эти символы просто снимаются, потому что `TCWSender` работает на одной
скорости и не знает прос-знаков. Доотправка позывного (`cw_msg:arg1;`)
игнорируется: уже отданный в очередь текст не редактируется.
**`KEYER` — чужой ключ (§4.3).** `KEYER:<передатчик>,<нажата>,<мс>` — не «нажми
сейчас», а **описание уже закончившегося интервала**. Документ задаёт это
алгоритмом: первое нажатие даёт `keyer:0,true,0`, отпускание —
`keyer:0,false,142` («посылка длилась 142 мс»), следующее нажатие —
`keyer:0,true,58` («пауза длилась 58 мс»). Отсюда правило перевода: тип
интервала — это состояние ключа **до** фронта, то есть обратное пришедшему;
`arg3 = 0` играть нечего, это лишь открытие передачи.
★Ключевое место — **почему нельзя дёргать ключ по приходу пакета**. Приход
говорит, что интервал кончился, а не сколько он длился: манипуляция «по
приходу» — это сетевой джиттер прямо в эфир, тот самый «пьяный матрос», ради
которого третий аргумент в протоколе и появился. Поэтому элементы становятся в
очередь и играются подряд по абсолютным дедлайнам (`TCWElemPlayer` в
`CWMorse.pas`): сумма длительностей равна времени у клиента, значит отставание
постоянно (сеть + один элемент) и не накапливается.
Дальше элементы уходят туда же, куда ключ оператора, — чужая манипуляция это и
есть прямой ключ, только с точными длительностями: у Pluto во вход прямого
ключа локального генератора (он сам поднимает сессию, рисует огибающую и
сайдтон), у openHPSDR в бит `CWX` прошивки (тем же путём идёт передача текста).
Гейт тот же, что у `CWXSend`, — `CWTXActive`: не телеграфный режим, RX-only
слот трансвертера или бэнд с `DoNotTx` — кейер разоружён, и трогать ключ
незачем. Передачу `KEYER` не поднимает: как и у текста, при включённом break-in
PTT даёт сама прошивка (или сессия локального генератора), при выключенном
оператор держит MOX сам.
Три оговорки, о которых честно:
- **очередь опустела — ключ отпускается**, и следующая пачка начинается с
чистого дедлайна. Элементы всегда чередуются, так что искажения тут нет,
зато оборвавшийся клиент не оставляет в эфире несущую;
- **паузы обрезаются** `TCI_KEYER_GAP_MAX_MS` = 1 с, посылки —
`TCI_KEYER_MARK_MAX_MS` = 5 с. Пауза целиком прибавляется к отставанию от
клиента; всё, что дольше секунды, — это «оператор задумался», и честнее
догнать реальное время, чем тащить его дальше. Посылка длиннее пяти секунд —
уже не телеграф, а залипший ключ;
- **обрыв — общий с текстом**: касание манипулятора, снятие MOX, уход из
телеграфа гасят чужую манипуляцию тем же `CWXAbort`. Оператор всегда сильнее
сети.
`arg1` проверяется, как у `TRX` (не число или вне `TRX_COUNT`
`bad receiver`; номер в потолке, но пана под ним нет — `receiver is not
running`), а сам телеграф уходит **текущему TX-источнику** — ровно так же, как
`CW_MACROS`. Захват (§3.5) общий с `TRX`: передатчик один, и ключ держит тот
же, кто держит эфир.
### 2.7 Кто именно уходит в эфир (`TRX`, `TUNE`)
`arg1` у этих двух команд — **номер передатчика**, и он не декорация. Клиент
живёт на своём приёмнике: скиммер на пане 2, цифра на пане 1, логгер на
главном. Нажимая передачу, он просит эфир **своему** слайсу — а не тому,
который оператор выбрал мышкой минуту назад. Пока номер игнорировался,
`trx:2,true` уводил в эфир чужой слайс: другая частота, а с кросс-бандовым
мультислайс-TX — и другой диапазон, то есть чужие антенна и фильтры. Клиент об
этом не узнавал никак (ответ всегда говорил `trx:0,…`), а оператор видел
передачу там, где её никто не просил.
Как устроено теперь:
- номер разбирается и проверяется, как у всех остальных команд:
не число или вне `TRX_COUNT``tci_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 (`SetMOX``SyncCWKeyer` → она), в том числе **до подключения
устройства**. В 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` цвета не хранит, подписи красятся по моде/возрасту |
| `TX_FOOTSWITCH` | не реализована — см. ниже под таблицей (`KEYER` сделан, см. §2.6) |
| Канал 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 с чужим расширением |
**`TX_FOOTSWITCH` — почему его нет.** (`KEYER` был в этом же пункте и теперь
сделан — см. §2.6: очередь элементов по `arg3`, а не проброс сетевых фронтов.)
`TX_FOOTSWITCH` шлёт **сервер**, и это просто не выведено: флаг у контроллера
уже есть — `HPS_PTT` из HP-статуса ловится по фронту (`FHPpHWPTT`
`FHWPTTActive`), — но события наружу нет. Плюс две оговорки, из-за которых
уведомление было бы полуправдой: бит не отличает педаль от тангенты микрофона,
а у 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. **TCI в демоне.** Юниты LCL-free (стенд собирает и гоняет их вместе с
`TRadioController` без единого виджета), подключается одной строкой в
`ewsdrd.lpr`, как web. Не сделано намеренно и в этом порядке: в headless
сперва попадает то, что уже прошло живого клиента в GUI, где отказ виден
глазами. Цена ошибки в демоне выше — грабля с `SIGPIPE` (см. конец §5)
убивала именно `ewsdrd`, целиком и молча.
3. **`TX_FOOTSWITCH`** — новый `rf`-эвент контроллера и строчка в адаптере,
см. §3.1 (флаг `FHWPTTActive` уже есть, наружу не выведен).
4. **Проверка `KEYER` на железе** — код прошёл стенд, живого ключа по сети ещё
не было.
## 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 (бинарные потоки) — 244 проверки, все зелёные
Отдельная программа (`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 с записи = три куска по секунде, а не
один свёрнутый — сплошной копии не появляется ни на миг), счёт бюджета при
этом не меняется, склеенные куски дают непрерывный звук, и писатель
возвращает резерв по окончании записи. ★Писатель: задание принимается и
дописывается очередью, после удачи временного файла рядом не остаётся,
неудачная запись (счёт обещает больше, чем есть в кусках) не оставляет НИ
файла, НИ `.part`, занятое имя `.part` (файл от прошлого запуска с тем же
pid) записи не теряет, за всё время записи 32 МБ целевое имя ни разу не видно
незаконченным, сверх потолка заданий следует отказ, после `Close` заданий не
берут, а `TCIStopWriter` дожидается 32-МБ записи — файл цел сразу после его
возврата, и **хвост очереди за ней дописан тоже** (это и есть проверка на
обрыв при выходе; без ожидания краснеют обе).
★Имя файла: простое имя ложится в каталог записей, а каталог из
просьбы отбрасывается — абсолютный путь, `..` и буква диска наружу не
выводят; пусто, `..`, не-`.wav`, управляющий символ и отсутствие каталога
записей дают отказ; существующий файл писатель не перезаписывает.
- **Чужая манипуляция** (`TCWElemPlayer`, отдельная часть стенда): интервалы
играются подряд и **их длительности сохраняются** (посылка 150, пауза 60,
посылка 150 — по фронтам ключа с допуском 30 мс на планировщик), на пустой
очереди ключ отпускается, следующая пачка после простоя играется целиком (а
не «догоняет» старый дедлайн), обрыв гасит очередь и отпускает ключ, нулевая
длительность не порождает ни одного фронта.
- **Команды на живом сервере** (настоящий `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` отвечают ошибкой
и **не поднимают эфир**, приёмник без живого пана — тоже; ответ называет
передающий приёмник; у `KEYER` номер разбирается так же (`keyer:9`,
`keyer:abc`, незапущенный приёмник и нечисловое состояние — ошибка), первое
нажатие (`arg3 = 0`) не ошибка и ничего не играет, а `keyer:0,true,<мс>`
ключ **не** замыкает (это пауза) в отличие от `keyer:0,false,<мс>`; `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`, ★по `trx:1,true,tci` берёт модуляцию из TCI
(то есть просьбу не стирает `Changed(rfTransmitting)` из `SetTxSlice`) и шлёт
маркеры `TX_CHRONO` **под номером 1** — оба этих места и ломали передачу
MSHV, сидящего на втором слайсе; а `trx:0,true,tci` поверх этой же передачи
(команда-пустышка) номер приёмника в маркерах не меняет, а после удаления слайса приёмник 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`. Это осознанно — дубли идемпотентны, а рассылка нужна для тех
случаев, когда значение поменял не клиент, а оператор.