Commit Graph
523 Commits
Author SHA1 Message Date
ew8bakandClaude Opus 5 d2096976df fix(tci): всплески на передаче — TX-аудио просилось общим тиком 20 мс
Посреди передачи из MSHV на водопаде появлялись всплески своего сигнала.
Цепочка: очередь DUC пустеет дольше подушки отправителя (DUC_FIFO_THROTTLE
= 2000 отсчётов = 10.4 мс) → FIFO радио сохнет → модуляция обрывается → в
эфире остаётся голая несущая на частоте гетеродина DUC, в стороне от тона
ровно на звуковой сдвиг. Доказано pcap-съёмом: шесть всплесков в дампе —
ровно столько, сколько видел оператор, и каждый стоит за паузой 10.3-23.5 мс,
а паузы 8 мс и короче не дали ни одного.

Виноват не клиент и не блокировка UI, а зернистость НАШЕГО запроса. Слой
первый: PushTxChrono жил на общем тике сервера 20 мс, а просил блок клиента
целиком (2048 отсчётов = 42.7 мс) — маркер выходил через два или три тика,
то есть через 40 или 60 мс. Слой второй: MSHV отвечает пачками по 4-5 блоков
раз в ~44 мс (STREAM_C = 4096 при 96 кГц), и мелкий квант этого не лечит —
нужен запас не меньше пачки.

Сделано:
* квант запроса = один блок TXA (512 отсчётов движка), а не блок клиента;
* свой поток-планировщик TxTickLoop с АБСОЛЮТНЫМИ дедлайнами (опоздание
  одного пробуждения не сдвигает сетку); общий тик маркеров больше не шлёт;
* бухгалтерия Owed/InFlight в кадрах на канал, гасится по k до интерполятора;
  потолок долга обязан быть выше окна в полёте (TCI_TX_OWED_HEADROOM_Q),
  иначе связывающим становится он и подача падает до 58% реального времени
  при полностью исправном клиенте;
* SendBinNow: маркеры пишутся в сокет напрямую под FWriteLock, минуя очередь
  (та выпускается лишь на пробуждении потока клиента, TCI_POLL_MS = 20 мс —
  вдвое больше кванта, и подача снова рвалась);
* FReapLock: планировщик TX — новый поток, а правило «клиента освобождает
  только тик-поток» держалось на том, что им же он и пользуется;
* аванс под зернистость клиента: измеряется по ПЕРИОДУ между пачками (размер
  пачки зависит от того, сколько мы запросили ⇒ положительная обратная связь),
  переживает конец передачи, умеет уменьшаться по выдержке TCI_TX_LEAD_DOWN_MS,
  потолок TCI_TX_LEAD_MAX_MS;
* старт передачи: KickTxTick будит планировщика на фронте PTT, TxPreWarm шлёт
  один маркер ДО SetMOX (41 мс раздумий клиента накладываются на нашу же
  подготовку тракта) под гейтом «передатчик свободен и чужого источника нет»,
  PrimeDUCIQ для источника TCI растянут до Max(6096, аванс×4) — путь микрофона
  радио, CW и web не затронут;
* посев аванса TCI_TX_LEAD_DEF_MS = 50 мс, пока про клиента ничего не известно:
  обучение к первому осушению физически не успевает.

Монотонные часы одного источника для всех потоков — PlatformUtils.MonotonicUs
(абсолютные дедлайны не терпят часов, способных прыгнуть от NTP).

На железе: опасных осушений посреди передачи НОЛЬ (было 12 за 11 с), четыре
передачи из пяти вообще без единого, включая старт; прогон 15:21 чист везде,
в том числе на первой передаче после подключения. Всплесков оператор больше
не видит.

Приборы: TxTrace.pas (EWSDR_TXTRACE=1) и стенд test/hpsdr — кольцевой tcpdump
capture.sh, разбор дампа pcap_tx_scan.py, разбор трассы txtrace_scan.py.
Стенд test/tci: часть F «Пейсинг TX», 260 проверок, провалов нет; живой клиент
с рампой, RTT и потерями — test/tci/tx_chrono_bench.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gmVQnna1i4EbSZ2VGm6KD
2026-08-23 22:06:10 +03:00
ew8bakandClaude Opus 5 ced8581ada fix(hpsdr): TX-антенна тонула в Alex1, OC-пины били через один, SO_EXCLUSIVEADDRUSE
Три дефекта, найденные сверкой с прошивкой (Anvelina_PROIII, board_type=5)
и с Thetis.

1. CalcAlex1 жёстко ставил бит 24 (ANT1) с комментарием «ADC1 дефолт».
   Старшие 16 бит Alex1 — не фильтры ADC1, а TX-СЛОВО Alex: прошивка кладёт
   байты 1428-1429 в Alex_Tx_data и на PTT подменяет им верхнюю половину
   Alex-слова целиком (Orion.v:2346, Alex_Tx_data_ok = Alex_Tx_data[10:8] —
   те же ANT1/2/3). Захардкоженный ANT1 делал условие истинным и затирал
   антенну, посчитанную в CalcAlex0: оператор с TX на ANT2/ANT3 передавал
   в ANT1. Thetis делает ровно то, что теперь делаем мы —
   netInterface.c:489 чистит биты 24-26 и ставит _TXANT_1/2/3 из tx_ant.

2. Байт 1401 (Open Collector) уходил без сдвига. Бит 0 не разведён, пины
   сидят на битах 1..7 (Orion.v:913, USEROUT0..6 = Open_Collector[1..7]) —
   та же раскладка, что у C2[7:1] в протоколе 1. Каждый включённый пин
   срабатывал на соседнем выходе, седьмой не срабатывал никогда.
   CalcOCBits остаётся в логических координатах (бит i = пин i+1),
   сдвиг делает SendFullHP на границе с проводом.

3. SO_EXCLUSIVEADDRUSE = not 5 ($FFFFFFFA) вместо ~SO_REUSEADDR = not 4
   ($FFFFFFFB) — как и написано в собственном комментарии. Опции с таким
   номером нет: setsockopt возвращал ошибку (её результат не проверяется),
   и порт оставался перехватываемым, ровно от чего строка и спасала.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:25:47 +03:00
ew8bakandClaude Opus 5 c2fa1da2b0 perf(dsp): молчащие слайсы считались от одного факта, что поднят TCI-сервер
RX_AUDIO по TCI снимается ДО громкости и мьюта, поэтому замьюченный слайс
обязан продолжать считаться, пока его кто-то качает по сети — иначе клиент
остаётся без звука ровно потому, что оператор убрал его у себя в комнате. Гейт
в ProcessSlicesFor это и делал, но спрашивал ГЛОБАЛЬНЫЙ флаг «тапы аудио
установлены», а его взводит SetTaps(True) на старте сервера. То есть галка
«Enable TCI server» одна, без единого клиента, заставляла гонять полный
fexchange0 по каждому молчащему слайсу — до шести штук, ~47 блоков в секунду на
каждый, плюс два входа в критические секции на блок в DSP-потоке. Клиент,
качающий один слайс из шести или вообще только IQ, оплачивал остальные так же.

Флаг стал ПОСЛАЙСОВЫМ (TSliceChan.TapWanted, SetSliceTapWanted). Кто его
выставляет — владелец потоков: TTCIAdapter.PushTapWants полностью пересчитывает
картину по FStreams и раскладывает по слотам. Считаются только потоки RX_AUDIO:
LINE_OUT и рекордер снимаются ПОСЛЕ мьюта, и на молчащем слайсе им достаётся
тишина независимо от того, посчитали его или нет.

Пересчёт зовётся отовсюду, где множество потоков или карта слайсов меняются:
StartStream (синхронно и ДО ответа клиенту — тогда не теряется ни один блок),
StopStream, DropClientStreams, DropDeadRxStreams, StopAllStreams и OnState по
MapChanged — слот мог сменить хозяина, и разрешение обязано переехать вместе с
ним. Список слушателей снимается под FStreamLock, и лок отпускается ДО похода в
движок: DSP-поток входит в тап, уже держа FSliceLock движка, и берёт в нём
FStreamLock — обратный порядок дал бы клин.

Глобальные FAudioTapsActive/SetAudioTapsActive/PushAudioTapsActive удалены.
FAudioTapCount остаётся: он по-прежнему гейтит сам вызов тапов (FireAudioTap) и
сборку звука DMR. Из AttachEngineCallbacks флаг не восстанавливается намеренно —
слайсы пересозданный движок не наследует вовсе.

Заодно: StopStream завершал перебор через Exit прямо из-под try, минуя хвост
процедуры, — заменено на Break.

Стенд (+3 проверки, негативный контроль в обе стороны): мьют слайса не глушит
RX_AUDIO по TCI; без потока молчащий слайс не считается вовсе (наблюдатель —
обычный аудио-тап, который теперь права требовать демодуляции не имеет);
попросили поток снова — слайс снова в работе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:07:25 +03:00
ew8bakandClaude Opus 5 3a5dced17c fix(tci): счётчик клиентских потоков переживал неудачный старт — Stop висел вечно
AcceptLoop делал InterLockedIncrement(FThreadCount) и следом создавал поток
безо всякой защиты. Не родился поток (память, лимит потоков в системе) —
исключение уходило из AcceptLoop и TTCIAcceptThread.Execute, accept-поток умирал
при FRunning = True, а счётчик оставался ≥ 1 навсегда. Ждут его на шаге 4
TTCIServer.Stop БЕЗ таймаута (и намеренно: следом освобождаются и клиенты, и сам
сервер), так что программа зависала и на выходе, и на любой смене настроек TCI.

Инкремент остался до старта — переносить его после Start нельзя, там гонка с
ThreadDone уходящего потока. Спавн обёрнут в try/except: счётчик возвращается
назад, сокет гасится Kill, клиент метится FClosed и достаётся тик-потоку
(ReapClients), как при обычном отключении. Объект потока уносит себя сам —
упавший конструктор сразу, поднявшийся по FreeOnTerminate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:06:53 +03:00
ew8bakandClaude Opus 5 6e4ba1c1c5 fix(tci): клик по DX-споту на доп. пане слал номер ПАНА вместо номера приёмника
Приёмником TCI в этой ветке стал слот СЛАЙСА (TCI_MAX_RX = 1 + MAX_SLICES,
«панадаптером приёмник быть перестал»), а PanNSpectrumMouseDown отдавал в
rx_clicked_on_spot P.PanId. Клик по пану 1 уезжал как «rx_clicked_on_spot:1,…»,
то есть адресовал слайс в слоте 0 — возможно, стоящий на ГЛАВНОМ пане, — а то и
приёмник, которого нет вовсе: клиенты, фильтрующие по номеру, такое уведомление
просто теряют. Вызов rx0 с главного пана был и остаётся верным.

Теперь номер берётся у слайса, который на спот и увели: DXTuneSpotOnPan стал
функцией и возвращает его, TTCIAdapter.RxOfSlice переводит id в номер TCI.

Заодно два следствия того же места:

  * Не увели ничего (спот вне полосы захвата зумнутого пана, слоты кончились) —
    молчим. Иначе уведомление уходило под номером 0, то есть от имени главного
    приёмника, который никуда не двигался, и логгер открывал карточку QSO с QSY
    на частоту, где радио не стоит.

  * AddSliceAtFreqPan стал функцией и честно сообщает, что получилось. При
    неудаче (нет свободных слотов, частота вне захвата) он не трогает
    FActiveSliceId, а DXTuneSpotOnPan читал именно его — и уводил на спот ЧУЖОЙ
    слайс, вообще говоря с другого пана, вместе с SetSliceModeBW и
    PushSliceFlagState не на том пане.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:06:53 +03:00
