mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 17:27:32 +00:00
fix(tci): ревизия — потоки, валидация, арбитраж и синхронизация клиентов
Разбор семи проходов ревью ветки. Ниже — по сути, а не по списку. Потоки. Сетевые потоки больше не читают модель контроллера напрямую. Слайсы снимаются в потоке контроллера (RefreshSlices → FSliceSnap, на событиях rfSliceFreq/rfSliceState/rfDevice/…), железо — тоже (RefreshDev → TTCIDevSnap: имя платы, границы, число панов, HasTX). Копия TCtrlSlice из чужого потока портила счётчик ссылок managed-строк, а BackendCaps и BoardDisplayName смотрят в FNetwork, который UI освобождает на смене устройства. По той же причине ActiveTXFreqHz переведён на GetSliceView. Sync-методы читают живую таблицу: они уже в потоке контроллера. Жизненный цикл. Stop ждёт выхода клиентских потоков БЕЗ таймаута, прокачивая очередь Synchronize: выйти по таймауту нельзя — следом освобождаются и клиенты, и сам сервер. OnDisconnect зовётся и при остановке (иначе захваты параметров ушедших клиентов доживали до следующего запуска). Отправка переехала на поток самого клиента (recv с TCI_POLL_MS): общий поток задерживал всех на таймаут записи в один медленный сокет. WebUtils.SockSend шлёт с MSG_NOSIGNAL — SIGPIPE убивал headless-процесс. Транспорт. Слот протокола выдаётся только после Upgrade, а сокет до него живёт по таймауту handshake: восемь молчащих соединений закрывали дверь настоящим клиентам. Handshake с заголовком Origin получает 403 — авторизации в TCI нет, и без этого открытая вкладка браузера дотягивалась до TRX и VFO. Заголовки разбираются построчно, текстовые кадры проверяются на UTF-8, close длиной один байт отвергается, на close отвечаем close. Валидация. Все установки ходят через TCITryArg* — «vfo^0~0~abc» больше не превращается в честный ноль. Частота проверяется дважды: в потоке клиента по снимку и в SyncSetVfo/SyncSetCenter по живым границам (устройство успевают сменить между разбором и исполнением). Границы теперь из ОДНОГО источника (FreqLimits поверх VisibleFreqBounds) — тот же, что уходит в VFO_LIMITS; сами VFO_LIMITS переобъявляются при смене железа, и их кэш ведётся независимо от того, подключён ли кто-то. Слайс двигается только TuneSliceInBand, как у CAT: прямой SetSliceTarget уводил TX-слайс в DUC на чужой диапазон без антенн и фильтров. Параметры потоков сверяются со списками спецификации, а IQ_START и прочие запуски честно отвечают ошибкой вместо молчания. Синхронизация клиентов (§3.5). Появился захват параметра на 200 мс: два логгера больше не перетягивают частоту. Пачка инициализации уходит под FClientLock — изменение между строкой снимка и READY терялось навсегда. Глобальные величины (tune_drive, cw_macros_*, split_enable, mon_volume) рассылаются всем, а правки оператора приходят событиями: rfTXProfile, rfActiveVfo, rfMonVolume и новый rfCWSettings. Создание и удаление слайса рассылается по rfDevice (сравнение расстановки), у живого пана без слайсов канал A показывает центр — иначе клиент навсегда оставался с частотой удалённого слайса. Прочее. SliceFreqChanged переехал внутрь SetSliceTarget — один путь для мыши, CAT и TCI (перетаскивание флага мимо клиентов проходило молча). VOLUME и MON_VOLUME развели: SetVolume правит АКТИВНУЮ громкость, поэтому команда на DUP-передаче уезжала в монитор — добавлен адресный SetRxVolume. Настройки сохраняются только после успешного применения, при отказе поднимается прежний слушатель. Время спота — UTC. Подписки на измерители читаются и пишутся под локом клиента. Проверено стендом (сырой WS-клиент + живой TRadioController без железа): 73 проверки, включая изоляцию медленного клиента, остановку под Synchronize, арбитраж до и после 200 мс, отбраковку по живым границам и переобъявление VFO_LIMITS. На реальном железе и с реальным клиентом по-прежнему не гонялось. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+250
-35
@@ -21,8 +21,8 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `TCIProtocol.pas` (~380 строк) | Чистый слой протокола: разбор `имя:арг1,арг2;`, сборка строк, экранирование `^ ~ *`, словарь видов связи, пересчёт громкости/порога в дБ. Зависит только от RTL + `RadioModes`. |
|
||||
| `TCIServer.pas` (~660 строк) | WebSocket-сервер: accept-поток, поток на клиента, HTTP-Upgrade, разбор фреймов, рассылка, тик 20 мс. Сокеты и фреймы переиспользованы из веб-подсистемы (`WebUtils`, `WsClient`). |
|
||||
| `TCIAdapter.pas` (~1100 строк) | Мост к `TRadioController`: реализация команд, пачка инициализации, уведомления об изменениях состояния, измерители. |
|
||||
| `TCIServer.pas` (~1250 строк) | WebSocket-сервер: accept-поток, поток на клиента, HTTP-Upgrade, разбор фреймов, рассылка, тик 20 мс. Сокеты и фреймы переиспользованы из веб-подсистемы (`WebUtils`, `WsClient`). |
|
||||
| `TCIAdapter.pas` (~2100 строк) | Мост к `TRadioController`: реализация команд, пачка инициализации, уведомления об изменениях состояния, измерители, захват параметров (§3.5). |
|
||||
|
||||
Принципы те же, что у CAT (см. `doc/CAT_STATUS.md`):
|
||||
|
||||
@@ -34,33 +34,82 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─
|
||||
сеттеры пишут параметр в 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.1 Правила, на которых держится транспорт
|
||||
|
||||
Всё это не украшения, а лечение конкретных отказов — менять с оглядкой.
|
||||
|
||||
1. **Отправка никогда не блокирует вызывающего.** `Send`/`Broadcast` кладут
|
||||
строку в очередь клиента (микросекунды под его локом), в сокет пишет
|
||||
тик-поток (`FlushClients`, 20 мс, вне общего лока). Уведомления рождаются
|
||||
внутри `Changed()` контроллера, то есть в UI-потоке: писать оттуда прямо в
|
||||
сокет означало бы отдать интерфейс во власть самого медленного клиента
|
||||
(таймаут отправки × число клиентов на каждое движение ручки VFO).
|
||||
Переполнилась очередь (`TCI_OUT_MAX`) или не прошла запись — клиент
|
||||
выбрасывается, а не тормозит остальных.
|
||||
1. **Отправка никогда не блокирует ни вызывающего, ни соседей.**
|
||||
`Send`/`Broadcast` кладут строку в очередь клиента (микросекунды под его
|
||||
локом), а в сокет её пишет **собственный поток клиента**: его `recv`
|
||||
просыпается каждые `TCI_POLL_MS` (20 мс) и сливает очередь. Уведомления
|
||||
рождаются внутри `Changed()` контроллера, то есть в UI-потоке — писать
|
||||
оттуда прямо в сокет означало бы отдать интерфейс во власть самого
|
||||
медленного клиента. Общий поток отправки был лишь половиной решения: один
|
||||
`SockSend` ждёт до `TCI_SEND_TIMEOUT` (300 мс), и восемь клиентов давали
|
||||
секунды задержки всем остальным. Теперь медленный клиент задерживает только
|
||||
себя; переполнилась его очередь (`TCI_OUT_MAX`) или не прошла запись — он
|
||||
выбрасывается.
|
||||
2. **Объект клиента освобождает только тик-поток** (`ReapClients`) и только
|
||||
после того, как клиентский поток честно вышел. Поэтому указатель, взятый
|
||||
кем угодно под `FClientLock`, гарантированно жив внутри лока.
|
||||
3. **`Stop` прокачивает очередь `Synchronize`.** Останавливает сервер поток
|
||||
контроллера (UI), а клиентский поток в этот момент может висеть как раз на
|
||||
`Invoke` в него же. Без прокачки это взаимный клин; по его таймауту сервер
|
||||
освобождал бы объекты из-под живых потоков. Дополнительно на время
|
||||
остановки взводится `Stopping`, и адаптер новых `Invoke` уже не начинает.
|
||||
Если поток всё же не вышел — объект НЕ освобождается: утечка на выходе
|
||||
дешевле обращения к освобождённой памяти.
|
||||
кем угодно под `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. **Браузерные клиенты не пускаются.** Handshake с заголовком `Origin`
|
||||
получает 403. Origin шлёт только браузер, а авторизации в TCI нет: без этой
|
||||
проверки любая открытая вкладка дотягивалась бы по `ws://127.0.0.1:40001`
|
||||
до `TRX`, `TUNE` и `VFO`. Своей web-странице нужен явный прокси, а не дыра
|
||||
по умолчанию.
|
||||
|
||||
Разбор HTTP — построчный (`TCIHttpHeader`), а не поиском подстроки
|
||||
«`upgrade: websocket`»: заголовок с табуляцией или без пробела после
|
||||
двоеточия валиден. Close-кадр подтверждается ответным close с тем же кодом
|
||||
(RFC 6455 §5.5.1); полезная нагрузка close длиной ровно один байт невалидна и
|
||||
рвёт соединение. Текстовые сообщения проверяются на UTF-8 (`TCIValidUTF8`,
|
||||
§5.6): обрыв последовательности, избыточно длинная форма, суррогаты и всё
|
||||
выше U+10FFFF закрывают соединение, а не уходят в разбор команд.
|
||||
|
||||
### 1.2 Настройки
|
||||
|
||||
@@ -74,7 +123,7 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
|
||||
**нет авторизации**: открытый наружу порт означает полный доступ к трансиверу,
|
||||
поэтому умолчание слушает только петлю.
|
||||
|
||||
Отсюда же два правила вокруг адреса:
|
||||
Отсюда же правила вокруг адреса:
|
||||
|
||||
- разбор `bind_addr` строгий (ровно четыре октета 0..255); всё непонятное —
|
||||
отказ поднимать сервер, а не молчаливый `0.0.0.0`. Пустая строка и явный
|
||||
@@ -83,8 +132,12 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
|
||||
каждое нажатие клавиши: иначе набор `127.0.0.1` по дороге проходил бы через
|
||||
«`127.0.0.`» и сервер успевал перезапуститься на всех интерфейсах.
|
||||
|
||||
Отказ старта (порт занят, адрес не разобран) виден оператору: `ApplySettings`
|
||||
возвращает результат, MainForm показывает сообщение.
|
||||
**Сначала применяем, потом сохраняем.** `ApplySettings` проверяет новую
|
||||
конфигурацию ДО остановки работающего сервера, а если новый слушатель не
|
||||
поднялся (порт занят) — возвращает прежний. `MainForm.ApplyTCISettings` пишет
|
||||
`settings.json` только после успеха и на отказе возвращает поля окна к тому,
|
||||
что реально работает. Иначе занятый порт оставлял оператора вообще без TCI, да
|
||||
ещё и с нерабочей конфигурацией на следующий запуск.
|
||||
|
||||
### 1.3 Маппинг модели
|
||||
|
||||
@@ -107,9 +160,46 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
|
||||
`VFO_LIMITS`, `IF_LIMITS`, `MODULATIONS_LIST`, `READY`.
|
||||
|
||||
`READY` шлётся **после** полного дампа состояния: клиент, дождавшийся его,
|
||||
уже знает всё. Границы частот берутся из `BackendCaps`; пока устройство не
|
||||
подключено — 10 кГц…30 МГц. `IF_LIMITS` = ±sample rate/2, пересылается при
|
||||
смене частоты дискретизации.
|
||||
уже знает всё. `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`
|
||||
@@ -118,13 +208,39 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
|
||||
|
||||
### 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`, `SetSliceTarget` |
|
||||
| `IF`, `VFO` | `SetVfoA/B`, `TuneSliceInBand` |
|
||||
| `MODULATION` | `SetMode` / `SetSliceMode` |
|
||||
| `TRX` | `SetMOX` |
|
||||
| `TUNE` | `SetTune` |
|
||||
@@ -132,9 +248,18 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
|
||||
| `TUNE_DRIVE` | `FTXSettings.TUNLevel` через `SetTXSettings` |
|
||||
| `SPLIT_ENABLE` | `SetSplit` |
|
||||
| `RX_FILTER_BAND` | `SetFilterEdges` / `SetSliceFilter` |
|
||||
| `VOLUME`, `MUTE` | `SetVolume`, `SetMute` (дБ ↔ 0..100) |
|
||||
| `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` |
|
||||
@@ -149,11 +274,22 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
|
||||
`SET_IN_FOCUS` (поднимает окно программы через `OnFocusRequest` — адаптер до
|
||||
окна не дотягивается, действие ставит MainForm),
|
||||
`SPOT`, `SPOT_DELETE`, `SPOT_CLEAR` (в `TDXSpotStore`, спот виден на всех
|
||||
панадаптерах), `RX_SENSORS_ENABLE`, `TX_SENSORS_ENABLE` (период — на клиента),
|
||||
панадаптерах; время спота — 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)
|
||||
|
||||
@@ -163,6 +299,31 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
|
||||
`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`
|
||||
@@ -170,8 +331,14 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
|
||||
сеттеры слайса: `SetSliceMode`, `SetSliceFilter`, `SetSliceAGCMode`,
|
||||
`SetSliceVolume`, `SetSliceMute`, `SetSliceDSP`, `SetSliceFMSquelch`.
|
||||
Адаптер разворачивает Id обратно в пару (приёмник, канал) и рассылает
|
||||
состояние именно этого канала. Частоту слайса, поставленную по TCI, тоже
|
||||
сопровождает `SliceFreqChanged` — как это делает CAT.
|
||||
состояние именно этого канала.
|
||||
|
||||
Частоту слайса объявляет сам `SetSliceTarget`: `SliceFreqChanged` живёт
|
||||
**внутри** него, а не у каждого вызывающего. Раньше об этом помнили CAT и TCI,
|
||||
но не UI — перетаскивание флага мышью обновляло только свой пан, и TCI-клиенты
|
||||
оставались на старой частоте. Дублирующие вызовы у вызывающих (в том числе
|
||||
ручные `PushSliceFlagState`/`LayoutFlags` в MainForm) убраны: теперь один
|
||||
путь на всех — мышь, колесо, CAT, TCI, бэнд-логика.
|
||||
|
||||
### 2.5 Телеграф (§3.2)
|
||||
|
||||
@@ -210,9 +377,10 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
|
||||
| `AGC_GAIN` у приёмника > 0 | AGC-T в ewsdr один на приёмный тракт, у слайса своего нет. Команда от имени доп. приёмника **игнорируется** (раньше молча правила главный), в ответ уходит текущее значение |
|
||||
| Цвет спота (`SPOT`, arg4 ARGB) | не читается: `TDXSpot` цвета не хранит, подписи красятся по моде/возрасту |
|
||||
| `KEYER`, `TX_FOOTSWITCH` | не реализованы: своего ключа-уведомления и опроса педали наружу у контроллера нет |
|
||||
| Арбитраж клиентов (§3.5, захват параметра ~200 мс) | нет. Команды разных клиентов идут подряд, последняя побеждает. С двумя активными логгерами возможна «перетяжка» частоты или моды |
|
||||
| Мода, фильтр, АРУ, шумодавы у канала B доп. пана | в TCI это свойства **приёмника**, а не канала: они относятся к каналу A. У канала B по протоколу есть только частота, IF и громкость |
|
||||
| Команды конфигурации потоков | подтверждаются как принятые, хотя самих потоков нет (этап 2). Клиент по ответу может решить, что функция доступна |
|
||||
| Захват параметра (§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) |
|
||||
|
||||
Отдельно: у `TX_SENSORS` второй аргумент — уровень микрофона; измерителя
|
||||
микрофона в EWSDR нет, шлём нижнюю границу шкалы (-60 дБм), чтобы клиент не
|
||||
@@ -244,7 +412,7 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
|
||||
5. **`LINEOUT_STREAM` + `LINE_OUT_RECORDER_*`** — запись в WAV/MP3.
|
||||
|
||||
Также в очереди: `RX_CHANNEL_ENABLE` как реальное создание/удаление второго
|
||||
слайса пана, `KEYER` и арбитраж нескольких клиентов (§3.5).
|
||||
слайса пана и `KEYER`.
|
||||
|
||||
Приёмный буфер `TWsClient` — 4 КБ, и сейчас это жёсткий потолок: кадр крупнее
|
||||
рвёт соединение (команд такой длины у TCI нет). Под TX-аудио его придётся
|
||||
@@ -264,7 +432,7 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
|
||||
клиент вылетает сам); остановка сервера в тот момент, когда команда клиента
|
||||
висит в `Synchronize` у потока контроллера.
|
||||
- **Сквозной прогон** с настоящим `TRadioController` (движки созданы, железо не
|
||||
подключено): пачка инициализации из 57 строк со всеми обязательными
|
||||
подключено): пачка инициализации со всеми обязательными
|
||||
командами и `READY` в конце; `VFO`, `MODULATION` (`cw` на 7 МГц дал CWL),
|
||||
`RX_FILTER_BAND`, `DRIVE`, `VOLUME`, `MUTE`, `AGC_GAIN`, `LOCK`,
|
||||
`CW_MACROS_SPEED`, `SPOT`, `RIT_*` — состояние контроллера после прогона
|
||||
@@ -275,6 +443,53 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
|
||||
работать (проверено на контроллере без движков, где `SetMode`/`SetCWSettings`
|
||||
падают с AV на неинициализированном `FNetwork`).
|
||||
|
||||
Прогон после ревизии (49 проверок, все зелёные):
|
||||
|
||||
- **Протокол:** строгий разбор (`abc`, пустой аргумент, переполнение Integer,
|
||||
дробная запись), списки частот потоков и форматов сэмплов, построчный разбор
|
||||
HTTP-заголовков.
|
||||
- **Транспорт:** 101 на заголовки с табуляцией и без пробела после двоеточия;
|
||||
отказ 403 при `Origin`; ровно восемь поднявшихся клиентов и 503 девятому;
|
||||
освобождение слотов после отключения; молчащий сокет уходит по таймауту
|
||||
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`, соединение живо) и
|
||||
неразрывность пачки инициализации под крутящейся ручкой.
|
||||
|
||||
Чего стенд не проверяет: поведение пана без слайсов и рассылку каналов при
|
||||
создании/удалении слайса — для них нужен живой 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).
|
||||
|
||||
Reference in New Issue
Block a user