feat(tci): KEYER — чужой ключ очередью элементов, а не сетевыми фронтами

Последняя невыполненная команда протокола. Ключ к ней в том, что arg3 —
длительность интервала, который ТОЛЬКО ЧТО кончился, а не начинающегося.
Документ задаёт это алгоритмом: первое нажатие keyer:0,true,0, отпускание
keyer:0,false,142 («посылка длилась 142 мс»), следующее нажатие
keyer:0,true,58 («пауза длилась 58 мс»). Отсюда перевод: тип элемента — это
состояние ключа ДО фронта, то есть обратное пришедшему; arg3 = 0 играть нечего.

Почему не дёргать ключ по приходу пакета: приход говорит, что интервал
кончился, а не сколько он длился, и манипуляция «по приходу» — это сетевой
джиттер прямо в эфир, тот самый «пьяный матрос», ради которого третий аргумент
в протоколе и появился. Новый TCWElemPlayer (CWMorse.pas) держит очередь
элементов и играет их подряд по абсолютным дедлайнам: сумма длительностей
равна времени у клиента, значит отставание постоянно (сеть + один элемент) и
не накапливается. Очередь опустела — ключ отпускается и следующая пачка
начинается с чистого дедлайна: элементы чередуются, искажения нет, зато
оборвавшийся клиент не оставляет в эфире несущую.

Контроллер: CWKeyerElement(Mark, Ms) ставит элемент в очередь, CWElemKey
раздаёт фронты — чужая манипуляция это прямой ключ с точными длительностями,
поэтому у Pluto она идёт во вход прямого ключа локального генератора (он сам
поднимает сессию, рисует огибающую и сайдтон), а у openHPSDR в бит CWX
прошивки (тем же путём идёт передача текста). Гейт CWTXActive, как у CWXSend;
обрыв общий с текстом — касание манипулятора, снятие MOX и уход из телеграфа
гасят чужую манипуляцию тем же CWXAbort. Передачу KEYER не поднимает: при
break-in PTT даёт прошивка (или сессия генератора), без него оператор держит
MOX сам.

Адаптер: номер передатчика разбирается как у TRX (bad receiver / receiver is
not running), захват §3.5 общий с TRX — передатчик один, и ключ держит тот же,
кто держит эфир. Паузы обрезаются TCI_KEYER_GAP_MAX_MS = 1 с (пауза целиком
прибавляется к отставанию от клиента, а дольше секунды — это «оператор
задумался», и честнее догнать реальное время), посылки — 5 с.

Стенд test/tci: 238 проверок (было 219). Новая часть C2 меряет ДЛИТЕЛЬНОСТИ по
фронтам ключа (посылка 150 / пауза 60 / посылка 150, допуск 30 мс), проверяет
отпускание на пустой очереди, старт следующей пачки без «догона» дедлайна,
обрыв и нулевую длительность; в части D — разбор аргументов команды и сквозная
проверка, что keyer:0,true,<мс> ключ не замыкает (это пауза), а
keyer:0,false,<мс> замыкает. Негативный контроль на инверсию перевода.
★Локальный генератор вооружается только при живом устройстве, поэтому сквозная
проверка подставляет FDevConnected/FRunning на время.

doc/TCI.md: §2.6 описывает команду целиком, §3.1 и §4 переписаны под то, что из
пары KEYER/TX_FOOTSWITCH остался только второй; попутно убран устаревший абзац
§2.3 про «потоки — этап 2».