ew8bakandClaude Opus 5 11d8e11ba5 fix(tci): выключение TUNE не забывало хозяина эфира — уход клиента рубил чужую передачу
Задний фронт передачи ловился только по rfTransmitting, а выключение TUN гасит
эфир изнутри SetTune: там SetMOX(False) шлёт rfTransmitting, пока FTuning ещё
True (флаг сбрасывается строкой ниже, см. RadioController.SetTune), поэтому
TxNow оставался True и ForgetTxOwner не звался. Следующий за этим
Changed(rfTuning) обработчик игнорировал вовсе.

Итог: клиент сделал «tune:0,true» и стал хозяином эфира, оператор выключил TUN
кнопкой (тогда сброс в DispatchCommand не срабатывает — команды-то не было),
позже нажал PTT сам, а когда сокет клиента закрылся, HandleDisconnect →
StopTxOf всё ещё видел его хозяином и снимал ОПЕРАТОРСКУЮ передачу. Ровно то,
что комментарий у ForgetTxOwner обещает не допускать.

Теперь фронт ловится и по rfTuning. На стенде — сценарий целиком: клиент
поднимает TUN, оператор гасит его сам и встаёт в эфир, уход клиента передачу не
трогает (негативный контроль: с прежним условием проверка падает).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:06:21 +03:00
ew8bakandClaude Opus 5 4b715c8b03 fix(tci): смена настроек TCI освобождала клиентов, не сняв тапы DSP
ApplySettings гасил сервер в порядке Stop → StopAllStreams → SetTaps(False), а
TTCIServer.Stop на шаге 5 освобождает всех клиентов. Тапы аудио и IQ в этот
момент ещё стояли, и DSP-поток, войдя в OnAudioTap между возвратом из Stop и
захватом FStreamLock в StopAllStreams, шёл по FStreams в FeedAudio → EmitFull →
FClient.SendBin, то есть брал FOutLock уже освобождённого объекта. Достаточно
было сменить порт или снять галку «Enable» в Settings → CAT, пока у клиента жив
AUDIO_START или IQ_START.

Порядок приведён к тому, что и так был в Destroy: ПЕРВЫМИ снимаем тапы (их
снятие ждёт выхода DSP-потока из вызова), потом останавливаем сервер, потом
гасим потоки — их объекты ссылаются на клиентов, поэтому они последние.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:05:29 +03:00
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
ew8bakandClaude Opus 5 4081ea0e57 fix(cat): нечисловое поле = индекс 0 ещё в семнадцати командах, ZZGT и ZZBE
Всё найдено новым стендом (test/cat), все правки им же и закрыты.

Нечисловое поле. Правку «TryStrToInt вместо StrToIntDef(s, 0)» однажды получили
пять команд (ZZAU, ZZBP, ZZBM, ZZBS, ZZFI), а остальные остались как были — в
том числе КЕНВУДОВСКИЕ ДВОЙНИКИ тех же самых величин, ходящие в те же сеттеры:
FWxxxx;, SHxx; и SLxx; ставили фильтр 0 (тот же индекс, что чинили у ZZFI),
AG0xxx; и SQ0xxx; — громкость и порог шумоподавителя в ноль, GTxxx; — АРУ в
FAST, PCxxx; и ZZPCxxx; — мощность в ноль. Всего семнадцать команд: CN, FW, GT,
NB, PC, SH/SL, AG, SQ и ZZAG, ZZAR, ZZNA, ZZNB, ZZNR, ZZPC, ZZSQ, ZZST, ZZTB.
Разбор везде приведён к идиоме ZZFL/ZZFH: не число — ошибка формата.

Не тронуты три места, где мягкий разбор безвреден или намеренный: FR сам
сверяет поле с '0'/'1' до преобразования; MD и ZZMD от нечислового получают 0, а
установка идёт от 1, то есть ничего не делают; ZZOS трактует мусор как симплекс
по эталону (default в String2OffsetDirection), и клиенты на это рассчитывают.

ZZGT. Опрос отвечал тремя цифрами (как кенвудовская GT), а установка принимала
ровно один символ: клиент, прочитавший «ZZGT000;» и написавший его назад,
получал «?;» — а читать значение и писать его обратно умеет любой логгер.
Принимаются обе ширины, поле разбирается строго.

ZZBE. Формы были перевёрнуты: опрос «ZZBE;» отвечал «?;», а установка
«ZZBE01;» возвращала данные ('1'), причём саму установку никто не исполнял.
Вся семья «сдвиг VFO на nn шагов» (ZZAD ZZAE ZZAF ZZBF ZZSG ZZSH) — однострочные
заглушки в таблице, и документ числит ZZBE среди нереализованных; приведено к
ним.

ZZEB. Выдача клампится туда же, куда и приём: сетка эквалайзера бывает только
трёх- или десятиполосной. Иначе опрос отдавал число полос, которое разбор той же
команды отвергал.

Стенд: 50 проверок, все зелёные; на коде до этого коммита падают 19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:21:38 +03:00
ew8bakandClaude Opus 5 d6a7dd96c5 test(cat): стенд разбора команд — свойства всего набора, а не список
Команд в CATEngine больше трёхсот, и проверять их поимённо бессмысленно: список
отстанет от кода в первую же неделю. Поэтому стенд ходит по кодам перебором
AA..ZZ (незнакомый движок отсеет сам, ответив «?;») и проверяет свойства,
которые обязаны выполняться на всём наборе сразу.

Круговой прогон: то, что команда отдала на опрос, она обязана принять обратно.
Это ловит расхождение ширин GET и SET — клиент, который читает значение и пишет
его назад, обычная идиома логгеров. Read-only статусы (IF, ID, ZZIF, ZZID)
исключены списком: их ответ — составной снимок, обратно его не пишут ни Kenwood,
ни Thetis.

Мусор в поле: разбор не имеет права упасть ни на одном аргументе (сборка с
-Criot: диапазоны, переполнения, приведения типов) и обязан отвечать либо ничем,
либо «?;»/«E;», либо ОДНИМ корректным кадром — лишняя ';' внутри ответа для
клиента означает две команды вместо одной, и дальше он читает мусор. Отдельным
прогоном на чистом радио: нечисловое поле не имеет права ничего перестроить.
Числовые формы в этот прогон не входят намеренно — «ZZFA00000000000;»
синтаксически законна, и решать её судьбу должен контроллер, а не разбор.

Радио подставное (TFakeRadio): ни движка, ни железа, но геттеры и сеттеры
настоящие, поэтому видно не «команда ответила», а ЧТО она изменила.

Плюс точечные регрессии на дефекты, которые уже случались: заглушка, молча
правившая радио (ZZVB копировал VFO A в VFO B прямо на опросе), ошибочные
алиасы, нечисловое поле как индекс 0, ширины полей.

★-B в обоих стендах (и в TCI тоже). Не «для чистоты»: fpc сверяет .ppu с
исходником по времени с точностью до секунды, и правка, попавшая в ту же
секунду, что и прошлая сборка, молча не подхватывается — стенд выносит вердикт
о СТАРОМ коде. Это хуже, чем не запускаться вовсе, и ловится обычно на
негативном контроле, когда исходник меняют туда-обратно (на нём и поймалось).
Полная пересборка графа стенда занимает около секунды.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:21:14 +03:00
ew8bakandClaude Opus 5 210f3e6457 fix(cw): обрыв манипуляции — поколение, а не флаг: пакет следом его отменял
Один дефект в двух местах: и TCWElemPlayer (чужая манипуляция по TCI), и
TCWSender (передача текста — F1..F8, набор в терминале, CAT KY) сбрасывали
FAbortReq прямо в Enqueue. Поток замечает обрыв только на очередном срезе Hold,
то есть в пределах 5 мс; всё, что прилетело в это окно, снимало флаг, и уже
отданный AbortPlay/AbortSending пропадал бесследно.

Чем это кончается в эфире: обрыв существует ровно затем, чтобы касание
манипулятора, снятие MOX или уход из телеграфа прекратили программную
манипуляцию (как «key hit» в Thetis/pihpsdr). Проглоченный обрыв означает, что
оператор взял ключ в руки, а софт продолжает манипулировать вместе с ним.

Источники, кормящие Enqueue, ровно такие, чтобы в 5 мс попадать: клиент TCI
шлёт элементы KEYER десятками в секунду; терминал отдаёт набор В ЭФИР ПО
СИМВОЛУ на нажатие клавиши; поле текста KY — 25 знаков, поэтому длинное
сообщение логгер шлёт несколькими командами подряд. У передачи текста цена
выше: AbortSending чистит FPending, но взятое сообщение живёт в локальной
переменной потока, и доигрывается ВЕСЬ его остаток — замер на стенде дал 620 мс
(остаток 720-мс тире на 5 WPM), у KEYER — 742 мс.

Лечение общее: вместо флага счётчик поколений. AbortPlay/AbortSending его
инкрементируют, Enqueue не трогает вовсе, поток несёт своё поколение (Take,
Aborted, Hold, SendChar) и принимает новое только сам — когда отпустил ключ и
бросил очередь. У TCWSender есть точка получше: текст и поколение берутся одним
заходом под лок (TakeText(out Gen)), а это закрывает заодно и вторую точку
сброса — ту, что стояла в начале Execute. Добор по ходу передачи стал
TakeMore(Gen): после обрыва не берём ничего, и набранное ПОСЛЕ него не теряется
вместе с брошенным, а уходит следующим сообщением со своим поколением.

Стенд: 244 проверки (было 238). В C2 — регрессия на KEYER, новая часть C3 на
передачу текста (раньше TCWSender стендом не покрывался вовсе): длительность
точки по скорости, старт сообщения, обрыв с пакетом следом, и что после обрыва
передача снова идёт. Обе регрессии проверены негативным контролем — на старом
CWMorse.pas они падают с теми самыми 742 и 620 мс.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:44:52 +03:00
ew8bakandClaude Opus 5 6aa4db3cb8 fix(tci): Stop прокачивает Synchronize и на accept/тик-потоках, а не только на клиентских
Шаг 4 остановки сервера намеренно ждёт клиентские потоки с прокачкой
CheckSynchronize — Stop зовут из потока контроллера, и клиентский поток может
как раз висеть на FController.Invoke, то есть на TThread.Synchronize к этому
самому потоку. Шаги 2 и 3 при этом делали глухой WaitFor, хотя тик-поток
ходит той же дорогой: ReapClients → Disconnected → HandleDisconnect →
StopTxOf → Invoke.

Проверка CanInvoke у адаптера от этого не спасает: она читает Stopping ДО
входа в Synchronize, а FStopping поднимается в начале Stop — значит клиент,
отвалившийся ровно в момент снятия галки «Enable TCI server» (или закрытия
приложения), успевает проскочить в это окно. Дальше тик-поток стоит в
Synchronize, главный — в FTickThread.WaitFor, и таймаута ни у того, ни у
другого нет: приложение висит намертво.

Общий JoinPumped: ждём Finished, прокачивая очередь (Sleep, если зовут не из
главного потока), и только потом WaitFor + FreeAndNil. Finished в FPC
взводится ПОСЛЕ DoTerminate, поэтому финальный WaitFor уже не может застать
чужой Synchronize и не блокирует.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:44:28 +03:00
ew8bakandClaude Opus 5 fe6fb9e292 fix(cat): ZZVB копировал VFO при опросе, KY писал в сокет без MSG_NOSIGNAL
Три замечания ревью в CAT-слое.

ZZVB. Команда была переписана под заглушку VAC (усиление приёма в
виртуальном кабеле, которого у нас нет), но заменили только комментарий —
тело осталось прежним, от старого ошибочного алиаса «обмен VFO». А ветка с
пустым суффиксом — это ФОРМА ОПРОСА: логгер, который просто спрашивает
«ZZVB;», молча затирал VFO B частотой VFO A, то есть терял сплит оператора и
даже не получал в ответ ошибку. Соседи по этому же диффу (ZZVA, ZZVG, ZZVS,
ZZAA, ZZAP, ZZBI, ZZBM, ZZDN, ZZMA, ZZMV, ZZQM, ZZOA, ZZPO, ZZSR) тело
получили, ZZVB — нет. Теперь это строчная заглушка в таблице, рядом с ZZVC и
ZZVD, которые про тот же несуществующий VAC.

ZZEB. Число полос эквалайзера принималось любое из 0..10 и уходило прямо в
TTXSettings.EQNumBands, то есть в СОХРАНЯЕМЫЙ TX-профиль. Потребители знают
ровно два случая: WDSPEngine.PushTXEQProfile ветвится на «3», редактор в
настройках — на 3 и 10. «ZZEB000…;» записывал ноль полос, всё прочее тихо
играло по полной 11-узловой кривой с чужой подписью. Принимаем только 3 и 10.

CATTcp.SendStr. Писал в сокет голым fpSend/send с флагами 0. В этой же ветке
WebUtils.SockSend получил MSG_NOSIGNAL ровно потому, что запись в закрытый
клиентом сокет иначе приходит как SIGPIPE, а он по умолчанию убивает процесс
целиком; обработчика сигнала в дереве нет. CAT про это забыли, а добавленный
здесь же цикл дозаписи расширил окно: длинный ответ (IF, ZZEB, список
режимов) уходит теперь несколькими send, и каждый может застать клиента уже
ушедшим. Пишем через WebUtils.SockSend — заодно ушла платформенная развилка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:44:14 +03:00
ew8bakandClaude Opus 5 84b90b23c0 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>
2026-08-19 22:46:37 +03:00
ew8bakandClaude Opus 5 66892fc7e0 fix(tci): пара «клиент+приёмник» только по факту передачи; перебор имён .part
[P1] FTxClient/FTxRx писались ДО Invoke, и команда, которая ничего не сделала,
всё равно их перебивала. Клиент, уже передающий с приёмника 1, шлёт
trx:0,true,tci — передатчик занят, SyncSetTRX не делает ничего (Started=False),
а маркеры ИДУЩЕЙ передачи с этого мига уходят под номером 0. MSHV такие блоки
отбрасывает (network.cpp:231), то есть передача просто замолкает. Теперь пара
назначается после Invoke и только при Started — тем же признаком, по которому
назначается хозяин эфира. Тот же гейт закрывает близнеца: trx:<N>,true без
',tci' поверх своей же передачи больше не снимает источник модуляции. Снятие
(',false') работает как прежде.

[P2] Имя временного файла (pid + счётчик) уникально внутри процесса, но не
между запусками: «.part», оставшийся от прошлой жизни (публиковать было
нечем), плюс повторно выданный системой pid дают EEXIST на создании — и
задание пропадало молча. TCICreateTempNear перебирает до 64 имён, но только
пока ошибка — «имя занято»: нет прав или каталога перебором не лечится.

Стенд test/tci: 219 проверок (было 217). Новое — занятое имя «.part» записи не
теряет (стенд занимает ровно то имя, которое возьмёт писатель) и пустая
команда TRX не меняет номер приёмника в маркерах. Негативный контроль на обе
правки. Попутно в тесте поправлены два комментария, описывавшие прежнюю
реализацию публикации.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:02:40 +03:00
ew8bakandClaude Opus 5 ecd42c32f8 fix(tci): передача с слайса — маркер TX_CHRONO под номером клиента, фронт rfTransmitting
Живой прогон с MSHV на втором слайсе (openHPSDR): приём в порядке, эфир по
trx:1,true,tci поднимается, а звук от клиента не доходит. Два независимых
дефекта, оба видны только на приёмнике > 0 — на rx0 передача работала, потому
стенд их и не ловил.

1. Маркеры TX_CHRONO уходили с receiver = 0 жёстко. MSHV шлёт TX-аудио ТОЛЬКО
   в ответ на маркер и фильтрует все входящие бинарные блоки по номеру
   приёмника первой же строкой обработчика (network.cpp: `if
   (pStream->receiver != tci_trx) return;`, ветка TxChrono там же и собирает
   блок). У клиента на слайсе tci_trx = 1, так что маркеры отбрасывались
   целиком. Теперь вместе с клиентом-модулятором запоминается номер приёмника
   из его же TRX (FTxRx), и маркеры идут под ним.

2. Changed(rfTransmitting) из SetTxSlice стирал TCIMicRequested. Контроллер
   шлёт это поле и просто как «перерисуй TX-бейджи», а адаптер понимал любой
   такой сигнал при FTransmitting = false как «передача кончилась». Приходил
   он посередине нашей же команды: SyncSetTRX ставит просьбу → RequestSliceTx
   → SetTxSlice → Changed → просьба стёрта → SetMOX выбирает микрофон уже без
   неё. В эфир шёл микрофон оператора (тишина), а TX-аудио клиента
   отбрасывалось — TCIMicActive не поднят. Теперь ловится фронт «было → стало»
   (FLastTxOn), а не всякое уведомление.

Стенд test/tci: 217 проверок (было 214), все зелёные. Новое — часть E, слайс
как приёмник 1: по trx:1,true,tci модуляция из TCI взята, маркеры TX_CHRONO
идут и названы номером 1. Негативный контроль разделён: каждая правка краснит
свою проверку.

Проверено на железе: MSHV на втором слайсе передаёт.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:47:30 +03:00
ew8bakandClaude Opus 5 3dc7035f9e fix(tci): писатель WAV — один поток с очередью, файл публикуется атомарно
SAVE запускал поток на каждую запись с FreeOnTerminate: его никто не держал
и никто не ждал. Замерено отдельным процессом — при штатном выходе сразу
после сохранения от ожидаемых 100000044 байт на диске оставалось 40960, а
заголовок заявлял полную длину; на медленном каталоге идущие подряд SAVE
плодили сотни потоков, чья память стеков в 128-МБ бюджет не входила.

Теперь писатель один и принадлежит адаптеру (лениво на первом SAVE), очередь
ограничена TCI_RECORD_MAX_JOBS = 16 (сверх — клиенту writer busy и возврат
резерва), а деструктор адаптера гасит его через TCIStopWriter: Close →
WaitDrained (без срока) → Free. Срока здесь нет намеренно: поток, стоящий в
write/fsync, изнутри процесса не останавливается (Terminate не указ, Free
обязан WaitFor, бросить живой TThread нельзя — он ходит в общий бюджет),
поэтому срок не ограничивал выход, а только терял подтверждённые клиенту
записи. Ограниченный выход = писатель отдельным процессом, одним TThread не
делается; это записано в коде и в доке.

Результат записи больше не игнорируется: FileWrite возвращает число байт и
при ошибке даёт 0/-1 без исключения, поэтому на полном диске файл спокойно
дописывался до конца огрызком. TCIWriteAll — цикл с проверкой каждого вызова,
плюс FileFlush перед публикацией (на ext4 с отложенным размещением ENOSPC
приходит именно там).

Файл появляется под целевым именем целиком или не появляется вовсе: данные
пишутся во временный файл рядом (эксклюзивно, не по симлинку), а публикует
их TCIPublishFile — renameat2(RENAME_NOREPLACE) напрямую через Do_SysCall,
если его нет — link + unlink, если нет и ссылок (FAT/exFAT, часть CIFS/SMB и
FUSE) — отказ с сохранением данных в .part. FileExists + rename не делается
нигде: это тот самый TOCTOU. На Windows — MoveFileW без REPLACE_EXISTING.

doc/TCI.md: §2.5 переписан (писатель-очередь, остановка, публикация); заодно
исправлено устаревшее описание склейки кусков в Take (её нет с 7aae0fd).

Стенд test/tci: 214 проверок (было 199), все зелёные. Новое — временный файл
убирается после удачи, неудачная запись не оставляет ни файла, ни .part, за
всё время записи 32 МБ целевое имя ни разу не видно незаконченным, отказ
сверх потолка заданий, после Close заданий не берут, TCIStopWriter дожидается
и самой записи, и хвоста очереди за ней. Все новые гарантии прогнаны
негативным контролем; путь link проверен сборкой с выключенным renameat2,
сам renameat2 — под strace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:40:31 +03:00
ew8bakandClaude Opus 5 7aae0fdfcd fix(tci): SAVE отдаёт писателю сами куски записи, а не сплошную копию
Потолок 128 МБ всё ещё пробивался примерно вдвое на пике: Take собирал
линейную копию ДО освобождения FChunks, поэтому при полном бюджете рядом
жили ~128 МБ кусков и ~128 МБ копии, а счётчик показывал 128. Передача
резерва писателю (9b77809) закрывала учёт, но не сам пик.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 13:18:20 +03:00
ew8bakandClaude Opus 5 9b77809a73 fix(tci,cat): резерв бюджета переезжает писателю; строгий разбор полей ZZ-команд
Три замечания по 79132f1.

1. [P2] Лимит памяти рекордеров обходился при SAVE. Take собирал сплошную
   копию записи, а DropData тут же возвращал исходные куски в бюджет —
   копия жила дальше в асинхронном TTCIWavWriter уже неучтённой. Один SAVE
   поднимал настоящее потребление примерно вдвое, а на медленном или
   зависшем сетевом каталоге очередь writer-потоков и их буферов росла
   мимо потолка вовсе. Теперь копия НАСЛЕДУЕТ резерв кусков, из которых
   собрана: Take отдаёт его out-параметром Reserved, писатель держит до
   конца записи и отпускает в ReleaseData — ровно один раз, сколько бы
   путей выхода ни было у Execute. Новых денег у бюджета копия не берёт,
   так что потолок теперь считает и очередь сохранений тоже.

2. [P2] Пять ZZ-команд проверяли длину поля, но не содержимое:
   StrToIntDef(s, 0) превращал любую нечисловую пару символов в индекс 0.
   ZZBSxx; переключал диапазон на нулевой вместо ?;, ZZBMxx; и
   ZZAUxx;/ZZBPxx; двигали VFO, ZZFIxx; выбирал фильтр 0. Разбор приведён
   к идиоме ZZFL/ZZFH: TryStrToInt, иначе ошибка формата.

3. [P3] Сообщение об отказе запуска TCI звало в Settings → Advanced, а
   настройки там уже не живут — вкладка CAT (переезд был в de0830f).

Стенд 194/194: Take отдаёт резерв размером с копию, после Take занят ровно
он, писатель возвращает его по окончании. Без фикса первая проверка
краснеет. GUI (--ws=qt6) и демон зелёные. doc/TCI.md §2.5 и сводка стенда,
doc/CAT_STATUS.md — таблица разбора.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:50:21 +03:00
ew8bakandClaude Opus 5 79132f133c fix(tci): рекордер линейного выхода — память под потолком, файл только в своём каталоге
Два дефекта уровня P1 в LINE_OUT_RECORDER_*. Авторизации в протоколе нет
(§3.1), bind наружу разрешён — значит «сколько стоит одна строка из сети»
это вопрос живучести процесса, а не аккуратности.