На железе с настоящим ключом по сети ещё не гонялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 22:46:37 +03:00
co-authored by Claude Opus 5
parent 66892fc7e0
commit 84b90b23c0
6 changed files with 598 additions and 26 deletions
+69 -23
View File
@@ -331,8 +331,9 @@ B независимо от того, где он живёт: на openHPSDR э
`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`
(значения принимаются и подтверждаются; сами потоки — этап 2). Параметры
(значения принимаются и подтверждаются). Параметры
потоков — настройки **клиента**, а не устройства: живут в `TTCIClient`, и один
клиент не переопределяет их остальным; подписки и параметры читаются/пишутся
под локом клиента, потому что пишет их его поток, а читает тик-поток.
@@ -343,8 +344,7 @@ int16/int24/int32/float32. Чужое значение не принимаетс
действующее.
Команды **запуска** потоков (`IQ_START`/`IQ_STOP`, `AUDIO_START`/`AUDIO_STOP`,
`LINE_OUT_*`) отвечают `tci_error:<команда>,binary streams are not implemented`.
Молчать нельзя: клиент решил бы, что поток пошёл, и ждал бы данных бесконечно.
`LINE_OUT_*`) разобраны в §2.5 — этап 2 сделан, и потоки настоящие.
### 2.4 Уведомления (§4.4, §4.5)
@@ -699,7 +699,7 @@ ExpertSDR3 давно бы не было.
### 2.6 Телеграф (§3.2)
`CW_MACROS`, `CW_MSG`, `CW_MACROS_STOP`, `CW_TERMINAL`.
`CW_MACROS`, `CW_MSG`, `CW_MACROS_STOP`, `CW_TERMINAL`, `KEYER`.
Текст приводится к тому, что понимает передатчик текста ewsdr (`CWXSend`):
экранирование `^ ~ *` снимается, `CALL$N` разворачивается в N повторов
@@ -709,6 +709,52 @@ ExpertSDR3 давно бы не было.
скорости и не знает прос-знаков. Доотправка позывного (`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` у этих двух команд — **номер передатчика**, и он не декорация. Клиент
@@ -832,7 +878,7 @@ ExpertSDR3 давно бы не было.
| `DDS` у приёмника-слайса | только читается (центр его панорамы): панорама под слайсом общая, и увести её по просьбе одного клиента значит утащить соседей по пану и картинку оператора. Слайсу двигаться незачем — за окном DDC следит `TuneSliceInBand` |
| Панорама без слайсов | в TCI не видна вовсе: приёмник = слайс. Слушать там нечего, но и IQ такой панорамы клиенту недоступен |
| Цвет спота (`SPOT`, arg4 ARGB) | не читается: `TDXSpot` цвета не хранит, подписи красятся по моде/возрасту |
| `KEYER`, `TX_FOOTSWITCH` | не реализованы, причины разные — см. ниже под таблицей |
| `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 не нужен — у него свой канал |
@@ -845,18 +891,8 @@ ExpertSDR3 давно бы не было.
| `IQ_SAMPLERATE` 384 кГц на Pluto 576/960 кГц | нацело не делится, поэтому уходит 192 кГц (см. §2.5). Клиент обязан читать частоту из заголовка блока, а не считать её равной запрошенной |
| MP3 у рекордера | не поддержан: кодера в проекте нет, а тащить внешний (lame) ради рекордера — это новая зависимость и её лицензия в сборке, которых у ewsdr сейчас нигде нет. WAV пишется без потерь и открывается всем; на `.mp3` уходит честный `tci_error`, а не молчаливый WAV с чужим расширением |
**`KEYER` и `TX_FOOTSWITCH` — почему их нет.** Команды противоположные по
направлению, и мешать их в один пункт «не сделано» неправильно.
`KEYER` шлёт **клиент**: это его «клоподав», прокинутый к нам, и третий
аргумент — длительность предыдущего знака — существует ровно затем, чтобы
телеграфное ядро воспроизвело тайминг точно. У нас несущую в CW даёт либо
прошивка (бит CWX / аппаратный кейер), либо программный кейер для Pluto, и оба
берут фронты **как пришли**. Прокинуть в них фронты из WebSocket значит
отправить в эфир сетевой джиттер, а `arg3` подставить некуда: команды «сыграй
знак длиной 142 мс» у openHPSDR нет. Точка входа готова (`CWXKeyEvent`), но
делать это надо вместе с очередью знаков по `arg3` — и после того, как весь
передающий тракт TCI пройдёт живого клиента (он ещё не проверялся).
**`TX_FOOTSWITCH` — почему его нет.** (`KEYER` был в этом же пункте и теперь
сделан — см. §2.6: очередь элементов по `arg3`, а не проброс сетевых фронтов.)
`TX_FOOTSWITCH` шлёт **сервер**, и это просто не выведено: флаг у контроллера
уже есть — `HPS_PTT` из HP-статуса ловится по фронту (`FHPpHWPTT`
@@ -892,15 +928,16 @@ ExpertSDR3 давно бы не было.
рабочий слайс вместе с его модой, фильтром и портом. Правильный порядок:
сперва завести создание слайса в контроллер (от этого выиграет и демон), и
только потом привязать к нему команду.
2. **`KEYER`** — см. разбор под таблицей §3.1: нужна очередь знаков по `arg3`,
а не проброс сетевых фронтов в ключ.
3. **TCI в демоне.** Юниты LCL-free (стенд собирает и гоняет их вместе с
2. **TCI в демоне.** Юниты LCL-free (стенд собирает и гоняет их вместе с
`TRadioController` без единого виджета), подключается одной строкой в
`ewsdrd.lpr`, как web. Не сделано намеренно и в этом порядке: в headless
сперва попадает то, что уже прошло живого клиента в GUI, где отказ виден
глазами. Цена ошибки в демоне выше — грабля с `SIGPIPE` (см. конец §5)
убивала именно `ewsdrd`, целиком и молча.
4. **Проверка на железе и с настоящим клиентом** — главное, см. конец §5.
3. **`TX_FOOTSWITCH`** — новый `rf`-эвент контроллера и строчка в адаптере,
см. §3.1 (флаг `FHWPTTActive` уже есть, наружу не выведен).
4. **Проверка `KEYER` на железе** — код прошёл стенд, живого ключа по сети ещё
не было.
## 5. Проверено
@@ -962,7 +999,7 @@ ExpertSDR3 давно бы не было.
движков и сети валится с AV — клиент получает `tci_error`, соединение живо) и
неразрывность пачки инициализации под крутящейся ручкой.
### Стенд этапа 2 (бинарные потоки) — 219 проверок, все зелёные
### Стенд этапа 2 (бинарные потоки) — 238 проверок, все зелёные
Отдельная программа (`test/tci/tcitest.pas`, прогон — `test/tci/run.sh`,
внешних библиотек не требует) проверяет потоки на четырёх уровнях:
@@ -1006,6 +1043,12 @@ ExpertSDR3 давно бы не было.
просьбы отбрасывается — абсолютный путь, `..` и буква диска наружу не
выводят; пусто, `..`, не-`.wav`, управляющий символ и отсутствие каталога
записей дают отказ; существующий файл писатель не перезаписывает.
- **Чужая манипуляция** (`TCWElemPlayer`, отдельная часть стенда): интервалы
играются подряд и **их длительности сохраняются** (посылка 150, пауза 60,
посылка 150 — по фронтам ключа с допуском 30 мс на планировщик), на пустой
очереди ключ отпускается, следующая пачка после простоя играется целиком (а
не «догоняет» старый дедлайн), обрыв гасит очередь и отпускает ключ, нулевая
длительность не порождает ни одного фронта.
- **Команды на живом сервере** (настоящий `TRadioController`, WS-клиент на
сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент,
отказ на старт потока с незапущенного пана, подтверждение и отбраковка
@@ -1019,7 +1062,10 @@ ExpertSDR3 давно бы не было.
чужой бинарный блок не рвёт соединение; без передачи маркеров `TX_CHRONO`
нет. Номер передатчика (§2.7): `trx:9`, `trx:abc` и `tune:9` отвечают ошибкой
и **не поднимают эфир**, приёмник без живого пана — тоже; ответ называет
передающий приёмник; `TUNE` через TCI включается и гасится, не оставляя за
передающий приёмник; у `KEYER` номер разбирается так же (`keyer:9`,
`keyer:abc`, незапущенный приёмник и нечисловое состояние — ошибка), первое
нажатие (`arg3 = 0`) не ошибка и ничего не играет, а `keyer:0,true,<мс>`
ключ **не** замыкает (это пауза) в отличие от `keyer:0,false,<мс>`; `TUNE` через TCI включается и гасится, не оставляя за
собой ни тона, ни поднятой PTT. Чужая передача: поверх MOX оператора `TRX`
не уводит микрофон и не снимает передачу, `TUNE` не включает тон, а уход
такого клиента передачу оператора не гасит. Конкурирующий TCI-клиент