1. Неограниченное выделение памяти. CmdRecorder проверял только ValidRx
   (номер в потолке), а конструктор выделял буфер целиком: 300 с × 48 кГц
   × 2 канала × int16 = 57.6 МБ на команду, приёмников 1+MAX_SLICES=7, то
   есть 403 МБ семью строками. У мёртвого приёмника Feed не зовут — значит
   срок записи никто не проверял; уход клиента рекордеры не трогал вовсе;
   DropDeadRxStreams бежит только на rfDevice/rfConnected и смене карты
   слайсов, в покое не срабатывает. Память жила до остановки сервера.

   Лечение тремя замками:
   - память набирается кусками по секунде, START не стоит ни байта;
     куски не перевыделяются (никакого realloc в DSP-потоке) и склеиваются
     один раз в Take — уже после того, как рекордер вынут из таблицы;
   - общий бюджет TCI_RECORD_MAX_BYTES (128 МБ) на все рекордеры сразу,
     спрашивается на каждый кусок; отказ не рушит запись, набранное
     остаётся сохраняемым;
   - освобождение по трём событиям: START требует живого приёмника
     (RxActive, ответ receiver is not running), тик сервера подметает
     истёкшие окна по часам (SweepRecorders — окно закрывается от START
     и без единого блока звука), уход клиента забирает его записи
     (DropClientRecorders; рекордер живёт на приёмнике, но платит за него
     тот, кто нажал START).

2. Перезапись произвольного файла. Путь из сети уходил в fmCreate почти
   как пришёл — вместе с '..' и абсолютными путями. Теперь TCIRecordPath
   берёт из строки ТОЛЬКО имя файла, каталог — настроенный tci.record_dir
   (пусто = <каталог конфигурации>/records). Каталог из просьбы
   отбрасывается молча: полный путь на сервере клиенту всё равно
   бесполезен, файл ложится не на его машину. Имя валидируется (пусто,
   '.', '..', управляющие, ':', длиннее 120, расширение не .wav →
   bad file name); после ExtractFileName выйти за каталог нечем. Файл
   создаётся эксклюзивно (TCICreateNewFile: O_EXCL|O_NOFOLLOW на Unix,
   CREATE_NEW на Windows) — ни перезаписи, ни симлинка, без окна между
   FileExists и созданием; клиенту заранее file exists.

Попутно: MainForm.ApplyTCISettings собирал TTCISettings по полям с
чистого листа — новое поле RecordDir обнулялось бы при каждом применении
вкладки CAT. В uses TCIStreams Windows стоит первым намеренно: иначе его
TCriticalSection перекрыл бы SyncObjs (та же грабля, что в DX-кластере).

Стенд 189/189 (было 169): 11 проверок имени файла (/etc/passwd.wav,
../../.., D|\rec\a.wav), бюджет (START не выделяет, растёт кусками,
потолок, отказ не рушит запись, срок истекает без Feed), существующий WAV
не перезаписывается, START на мёртвом приёмнике и с чужим номером, и
сквозная проверка в части E — клиент стартует запись, набирает память
живым звуком через WDSP, рвёт TCP, бюджет возвращается к нулю. Без фиксов
новые проверки краснеют. GUI (--ws=qt6) и демон зелёные.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:30:36 +03:00
ew8bakandClaude Opus 5 adbb8d02ac fix(tci): Stream.length — вещественные отсчёты всего блока, а не на канал
MSHV не декодировал FT4 по TCI (по виртуальному кабелю — декодировал).
Регрессия из dc6f996: там length аудио был переведён на «сэмплы на канал»
по формулировке §4.3, а живые клиенты считают по нему БАЙТЫ блока
(network.cpp: `int cr2 = pStream->length*bit_s;` с шагом `chan*bit_s`,
на передаче `quint32 cr3 = pStream->length*bit_s;`). При Channels=2
(умолчание MSHV) клиент разбирал половину каждого блока: звук с дырами
50%, водопад шире и грязнее, декодер разваливался. В моно дефекта не
видно — единицы совпадают, поэтому первый прогон стенда увёл в сторону.

Правка верна и по документу, а не только по клиенту: §3.4 после IQ
(«количество вещественных отсчётов… комплексных = length/channels»)
говорит «аудиопоток приёмника ПОЛНОСТЬЮ ПОВТОРЯЕТ IQ поток» и
перечисляет ровно три отличия — каналы, формат сэмплов, число сэмплов в
пакете. Единиц length среди них нет, поле в struct Stream одно.
Развилка по типу потока была вычитана из воздуха.

§4.3 путает две величины, и её формулировка верна лишь для моно:
AUDIO_STREAM_SAMPLES — кадры НА КАНАЛ, Stream.length — отсчёты ВСЕГО
блока. Что arg1 считает кадры, видно из самой §4.3 дважды: минимум
512/256/128/100 на 48/24/12/8 кГц даёт обещанные «не меньше 10 мс»
только при счёте на канал, и потолок data[16384] = 2048 × 2 × float32.
TX_CHRONO замыкает круг: клиент шлёт столько отсчётов, сколько названо
в length маркера, и возвращает то же число обратно.

Размер блока не менялся (2048@48к = 42.7 мс). Гипотеза про клиппинг
тапа RX_AUDIO проверена замером и снята: пик 0.115.

Стенд: проверки length переписаны на инвариант «байт = length × размер
отсчёта»; новый test/tci/ft4_bench.py снимает поток TCI и PipeWire-
источник одновременно, режет на нарезки FT4 по общим часам и гоняет
через настоящий jt9 --ft4 (блок разбирает КАК MSHV — этим и поймал).
Проверено вживую: MSHV декодирует FT4 по TCI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:26:29 +03:00
ew8bakandClaude Opus 5 bb48f5d3fa feat(tci): приёмник = слот слайса; TRX/TUNE адресуют передатчик; стенд «как MSHV»
Модель приёмников переделана: приёмник TCI — это СЛОТ СЛАЙСА, а не панадаптер.
rx0 — главный тракт (каналы A/B = VFO A/B), rx N — слайс слота N−1, то есть
буквы B..G с флага, на каком бы пане он ни стоял. Панорама стала свойством
приёмника: от неё берутся DDS (у слайса только чтение) и поток IQ.

Почему: у Pluto панорама ровно одна (MaxPans = 1), и второй приёмник там
существует ТОЛЬКО как слайс главного пана — при нумерации по панам он был
недоступен вовсе, а TRX_COUNT навсегда равнялся единице. С другого конца —
клиенты: у MSHV в настройках всего «TCI Client rx1/rx2», то есть приёмники 0 и
1, третьего номера ввести некуда. Со слотами правило «первый созданный слайс =
приёмник 1» держится на любом железе: на openHPSDR слайс второго пана и на
Pluto слайс главного одинаково занимают слот B. Номер совпадает с буквой на
экране и с портом слайс-CAT. Цена: панорама без слайсов из TCI пропала, а
второй слайс пана перестал быть «каналом B» и стал своим приёмником — у канала
B в протоколе только частота, IF и громкость, у приёмника же всё.

TRX/TUNE раньше игнорировали arg1 (номер передатчика) целиком: клиент доп.
приёмника уводил в эфир слайс ОПЕРАТОРА — чужая частота, а с кросс-бандовым
мультислайс-TX и чужой диапазон, с чужими антенной и фильтрами; trx:9,true жал
PTT. Теперь номер разбирается и проверяется, приёмник N > 0 идёт через
RequestSliceTx (та же дверь, что у CAT-порта слайса: «в эфире только один» и
Auto TX), у контроллера появился параметр Tune для TUN тем же путём. Чужую
передачу не трогаем вовсе — ни источник модуляции, ни тон: SetMOX(True) поверх
идущей передачи не выходит рано, а заново выбирает микрофон. Хозяином эфира
клиент становится, только если передача началась именно от его команды, и
решает это Sync-метод в потоке контроллера (снимок «шла ли передача», взятый в
потоке клиента, врал: между разбором и исполнением влезает PTT оператора).

Разбор исходников MSHV (он фильтрует ВСЕ строки и бинарные блоки по номеру
приёмника) дал ещё три правки:
  * ответ на TRX/TUNE адресуется номером АВТОРА, состояние в нём — «в эфире
    именно твой слайс»; в рассылку идёт номер реально передающего;
  * tx_enable рассылается каждому живому приёмнику со своим номером и входит в
    картину нового приёмника — без этого у MSHV молча мёртвая PTT
    (set_ptt начинается с `if (!tci_tx_enable) return;`);
  * про несуществующий приёмник молчим целиком (LiveRx), в том числе на чтение:
    ответ «vfo:1,0,0» MSHV принимал бы за конец инициализации.

Попутно, вне TCI: SendDUCSpecificFromSettings трогала FNetwork без Assigned, а
зовут её по любому PTT/TUN (SetMOX → SyncCWKeyer → она) — до подключения
устройства это была Access violation, у TCI её глотал обработчик команды.

Стенды: новый test/tci/mshv_sim.py — точная копия логики клиента MSHV, отвечает
на вопрос «почему он не подключается» одной строкой (на живом приложении
воспроизвёл ошибку инициализации для rx2 до правки). tcitest — 158/158, в
сквозном прогоне добавлено создание слайса на главном пане: он становится
приёмником 1, отвечает на vfo:1,0, слушается командой, отдаёт аудио с
receiver = 1 и замолкает после удаления.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:10:02 +03:00
ew8bakandClaude Opus 5 dc6f996e70 fix(tci): дефекты живого прогона — length аудио, маршруты тапов, MOX, рекордер, EOF сокета
Восемь дефектов, найденных прогоном настоящего TCI-клиента (три приёмника:
NFM, DIGU, FMRAW) и его отчётом.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:10:29 +03:00
ew8bakandClaude Opus 5 de0830fd19 feat(tci): этап 2 — бинарные потоки IQ/аудио, TX-аудио и запись линейного выхода
Реализованы все четыре потока §3.4 и рекордер:

  • RX_AUDIO_STREAM — тап ДО громкости и мьюта (OnDemodAudioReady): скиммеру
    и цифре нужен звук приёмника, а не то, что осталось после ручки;
  • LINEOUT_STREAM — тап ПОСЛЕ (OnAudioReady), то есть что слышно;
  • IQ_STREAM — тап сырого IQ в движке, ОДИН вызов на накопленный блок;
  • TX_AUDIO_STREAM + TX_CHRONO — TRX:0,true,tci берёт модуляцию из потока
    клиента (флаг TCIMicRequested впереди web в SetMOX), маркеры времени идут
    из тика по часам, аудио клиента разворачивается в 48 кГц моно в тот же
    ринг, что и web-микрофон;
  • LINE_OUT_RECORDER_* — кольцо int16 на приёмник, WAV пишет отдельный поток.

Тапы аудио в контроллере многоадресные (AddAudioTap): слушают, ничего не
забирая, в отличие от OnAudioConsume, которым владеет web. Блоки нарезает и
раскладывает по кольцам клиентов сам DSP-поток, в сокет пишет поток клиента —
та же дисциплина, что у команд. Порядок локов везде FSliceLock → FStreamLock.
У очереди команд и кольца блоков разная политика переполнения: команду терять
нельзя, блок потока — можно (теряется самый старый).

Пересчёт частоты многоступенчатый (TCIStreams). Одноступенчатый FIR на верхнем
пресете Pluto (5760 кГц, коэффициент 120) упирался в потолок отводов и давал
завал 1.3 дБ в полосе при подавлении зеркала 16 дБ — то есть поток IQ с
мусором. Теперь коэффициент раскладывается на множители, спецификацию фильтра
каждой ступени задаёт ИТОГОВАЯ полоса, а свёртка идёт со сложением
симметричных пар: −83 дБ на любом коэффициенте, ≈10% ядра на 5.76 МГц.

Согласование частот с железом: из пресетов Pluto 576 и 960 кГц на 384 не
делятся, поэтому отдаём наибольшую ЗАКОННУЮ частоту, делящую источник нацело
(576/960 → 192 кГц). Ответ на IQ_SAMPLERATE называет достижимое, а не просьбу
клиента, и переобъявляется без запроса при смене rate и устройства.

Приёмный буфер соединения 4 → 32 КБ: блок TX-аудио это 64 байта заголовка плюс
data[16384], а кадр крупнее буфера не собирается никогда.

Настройки TCI переехали из Advanced на вкладку CAT, справа от TCP CAT Server:
это такой же канал внешнего управления трансивером.

Стенд (scratchpad, tcitest.pas): 112 проверок, все зелёные — включая сквозной
прогон через живой WDSP (синтетический IQ → блоки RX-аудио и IQ у настоящего
WS-клиента, и обратно TX-аудио клиента → блоки TX-IQ).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:36:50 +03:00
ew8bakandClaude Opus 5 4165cbe9a5 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>
2026-08-17 22:53:32 +03:00
ew8bakandClaude Opus 5 84c9e60b93 fix(tci): жизненный цикл, синхронизация слайсов и WebSocket по RFC
Разбор ревью ветки. Критичное — четыре отказа жизненного цикла и один
пробел синхронизации.

Use-after-free стора спотов: FTCIAdapter освобождается ДО FDXStore.
Команда SPOT/SPOT_DELETE, пришедшая между их гибелью, обращалась к
освобождённой памяти.

Bind-адрес: TCIParseIPv4 стал строгим (out + Boolean, ровно четыре
октета 0..255). Кривой адрес — отказ поднимать сокет, а не молчаливый
INADDR_ANY: авторизации в TCI нет. В UI порт и адрес применяются по
уходу фокуса и по Close, а не на каждую букву — набор «127.0.0.1» по
дороге проходил через «127.0.0.» и открывал порт наружу.

Остановка при висящем Synchronize: флаг Stopping (адаптер не начинает
новых Invoke), прокачка CheckSynchronize в цикле ожидания Stop и запрет
освобождать клиента, чей поток не вышел. Владение переделано: клиента
освобождает только тик-поток (ReapClients), клиентский лишь помечает
себя закрытым.

Отправка больше не блокирует вызывающего: Send/Broadcast кладут строку
в очередь клиента, в сокет пишет тик-поток вне общего лока, склеивая
очередь в общие кадры. Медленный клиент морозил UI на таймаут отправки
за каждое движение ручки VFO; теперь он просто вылетает.

Слайсы: в контроллере появилось rfSliceState (нагрузка — FSliceFreqId),
его шлют сами сеттеры слайса; SyncSetVfo зовёт SliceFreqChanged, как
CAT. Адаптер разворачивает Id в пару (приёмник, канал) и рассылает
состояние именно этого канала, а не канала 0 каждого пана.

WebSocket по RFC 6455: маска обязательна, FIN/continuation собираются,
RSV и незнакомые opcode рвут соединение, control-кадры ≤125 и только
целиком, 64-битная длина не сворачивается в отрицательный Integer,
пустой Sec-WebSocket-Key получает 400. Хвост пакета handshake больше не
выбрасывается — первая команда не теряется. Клиент после исключения в
разборе не остаётся висеть в массиве.

Ещё: DSP и squelch доп. приёмников читаются и пишутся из TCtrlSlice
(парные сеттеры сохраняли соседние поля значениями главного тракта);
параметры потоков — в TTCIClient, они клиентские по спецификации; эхо
под своим локом; ApplySettings возвращает результат, отказ старта виден
оператору; инициализация объявляет только существующие каналы;
SET_IN_FOCUS реализован через OnFocusRequest.

Осознанно не сделано и записано в doc/TCI.md §3.1: AGC_GAIN для
приёмников >0 (AGC-T один на тракт), цвет спота, KEYER, TX_FOOTSWITCH,
арбитраж нескольких клиентов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:49:31 +03:00
ew8bakandClaude Opus 5 82f0e4f771 feat(tci): TCI 2.0 — EWSDR как сервер (команды и уведомления)
Протокол Expert Electronics поверх WebSocket, порт 40001. EWSDR слушает,
клиенты — логгеры, скиммеры, цифровые программы.

- TCIProtocol.pas — чистый слой протокола: разбор/сборка `имя:арг;`,
  экранирование `^ ~ *`, словарь видов связи, пересчёт громкости в дБ.
- TCIServer.pas — WS-сервер поверх WebUtils/WsClient: accept-поток,
  поток на клиента, HTTP-Upgrade, фреймы, рассылка, тик 20 мс.
- TCIAdapter.pas — мост к TRadioController по схеме CAT: геттеры читают
  поля напрямую, сеттеры через Invoke, уведомления через AddStateListener.
  Маппинг: приёмник TCI = панадаптер, канал A/B = VFO A/B (пан 0) либо
  первый/второй слайс (паны 1..).

Попутно: TRadioController.RemoveStateListener (адаптер умирает раньше
контроллера) и TDXSpotStore.RemoveCall (spot_delete).

Настройки — секция "tci" в settings.json (умолчание: выключено,
127.0.0.1, так как авторизации в протоколе нет) и вкладка
Advanced → TCI Server.

Бинарные потоки (IQ/аудио) — этап 2. Статус, таблица команд и список
осознанных эхо-заглушек — в doc/TCI.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:22:49 +03:00
ew8bak 92c01fa2c6 Merge feature/cat-tx-cw: CAT-команды TX-тракта и телеграфа, ревизия алиасов и подписей, починка транспортов 2026-08-17 13:15:14 +03:00
ew8bakandClaude Opus 5 20bf3c1f39 fix(cat): транспорты — короткая запись в TCP и молчаливый отказ serial-порта
CATTcp.SendStr делал один send на ответ. TCP не обязан отдавать весь буфер за
раз, а усечение здесь не ошибка — длинный ответ (IF, ZZEB, список режимов) мог
уехать обрезанным, и молча. Дописываем остаток в цикле.

CATSerial помечал порт активным ДО SerOpen, а открытие шло внутри потока: при
отказе порт навсегда оставался «работающим» в ActiveCount и UI, а причина
нигде не оседала. Открытие переехало в TCATSerialPort.Start и делается
синхронно, так что отказ виден сразу — FActive остаётся False, причина в новом
свойстве LastError. Поток теперь только читает, закрывает владелец в Stop.

Заодно Andromeda-порт назначался по галке в настройках, без оглядки на то,
поднялся ли порт: HasAndromeda рапортовал о панели, которой нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:13:46 +03:00
ew8bakandClaude Opus 5 27a8d1d5cf feat(cat): TX-тракт и телеграф; ревизия алиасов, подписей и валидации
Со времён прошлой правки CAT в EWSDR появились TX-профили со всей «звуковой»
цепью и телеграф — команды, которые doc/CAT_STATUS.md объявлял незакрываемыми,
стали рабочими.

TX-тракт: MG/ZZMG (усиление микрофона), MO/ZZMO + ZZTM (монитор передачи и его
уровень), ZZTL/ZZTH (кромки TX-фильтра), PR/ZZPK + ZZPL (речевой компрессор),
ZZET + ZZEB (эквалайзер), ZZTO + ZZTU (мощность и кнопка настройки), ZZUT
(2TON), ZZLI + ZZUS (PureSignal), ZZTP (выбор профиля), ZZFD (девиация).

Телеграф (были проброшены только KS/KY/ZZKS/ZZKY): ZZCS скорость, ZZCL тон,
ZZCI иамбик, ZZCB/ZZCD break-in и hang-time, ZZCM сайдтон.

Ревизия подписей. Комментарии у заглушек писались по буквам кода, а не по
Thetis, и врали примерно в 150 местах. Хуже: часть РАБОТАЮЩИХ команд была
привязана не к своей функции — внешний софт получал осмысленный, но неверный
ответ, что хуже честной заглушки. Всё сверено с CATCommands.cs:

  ZZMA  режим        -> кнопка MUT            ZZNN  заглушка -> SNB
  ZZRX  переход в RX -> аттенюатор RX1        ZZNS  SNB      -> кнопка NR2
  ZZRV  версия ПО    -> напряжение питания    ZZVS  алиас ZZSP -> операции с VFO
  ZZMV  индекс режима-> счётчик памяти        ZZBM  режим    -> VFO B вниз на nn
  ZZKM  режим кейера -> запуск CW-макроса     ZZFT  всегда A -> TX-частота (split)
  ZZFI/ZZBS  были алиасами -> сами держат фильтр и диапазон

Ошибочные алиасы на громкость/мощность сняты (ZZAA ZZVG ZZOA ZZAP ZZDN ZZAC
ZZBI ZZMB ZZVA ZZPD ZZPO ZZQM ZZSR ZZRD ZZRU) — теперь честные заглушки с
указанием, где функция живёт на самом деле.

Кенвудовские команды сверены отдельно, покрытие полное (все 40 из Thetis),
ширины полей совпадают с CATStructs.xml. Исправлено: SM отдавал 4 цифры вместо
5; RD/RU перестраивали VFO, хотя это RIT; KY не срезал набивку поля пробелами
и гнал её в эфир паузами; CT принимала любой символ и «CT9;» молча гасил тон;
OF/OS несли реализацию сами, а ZZOT/ZZOS были заглушками — клиент Thetis
обращается как раз к ZZ* и не получал ничего.

Валидация. Команды без параметров не проверяли суффикс: «TXanything;» доходил
до CmdTX и ПОДНИМАЛ ПЕРЕДАЧУ (так же RX UP DN BD BU QI RC ID IF) — эталон
отбраковывает лишний суффикс в парсере, у нас теперь список в IsParamless.
ZZTX поднимал передачу на любом значении кроме нуля («2=TUNE» — выдумка).
ZZFL/ZZFH принимали поле любой длины от 4 символов и через StrToIntDef молча
схлопывали кромку в ноль. ZZMG принимал 1-2 символа. KY/ZZKY не ограничивали
текст 25 символами, и очередь передачи могла расти произвольно.

Осознанные отклонения от эталона сведены в отдельную таблицу документа:
ZZBS (индекс диапазона вместо кода), ZZMN (имя режима вместо пресетов
фильтров), ZZST (шаг FM вместо размера шага настройки), ZZCD (потолок 2000 мс —
наш предел, он же в поле HangDelay пакета DUC Specific).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:13:33 +03:00
ew8bakandClaude Opus 5 f7bed07831 refactor(controller): единая дверь SetTXSettings для правки TX-настроек
Применение TX-настроек (WDSP, DUC Specific при смене mic-битов/ATT, живое
обновление TUN, запись в активный профиль, персист) жило в обработчике
MainForm — то есть было недоступно ничему, кроме вкладки Transmit. CAT и web
по архитектуре ходят только в контроллер и дотянуться туда не могли.

Логика переехала в TRadioController.SetTXSettings по образцу уже
существовавшего SetCWSettings; MainForm делегирует ей и оставляет себе только
рендер. Notify=False у формы — иначе rfTXProfile перезагрузил бы вкладку
Transmit прямо под руками у того, кто её правит.

Заодно SetTXMonVolume: громкость self-monitor'а адресно, вне TX-контекста
(слайдер добирается до неё только на передаче, CAT-клиенту такой контекст
не нужен).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:12:10 +03:00
ew8bak f89f202a34 Merge feature/dxcluster-spots: споты DX-кластера на панадаптерах, мода по бэндплану, кнопка DX в RX 2026-08-17 11:29:04 +03:00
ew8bakandClaude Opus 5 6ed1be3dc7 fix(dxcluster): резолв имени, гейт логина, TTL и мелочи UI по итогам ревизии
Сеть и резолв (DXClusterClient):
- Резолв ушёл в отдельный поток: системный резолвер блокирующий и не
  прерывается, а сокета в этот момент ещё нет — Stop из UI-потока висел на
  DNS-таймауте. Сессия ждёт квантами по 200 мс, просыпаясь на FStopEvent;
  Stop теперь отрабатывает за 100-200 мс в любой фазе.
- Реестр запросов: по одному резолверу на имя, не больше DX_MAX_RESOLVERS.
  Не дождавшись, сессия оставляет запрос в реестре и на следующей попытке
  цепляется к нему же (иначе зависший резолвер плодил бы вечные потоки).
  Слотов несколько, чтобы смена адреса работала поверх зависшего прежнего;
  литеральный IP разбирается до реестра — ввод адреса руками обязан работать
  всегда. Запись живёт по счётчику ссылок, исключение в резолвере не
  оставляет слот занятым.
- getaddrinfo вместо netdb.ResolveHostByName: тот ходит в DNS сам и
  /etc/hosts не читает вовсе (getent находит localhost, ResolveHostByName —
  нет), т.е. локальный алиас кластера не работал. Заодно реентерабельно.
- WSAStartup перенесён в initialization, WSACleanup убран: Stop не ждёт
  резолвер, а тот может сидеть в gethostbyname.

Логин (DXClusterClient):
- Команды пользователя больше не уходят в незавершённый логин: очередь
  разбирается только после post-login, SendCommand говорит в лог, что
  команда ждёт.
- ONLINE не по таймеру, а по существу: LoginSettled требует ответа сервера
  после учётки (с потолком молчания), пароль ждёт своего приглашения.
  Подтверждение ставится ПОСЛЕ разбора куска, а не на приход байтов —
  иначе исход логина зависел от границ TCP-пакетов.
- Приглашения и отказы: строгий детектор (текст, заканчивающийся
  двоеточием) и для отправки пароля, и для вердикта — по вхождению слова
  пароль улетал командой от строки приветствия. Повтор приглашения пароля
  или позывного = отказ авторизации (dxsError, без реконнекта); опоздавшее
  приглашение после слепой отправки позывного отказом не считается.
  Эхо уже отвеченного приглашения гасится окном в одну строку.
- Ошибка отправки post-login рвёт сессию, а не только цикл команд;
  неотправленная очередь возвращается на следующее соединение.

UI и данные:
- QSY по споту крутит активный VFO, а не всегда A (MainForm).
- TTL спотов чистится тиком независимо от видимости оверлея (DXSpotStore
  .Purge + ServiceDXCluster).
- Выделение в окне списка держится по позывному И частоте: один позывной
  живёт на разных диапазонах (DXClusterForm).
- Кнопка DX правит и отложенную копию настроек, иначе debounce SETUP
  возвращал прежнее состояние подписей (MainForm).

Проверено на фейковом кластере: приглашения с CRLF и без, границы
TCP-пакетов, отказ по паролю и по позывному, опоздавшее приглашение,
ловушки ложного срабатывания.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:00:41 +03:00
ew8bakandClaude Opus 5 eb4af61fad ui(spectrum): полоса главного фильтра — как у слайсов, со своим оттенком
Главный фильтр рисовался иначе, чем слайсовые полосы, причём в CPU-пути
сильнее, чем в GL.

1. Порядок отрисовки. Кромки, несущая и её треугольник у главного шли ПОСЛЕ
   кривой спектра, у слайсов — до неё, а в GL-вьюхе весь блок фильтров идёт
   перед DrawSpectrumCurve. Из-за этого на CPU главная полоса единственная
   лезла поверх сигнала и выглядела жирнее и ярче слайсовых при тех же цвете и
   толщине линий. Блок перенесён к слайсам, до кривой: порядок кадра теперь
   одинаков у обоих путей.

2. Заливка. Была НЕПРОЗРАЧНОЙ, рядом с просвечивающими слайсовыми смотрелась
   плотным блоком. Теперь та же полупрозрачность, что у слайсов; прозрачность
   вынесена в общие SPEC_BAND_ALPHA/SPEC_BAND_ALPHA_TX (VfoOverlay, рядом с
   палитрой слайсов) — раньше это были четыре магических числа по двум вьюхам.
   Осиротевшая FillBandRaw удалена.

3. Цвет. С полупрозрачностью полоса перестала читаться: SpecFilter (34,78,106)
   почти совпадает с нижним цветом фонового градиента спектра (31,79,108), и
   заливка давала не оттенок, а затемнение — над низом градиента выходило
   ровно (32,79,108), то есть фон. Полосе дан свой цвет темы SpecFilterBand:
   тёплый янтарь RGB(255,150,40) на тёмной (фон сине-стальной, тёплое на нём
   читается) и насыщённый зелёный RGB(60,150,60) на светлой. SpecFilter
   остался подложкой подписей AGC — иначе перекрасились бы и они.

4. Слайс B перекрашен из оранжевого в васильковый RGB(97,118,255): с янтарной
   полосой главного фильтра оранжевый стал неразличим. Оттенок выбран по
   свободному месту в круге: заняты 0°(TX), 31°(полоса), 55°(E), 132°(A),
   190°(C), 195°(G), 276°(F), 308°(D); самый широкий промежуток 195..276°,
   середина ≈236°. Палитра одна на всё, поэтому вместе с полосой и несущей
   перекрашиваются буква слайса на спектре и бейдж во флаге VfoOverlay.

Сборка ewsdr (--ws=qt6) и ewsdrd — ОК. На экране не проверено: нужен эфир со
слайсами; цвета правятся одной строкой (SpecFilterBand в AppTheme,
SLICE_COLORS в VfoOverlay).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:14:59 +03:00
ew8bakandClaude Opus 5 d0c82ade73 ui(spectrum): вертикаль несущей не перечёркивает букву полосы фильтра
Буква слайса стоит по центру полосы фильтра у самого верха, и вертикаль
несущей шла туда же — от Y=0. В SSB/CW это не мешало (несущая лежит у кромки
полосы, буква — по центру), а в модуляциях с несущей (AM/FM/DSB) центр полосы
и есть несущая, и линия шла прямо по букве.

Одна функция на оба вида — CarrierTopY (CPU) / CarrierTopYGL (GL): смотрит,
попадает ли вертикаль под глиф, и если да, отдаёт Y ПОД буквой. Решение по
факту пересечения, а не по списку «модуляций с несущей»: список пришлось бы
вести и сопровождать, а в SSB зазор и так выходит нулевым сам собой.

- Слайсы: DrawSliceFilterLinesRaw и DrawSliceFilterMarkers.
- Главный VFO: вместе с линией вниз уезжает и её «шляпка» — треугольник в CPU
  (у RawTriangleDown появился параметр верхней координаты) и метка в GL. Без
  этого они остались бы поверх буквы и зазор не помог бы.
- Полоса TX при split сверяется с той же буквой — она нарисована на RX-полосе.
- В GL буква главного флага теперь рисуется ПОСЛЕ линий, как в CPU: порядок
  наложения у путей должен совпадать.

Кегль буквы вынесен в BAND_LETTER_FONT: рисование и расчёт зазора обязаны
брать его из одного места, иначе зазор разъедется с глифом.

Метрика проверена замером (Qt6, offscreen): глиф 8 bold = 7x12 px ⇒ вертикаль
стартует с Y=14, по горизонтали зазор ±6 px от центра полосы. Правится
константами CLEAR_X/CLEAR_Y рядом с функцией.

Сборка ewsdr (--ws=qt6) и ewsdrd — ОК. На экране не проверено: нужен эфир с
AM/FM и слайсами.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:21:08 +03:00
ew8bakandClaude Opus 5 13b46f0b7d ui(dxcluster): системный шрифт вместо захардкоженного 'Courier New'
По проекту гарнитуры не задаём — работает системный шрифт (907c2c0 выпилил
'Courier New' из flat-контролов, 19babef из статус-бара). Новый код DX-кластера
это правило нарушал в трёх местах, все три введены в a2fdc3a.

- DXSpotOverlay: подписи спотов на спектре рисуются системным шрифтом —
  гарнитура убрана совсем. Моноширинность там ни к чему: позывные не в
  колонках, ширина чипа и так меряется по TextWidth. Размер по-прежнему задан
  через Font.Height от высоты ряда — иначе подпись не влезала бы при DPI.
- DXClusterForm: список спотов и лог соединения ОСТАЮТСЯ моноширинными
  осознанно (колонки частота/позывной/мода/UTC/спотер выровнены пробелами в
  FormatSpotLine, пропорциональный шрифт их развалит), но имя теперь
  платформенное — MONO_FONT = Consolas/Monospace, как в CWTerminalForm и в
  полосе CW у PanafallPanel. 'Courier New' на Linux не существует вовсе,
  fontconfig молча подменял его.

Font.Size/Font.Style не трогал: их проект оставляет и после перехода на
системный шрифт (в StatusBar размер сохранён).

Дифы всех четырёх коммитов ветки проверены по Font.Name — других мест, где
DX-код задаёт гарнитуру, нет. Сборка ewsdr (--ws=qt6) — ОК.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:43:36 +03:00
ew8bakandClaude Opus 5 84f710476f fix(dxcluster): сборка под win64 — Windows.pas перекрывал TCriticalSection
Сборщик под win64 падал в конструкторе клиента кластера: «identifier idents no
member Create». Windows.pas объявляет TCriticalSection как ЗАПИСЬ (алиас
TRTLCriticalSection), а в uses он стоял ПОСЛЕ SyncObjs — и поле FLock, и
TCriticalSection.Create разрешались в запись. На Linux эта ветка uses не
компилируется вовсе, поэтому баг жил с первого коммита DX-кластера (a2fdc3a)
и всплыл на первой же сборке под Windows.

Windows.pas убран из uses совсем: сокетам он не нужен, всё даёт WinSock2 —
так же сделано в WsClient (там тоже FLock: TCriticalSection) и HPSDRNetwork.
Причина подтверждена минимальным репро, дающим дословно тот же текст ошибки.

Сверено по исходнику winsock2.pp (FPC 3.2.2), чтобы следующая итерация
сборщика не встала на следующем имени: connect/socket/select/getsockopt/
setsockopt/FD_SET/FD_ZERO/WSAStartup/WSACleanup/inet_addr/gethostbyname/htons,
типы TWSAData/TSockAddrIn/TFDSet/TTimeVal/PHostEnt и константы INADDR_NONE/
SOL_SOCKET/SO_ERROR/SO_KEEPALIVE/WSAE* — все на месте. Указательная форма
connect(@Addr, …) тоже корректна: в winsock2 есть обе перегрузки.

Заодно FNV-хэш подписи набора спотов обёрнут в {$Q-}{$R-}: он живёт
переполнением, а проверки правятся в .lpi (в Release их сейчас нет).
Порядок «Windows после SyncObjs» проверен по всему проекту — больше нигде.

Сборка ewsdr (--ws=qt6) и ewsdrd — ОК, тест бэндплана зелёный. Win64 проверит
сборщик.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:32:59 +03:00
ew8bakandClaude Opus 5 d12d8239da feat(dxcluster): мода спота по бэндплану, когда комментарий молчит
В CW и SSB спотеры сплошь и рядом не пишут моду, и такой спот оставался
dxmUnknown: ни фильтр по модам его не видел, ни QSY по нему моду не ставил.
Теперь порядок такой: сначала комментарий (как было), и только если он молчит —
участок бэндплана.

DXSpotStore.DXModeFromFreq — таблица участков, читается сверху вниз, первое
попадание выигрывает. Узкие «водопои» цифры стоят РАНЬШЕ широких сегментов:
FT8 на 7074 живёт посреди телефонного участка R1, FT4 на 21140 — посреди
15-метрового, и без такого порядка они утонули бы в SSB.

★ Границы — по IARU Region 1 (наш регион), а споты прилетают со всего мира,
поэтому там, где регионы расходятся, мода НЕ выводится вовсе: пропуск честнее
ошибки. Отсюда дырки 1843-1850 (R1 телефон против R2 CW), 60 м (канальный),
маячные щели, 2 м/70 см кроме FT8-окон. Два спорных куска всё же отданы R1:
3570-3600 — цифре (у R2 это ещё CW, но споты там почти сплошь FT8/RTTY) и
7053-7300 — телефону (у R2 ниже 7125 данные).

QO-100 (даунлинк 10489.5-10490.0) — отдельной веткой по плану AMSAT-DL, теми же
границами, что рисует BandPlanOverlay: CW, NB/DIGI, две SSB-зоны. Маяки и
mixed modes не гадаем.

Угаданное помечено (TDXSpot.ModeGuessed) и в окне списка выводится с '?' —
'CW?' против 'CW': спотер моду не называл, а на границах участков таблица
может ошибаться, поэтому разницу видно. На спектре в чипе только позывной —
там ничего не изменилось.

Проверено консольным тестом на этих же юнитах: 25 частот + 6 полных строк
кластера (комментарий перебивает таблицу; 7012.5 → CW guessed, 7145 → SSB
guessed, 10489.680 → SSB guessed; 1846, 60 м и 144.300 остаются без моды).
Сборка ewsdr (--ws=qt6) и ewsdrd — ОК.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:13:28 +03:00
ew8bakandClaude Opus 5 17fa965aec feat(dxcluster): споты на всех панадаптерах + кнопка DX в блоке RX
Подписи спотов рисовались только на главном пане. Теперь их видит каждый пан,
в чьё окно попадает частота спота.

Оверлей — ЭКЗЕМПЛЯР НА ПАН, а не один общий: кэш полосы подписей ключуется
центром/спаном/шириной, а у панов они свои — общий оверлей пересобирался бы на
каждый пан каждый кадр, то есть ровно то, ради чего кэш и заводился. Живёт в
TPanafallPanel (AttachDXSpots/DetachDXSpots/DXOverlay, Owner=панель), база
спотов по-прежнему одна на всех. FDXSpotOverlay в MainForm стал алиасом на
оверлей пана 0 — как FSpecView для FPan.View: настройки, тема и тик затухания
идут общим циклом по FPans, и главный пан перестал быть особым случаем (иначе
тумблер DX гасил бы подписи только на нём).

★ Version стора глобальна, а окно у пана своё, поэтому в EnsureRendered на
смену версии сначала берётся снимок окна и сверяется его подпись (FNV-1a по
позывному, частоте, моде и метке времени). Совпала — ни пересборки битмапа, ни
перезаливки GL-текстуры: спот, севший на чужой диапазон, до этого пана не
доходит. Без этого цена пересборки множилась бы на число панов. Снимок берётся
один раз и переиспользуется раскладкой — стор второй раз не дёргаем.

Клик по подписи на пане N = QSY слайса ЭТОГО пана (активного, если он здесь,
иначе первого; нет ни одного — создаём в точке) с модой по комментарию
кластера и полосой пресета этой моды. Своего VFO у панов N нет, а ретюнить их
DDC под спот нельзя — увезло бы весь пан. Мода спота → режим вынесена в общую
DXSpotRadioMode для обоих путей.

Низкий пан (грид): полоса подписей и штрихи пропускаются целиком — раньше в
GL-пути полоса легла бы поверх спектра, а штрихи пошли бы снизу вверх.

Кнопка DX переехала из тулбара в блок RX левой панели, сразу после CTUN (ряд
стал четырёхколоночным: CTUN | DX | Channel | BEACON): споты — часть приёмного
вида, а не глобальная команда уровня DISCOVER/START/SETUP. Заодно снят расчёт
SafeLeft, резервировавший под неё место в шапке.

Подписи по умолчанию ВЫКЛЮЧЕНЫ (show_spots: дефолт и фолбэк чтения были True),
состояние переживает перезапуск. Клик по кнопке подливает состояние в открытый
SETUP: там своя копия конфига, и первая же правка любого поля страницы вернула
бы подписи обратно.

fix: Stop клиента кластера больше не держит главный поток. Он делал Terminate +
WaitFor, а поток в этот момент сидит в блокирующем recv и просыпается лишь по
своему кванту (до 1 с), на застрявшем send — до SO_SNDTIMEO. Всё это время
окно висело на выходе и на переподключении после правки настроек. Теперь Stop
рвёт живой сокет SockShutdown (хэндл — зеркало под тем же локом, поток снимает
его ДО close, поэтому shutdown по закрытому fd невозможен), а FormDestroy зовёт
неблокирующий RequestStop первой строкой: поток доживает параллельно с
разборкой радио, и join в конце уже никого не ждёт.

Сборка ewsdr (--ws=qt6) и ewsdrd — ОК. На железе не проверено: доп. паны
требуют подключённого радио.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:00:08 +03:00
ew8bakandClaude Opus 5 a2fdc3a741 feat(dxcluster): споты DX-кластера на панадаптере
Telnet-клиент DX-кластера, база спотов и подписи позывных прямо на спектре
на своих частотах. Разложено на три слоя, как бэндплан и лупа маяка.

DXClusterClient.pas — один рабочий поток: резолв, неблокирующий connect с
select квантами по 200 мс (Stop не ждёт таймаут соединения), логин позывным,
чтение строк, реконнект с backoff. Приглашения логина И пароля ловятся в
незавершённом хвосте буфера — типичный telnet-prompt приходит без CR/LF.
Ошибки recv отличаются от таймаута кванта (EAGAIN/EINTR/WSAETIMEDOUT), иначе
на ECONNRESET поток крутился бы в пустом цикле вместо реконнекта. Отправка
дописывает частичный send. LCL-free.

DXSpotStore.pas — потокобезопасная база: дедуп по позывному, TTL, потолок
записей, монотонный Version. Единственная точка обмена потока с UI: никакого
Synchronize, UI сам замечает правки по Version, как оверлеи — по ключам кэша.

DXSpotOverlay.pas — рендер по модели BandPlanOverlay/VfoOverlay: кэшируется
только полоса подписей (W × BandH) в key-color битмап, пересборка строго по
dirty-ключу, на кадр — один keyed-композит. Штрихи от полосы до низа спектра
рисует вызывающая сторона теми же примитивами, что и прочие маркеры: CPU —
RawVLine внутри RawBegin/RawEnd, GL — DrawLine по готовому списку X/цвет.
GL берёт тот же битмап текстурой и заливает её только при смене RenderVersion.
Пересекающиеся подписи раскладываются лесенкой, цвет гаснет с возрастом,
свой позывной выделен; палитра парная под тёмную и светлую тему.

DXClusterForm.pas — окно списка: споты, лог соединения, строка команды
кластеру (диалект set/filter у всех свой — не угадываем). Двойной клик или
Enter = QSY. Данные тянутся поллингом по Version/LogVersion.

Интеграция: кнопка DX в тулбаре (ЛКМ — подписи на спектре, ПКМ — окно),
клик по подписи спота = QSY с автовыбором моды по комментарию кластера,
страница SETUP → DX Cluster с персистом в секции "dxcluster". Правки SETUP
прилетают посимвольно, поэтому запись конфига, TTL стора и переподключение
откладываются до паузы в наборе — иначе набор позывного стоил бы шесть
реконнектов, а промежуточный TTL «3» необратимо выбросил бы споты.

Частота спота кладётся как есть и сравнивается с GetViewWindow: на QO-100
кластеры постят downlink 10489.xxx, что совпадает со шкалой пана само собой.

Побочно в общих юнитах: WebUtils.SockSetRcvTimeout, FlatMemo.OnChange,
FlatListBox.OnKeyDown, BlendBitmapKey вынесен в interface VfoOverlay (одна
копия дворд-блендера на проект).

Проверено на локальном фейковом кластере: логин и пароль по prompt без CR/LF,
разбор спотов (включая QO-100), уход в RETRY по RST, Stop за 200 мс на
висящем connect. На железе рендер не проверялся.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:47:26 +03:00
ew8bak ab848404b1 Merge feature/beacon-zoom-panel: лупа наведения на маяк QO-100
Окно Beacon получило карточку BEACON TUNING: узкий кусок спектра вокруг маяка в
высоком разрешении плюс мини-водопад. На спектрограмме 576к маяк занимает пару
пикселей и ткнуть в него для наведения декодера почти нельзя — здесь то же
наведение делается по широкому следу. Наведение с главной спектрограммы не
тронуто.

Увеличение делает сам WDSP: второй analyzer на том же IQ главного тракта, со
своей FFT и ассиметричным span-clip, посчитанным прямо из границ окна в герцах.

Вошло:
- лупа с кликом-наведением, зумом колесом, панорамированием и FOLLOW;
- автопоиск маяка (AUTO) по min-hold — критерий «непрерывный след», а не
  «самый громкий»;
- состояние захвата прямо в лупе: полоса приёма меняет начертание на локе,
  рядом SNR и расхождение с опорной частотой;
- окно лупы переживает перезапуск (сдвиг от опорной, а не абсолют);
- контур больше не принимает за маяк голую несущую — проверка BPSK по
  констелляции;
- взведённый клик снимается при любом наведении, не только мышью;
- попутно: пропадавшая расстройка тона в статусе терминала телеграфа ('%+d'
  в Format Object Pascal не работает).

На эфире проверено частично: наведение и картинка — да, автопоиск и новый
критерий лока — нет.
2026-08-14 13:03:27 +03:00
ew8bakandClaude Opus 5 c30c23a64b fix(beacon): взведённый клик снимается при любом наведении, не только мышью
FBeaconArming гасился только в обработчике клика по спектру. Навёл AUTO из окна
лупы — флаг остался взведён, и следующий случайный ЛКМ по панораме утаскивал
маяк на пустое место. Те же грабли были для наведения из web и CAT.

Снимаем в обработчике rfBeaconLock, как только BeaconDecodeFreqHz стал
ненулевым — кто навёл, роли не играет. FSpectrumDirty просим только в этот
момент: маркеры и так едут вместе с кадрами, а на стопе спектр статичен, дёргать
перерисовку 10 раз в секунду незачем.

Сборка ewsdr (--ws=qt6) — ОК.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:01:42 +03:00
ew8bakandClaude Opus 5 8569236214 feat(beacon): автопоиск маяка в окне лупы (кнопка AUTO)
Лупа сделала прицеливание возможным, но целиться всё равно надо руками. AUTO
находит маяк сам, в текущем окне лупы: что видно, то и обыскивается — заодно
зумом можно сузить область поиска.

Критерий — не «самый громкий», иначе поймается первая же SSB-станция. Копим
min-hold по кадрам лупы (~1.6 с): у маяка несущая непрерывна и минимум остаётся
высоким, а речь, телеграф и цифра в паузах проваливаются к шуму и срезаются
минимумом. Это тот самый «непрерывный след», который видно на водопаде, только
числом.

По накопленному: шумовой пол = медиана, кандидат = максимум скользящего среднего
шириной с полосу приёма (±450 Гц) — среднее по полосе, а не отдельный бин, потому
что маяк это плато ~900 Гц, и по такой мере он выигрывает у узкой пораженки с той
же высотой пика. Порог 6 дБ над полом; центр уточняется центроидом превышения,
чтобы наведение село на середину плато.

- Тумблер рядом с FOLLOW, состояние в настройках (beacon_zoom_auto).
- На локе автопоиск молчит: в контур не лезем.
- После наведения выдержка ~3 с — дать декодеру попробовать захватиться.
- Нашли там же, где уже стоит наведение (ближе 150 Гц) — не трогаем.
  BeaconSeedAtHz дёргает Reseed, и без этой проверки при слабом сигнале
  автопоиск сбрасывал бы захват по кругу, мешая декодеру сойтись.
- В накопление идут только свежие кадры: таймер формы (25 Гц) быстрее
  анализатора (15 к/с), и повторный учёт кадра сокращал бы окно наблюдения,
  ради которого min-hold и заведён.

В строке захвата появляется пометка AUTO — и только пока автопоиск реально
работает: на локе она гаснет, чтобы не обещать действие, которого нет.

Сборка ewsdr (--ws=qt6) и ewsdrd — ОК. На эфире не проверено.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:01:26 +03:00
ew8bakandClaude Opus 5 51599b2457 fix(beacon): контур принимал за маяк любую узкую несущую
Наводишься на соседнюю станцию — и контур честно берёт её за маяк и тащит
LOError, подтягивая чужой сигнал к 10489.750.

Решение принималось по CarrierLock и SNR, а CarrierLock = FCarPresent and
FFreqLocked. FCarPresent — prominence сильнейшей линии в squaring-FFT: возведение
в квадрат схлопывает модуляцию BPSK ±180° в одну линию на 2·fc, но такую же линию
даёт любой сигнал с узкой составляющей — телеграф, пораженка, остаток несущей у
цифры. FFreqLocked — Costas держит фазу, а на немодулированную несущую он садится
идеально: ровный тон это BPSK без переходов. SNR считается как (E|I|)²/E[Q²], и
захваченная Costas'ом несущая лежит целиком на оси I — Q пустой, SNR выходит
отличный. То есть чужой сигнал не просто проходил порог, он выглядел лучше маяка.

Различаем по констелляции: у настоящего BPSK точки ходят между двумя сгустками
±I (данные после свёрточного кодера равновероятны), у несущей все в одном.
BeaconIQBalance считает долю меньшинства по знаку I, EMA сглаживает, порог 0.15
на взятие лока и 0.08 на сброс — гистерезис, как у SNR. Кольцо в 256 точек при
400 Bd набирается за ~0.6 с, EMA добавляет столько же: чужой сигнал отваливается
примерно за секунду, а не за десяток, как ждать кадр FEC.

FBeaconBal обнуляется вместе с FBeaconLocked при каждом наведении и при включении
лока — после клика BPSK-ность доказывается заново, унаследовать её от прошлой
цели нельзя.

Скорость символов признаком служить не может: FWsym зажат в ±0.02, поэтому
S.SymRate конструктивно всегда 392..408 Bd, что бы декодер ни слушал.
Непрерывный цифровой сигнал с настоящей модуляцией баланс пройдёт — от него
защитит только декодированный кадр AO-40.

Сборка ewsdr (--ws=qt6) и ewsdrd — ОК. На эфире не проверено.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:59:46 +03:00
ew8bakandClaude Opus 5 504265f8ad fix(cw): в статусе терминала пропадала расстройка тона
Format(', tone %+d Hz', ...) печатал ', tone d Hz' — без значения. Флага '+' в
Format Object Pascal нет, синтаксис здесь %[индекс:][-][ширина][.точность]тип;
встретив '%+', FPC съедает процент с плюсом и копирует остаток спецификатора
как обычный текст. Знак ставим руками, отрицательные %d печатает со своим
минусом сам.

Найдено попутно — тот же промах был в новом коде лупы маяка.

Сборка ewsdr (--ws=qt6) — ОК.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:13:33 +03:00
ew8bakandClaude Opus 5 a5daf33cc9 feat(beacon): состояние захвата видно прямо в лупе
Чтобы понять «попал или нет», приходилось переводить взгляд на плитки метрик
под графиком. Теперь это читается в самой лупе:

- Полоса приёма показывает захват начертанием: в поиске три линии пунктирные,
  на локе — сплошные и стянуты сверху скобой. Новых цветов не добавлено, та же
  семантика $0020D0FF, что и на главном спектре.
- Строка захвата под частотой центра: LOCK · 14.2 dB (зелёная) / SEARCH (янтарь)
  / BEACON OFF. Состояние берётся из BeaconState — это гистерезисный
  FBeaconLocked, которому доверяет сам контур коррекции, а не мгновенный
  CarrierLock: тот мерцал бы на границе.
- Δ от опорной рядом: расхождение измеренной частоты маяка с опорной, то есть
  расстояние между оранжевым и зелёным маркерами числом. Это невязка, которую
  лок стекает в LOError; в пределах дедбэнда (20 Гц) — зелёная, иначе янтарная.

Знак у Δ ставится вручную: флага '+' в Format Object Pascal нет (это printf'изм),
'%+.0f' молча съедается и в строку попадает голый хвост спецификатора — на
экране было «Δ .0f Hz» вместо значения.

Состояние и SNR снимаются раз в тик в PollZoom и кладутся в поля: копия
TBeaconScope в отрисовку не лезет, а SNR сравнивается с допуском 0.2 дБ —
иначе дрожание в сотых долях дБ пересобирало бы кадр каждый тик и dirty-флаг
потерял бы смысл.

Сборка ewsdr (--ws=qt6) — ОК.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:13:33 +03:00
ew8bakandClaude Opus 5 e82f573313 feat(beacon): окно лупы переживает перезапуск
Ширина, положение и состояние FOLLOW уезжали в дефолт при каждом открытии
окна: настроил 5 кГц вокруг маяка, закрыл — в следующий раз снова 40 кГц.

Положение хранится СДВИГОМ от опорной частоты маяка, а не абсолютом. Абсолют
протух бы от любой правки опорной частоты, а главное — калибровка LOError
копится от сеанса к сеансу и двигает шкалу, так что сохранённая абсолютная
частота через неделю указывала бы уже не туда.

- Settings: BeaconZoomSpanHz/OffsetHz/Follow в TGlobalSettings (ключи
  beacon_zoom_span/offset/follow), дефолты и чтение/запись по общему пути.
- RadioController: FBcnZoom* с клампом при загрузке (span 600..200000 Гц,
  сдвиг ±500 кГц) — руками испорченный JSON окно не сломает.
- BeaconScopeForm: RestoreZoomState в DoShow, StoreZoomState каждый тик из
  PollZoom. Раз состояние пишется в одном месте, оно не разъедется, каким бы
  путём ни менялось — колесом, drag'ом, кнопками или автоподтяжкой FOLLOW.
  Наведение декодера за краем восстановленного окна подтянет сам FOLLOW на
  первом тике, отдельной логики не потребовалось.

Сборка ewsdr (--ws=qt6) и ewsdrd — ОК.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:03:13 +03:00
ew8bakandClaude Opus 5 ac216fa332 fix(beacon): трасса лупы не рисовалась до первого клика по маяку
Перерисовку спектра лупы гейтил не тот сигнал: возврат GetBeaconZoomSpectrum
(«в кэше лежит непрочитанный кадр») игнорировался — функция звалась как
процедура, — а FZoomDirty ставился только по смене границ окна и по сдвигу
маркеров. Пока никто не панорамирует, границы не меняются, а до клика все три
маркерные частоты нулевые и неподвижные: кадр собирался один раз и застывал.

Клик задаёт наведение декодера, дальше BeaconDecodeFreqHz непрерывно едет за
несущей в ServiceBeaconLock — сравнение с FLastDecHz начинает срабатывать
каждый тик, и спектр «оживает». Водопад работал с самого начала: он всегда шёл
по своему честному флагу свежести, хотя слой тот же самый и анализатор один.

Теперь свежесть кадра — основной триггер (трасса идёт 15/с, как и даёт
анализатор); проверки окна и маркеров оставлены как дополнительные, они дают
отклик на зум/пан не дожидаясь следующего кадра.

Сборка ewsdr (--ws=qt6) — ОК.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:54:13 +03:00
ew8bakandClaude Opus 5 705da75c26 feat(beacon): лупа наведения на маяк QO-100 в окне Beacon
На спектрограмме 576к центральный маяк — пара пикселей, ткнуть в него для
наведения декодера почти нельзя. В окне Beacon появилась карточка BEACON
TUNING: узкий кусок спектра вокруг маяка в высоком разрешении + мини-водопад,
клик по нему = BeaconSeedAtHz, ровно как клик по главному спектру. Наведение
с главной спектрограммы не тронуто.

Увеличение делает сам WDSP, а не растяжка бинов главного дисплея: второй
analyzer BCN_DISP_ID=4 на том же IQ главного тракта, со своей FFT и своим
span-clip. Клипы fscLin/fscHin считаются прямо из желаемых границ окна в
герцах, а не из зум-слайдера — анализатор умеет ассиметричный клип, поэтому
окно ставится в любое место полосы захвата, а не только вокруг центра DDC.

- WDSPEngine: SetBeaconZoom + GetBeaconZoom{Spectrum,Waterfall}. Analyzer живёт
  только пока окно маяка открыто; весь доступ к disp'у под FBcnLock (SetAnalyzer
  из UI, Spectrum0 из DSP-потока, GetPixels из display-потока). Кадр (1024
  точки) фиксирован по размеру — ресайз окна не переармирует analyzer и не
  морозит спектр на наполнение FFT-окна.
- Span-clip НЕ удешевляет FFT — она по всей полосе захвата. Отсюда потолок
  262144 и своя пониженная частота кадров BCN_ZOOM_FPS=15 через ovrlp: на 60 fps
  это было бы ядро под нагрузкой ради картинки, в которой ничего не меняется.
  Переарм по смене sample rate, детектор/усреднение — без переарма.
- RadioController: SetBeaconZoomWindow / GetBeaconZoom* / GetCaptureWindow /
  BeaconZoomBinHz. Наружу — абсолютные display-Гц, те же, в которых живут
  BeaconSeedAtHz и маркеры главного спектра.
- BeaconScopeForm: клик = наведение, колесо = зум вокруг курсора, drag = пан,
  двойной клик = центрировать, кнопки -/+/RESET/FOLLOW (FOLLOW подтягивает окно
  только когда маркер ушёл за край, а не возит картинку постоянно). Маркеры —
  теми же цветами и с той же семантикой, что DrawBeaconMarkersRaw/GL: опорная
  пунктиром, центроид, три линии полосы приёма ±450 Гц, без полупрозрачной
  заливки. Водопад — TWaterfallView с палитрой/гаммой из контроллера.
- Рендер по конвенции проекта: кадр лупы собирается в офскрин только по
  FZoomDirty (новый кадр анализатора, сдвиг маркеров/окна, ресайз, тема), Paint
  = блит + курсор. Констелляция с метриками переведена туда же: статичная
  обвязка (карточки, оси, рамки и подписи плиток) кэшируется в FScopeChrome и
  пересобирается лишь на ресайз/смену темы — раньше десяток RoundRect и TextOut
  рисовались заново 25 раз в секунду прямо в обработчике Paint.

Лупа рисуется на CPU: GL-путь в проекте есть только у главного панафолла и
только под ключом, все прочие панели — обычные TPaintBox.

Сборка ewsdr (--ws=qt6) и ewsdrd — ОК. На железе не проверено.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:42:25 +03:00
ew8bakandClaude Opus 5 b0a93ddb14 Merge feature/cw: телеграф (кейер прошивки + программный), терминал и декодер CW
Телеграф на openHPSDR P2 и Pluto/LibreSDR: кейер в FPGA (тайминги в железе) и
программный кейер для бэкендов без него, pitch как ЕДИНЫЙ сдвиг гетеродина в
движке, сайдтон, CWX/F1..F8, CAT KY/KS/ZZKM, терминал с набором с клавиатуры и
декодером приёма. Голос в CW заблокирован, реле T/R и антенна ходят за ключом
прошивки, запрет передачи (RX-only XVTR, DoNotTx) действует и на кейер FPGA.

Попутно: окно повторов Specific-пакетов только со старта (e87ca6b), правки в
трансвертере больше не травят память КВ-диапазона (827902f), перестройка
слайса по CAT внутри полосы захвата обновляет флаг (00860e5).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:47:57 +03:00