From b62369ec6b7b7a0c149273f31bbf2e9614abe03d Mon Sep 17 00:00:00 2001 From: Vladimir Date: Thu, 20 Aug 2026 11:21:58 +0300 Subject: [PATCH] =?UTF-8?q?fix(tci):=20callsign=5Fsend=20=D1=81=D0=BB?= =?UTF-8?q?=D0=B0=D0=BB=20=D1=80=D0=B0=D0=B7=D0=BC=D0=BD=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BE=D0=B7=D1=8B=D0=B2=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9,=20tci=5Ferror=20=E2=80=94=20=D0=BD=D0=B5=D1=8D?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=B8=D0=BC=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два расхождения со спекой, оба в том, что уходит клиенту. 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 --- TCIAdapter.pas | 62 +++++++++++++++++++++++++++++++------------------- doc/TCI.md | 19 ++++++++++++---- 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/TCIAdapter.pas b/TCIAdapter.pas index 78dbf9d..a599747 100644 --- a/TCIAdapter.pas +++ b/TCIAdapter.pas @@ -334,7 +334,7 @@ type // ── Отдельные команды (чтобы HandleCommand не превратился в простыню) ── procedure CmdFreq(Client: TTCIClient; const M: TTCIMessage; IsIF: Boolean); - procedure CmdCWMacros(const M: TTCIMessage; IsMsg: Boolean); + procedure CmdCWMacros(Client: TTCIClient; const M: TTCIMessage; IsMsg: Boolean); procedure CmdKeyer(Client: TTCIClient; const M: TTCIMessage); procedure CmdSpot(const M: TTCIMessage); public @@ -1728,14 +1728,15 @@ begin if IsIF then Reply(Client, StrIf(Rx, Ch)) else Reply(Client, StrVfo(Rx, Ch)); end; -procedure TTCIAdapter.CmdCWMacros(const M: TTCIMessage; IsMsg: Boolean); +procedure TTCIAdapter.CmdCWMacros(Client: TTCIClient; const M: TTCIMessage; + IsMsg: Boolean); // CW_MACROS:trx,текст; CW_MSG:trx,префикс,позывной,суффикс; // Разметку TCI приводим к тому, что понимает передатчик текста ewsdr: // |ABBR| — слитная передача (у нас такой команды нет) → скобки снимаем; // < / > — шаг скорости ±5 wpm (передатчик работает на одной скорости) → снимаем; // CALL$N — повтор позывного N раз. var - Text, Prefix, Call, Suffix, Rep: string; + Text, Prefix, Call, Suffix, Rep, Final: string; P, N, i: Integer; begin if IsMsg then @@ -1749,9 +1750,13 @@ begin if Prefix = '_' then Prefix := ''; if Suffix = '_' then Suffix := ''; + // Финальный вариант позывного — ДО развёртки повторов: клиенту он нужен + // как позывной (§3.2.2), а не как «RA6LH RA6LH». + Final := Call; P := Pos('$', Call); if P > 0 then begin + Final := Trim(Copy(Call, 1, P - 1)); Rep := Copy(Call, P + 1, MaxInt); Call := Copy(Call, 1, P - 1); N := StrToIntDef(Trim(Rep), 1); @@ -1788,9 +1793,14 @@ begin FLock.Leave; end; - // Позывной ушёл в эфир целиком — подтверждаем финальный вариант (§3.2.2). - if IsMsg and (Call <> '') then - FServer.Broadcast(TCIBuild('callsign_send', [Call])); + // §3.2.2: подтверждение финального варианта позывного — АВТОРУ сообщения, а + // не всем (у остальных клиентов своих сообщений нет). ★Расхождение со спекой, + // осознанное: документ шлёт эту команду по факту окончания передачи позывного, + // а наш передатчик текста моментов внутри очереди не отмечает — подтверждаем + // сразу. Доотправка позывного (cw_msg:arg1;) всё равно не поддержана, так что + // финальный вариант известен уже здесь и позже не изменится. + if IsMsg and (Final <> '') then + Reply(Client, TCIBuild('callsign_send', [TCIEscape(Final)])); end; procedure TTCIAdapter.CmdKeyer(Client: TTCIClient; const M: TTCIMessage); @@ -1814,19 +1824,20 @@ var begin if not TCITryArgInt(M, 0, Rx) or not ValidRx(Rx) then begin - Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad receiver'])); + Reply(Client, TCIBuild('tci_error', + [TCIEscape(LowerCase(M.Name)), 'bad receiver'])); Exit; end; // Номер объявлен потолком железа, но пана под ним может не быть — как у TRX. if (Rx > 0) and not RxActive(Rx) then begin Reply(Client, TCIBuild('tci_error', - [LowerCase(M.Name), 'receiver is not running'])); + [TCIEscape(LowerCase(M.Name)), 'receiver is not running'])); Exit; end; if not TCITryArgBool(M, 1, Down) then begin - Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad state'])); + Reply(Client, TCIBuild('tci_error', [TCIEscape(LowerCase(M.Name)), 'bad state'])); Exit; end; if not TCITryArgInt(M, 2, Ms) then Ms := 0; @@ -2001,7 +2012,8 @@ begin begin if not TCITryArgInt(M, 0, Rx) or not ValidRx(Rx) then begin - Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad receiver'])); + Reply(Client, TCIBuild('tci_error', + [TCIEscape(LowerCase(M.Name)), 'bad receiver'])); Exit; end; // Номер объявлен (TRX_COUNT = потолок железа), но пана под ним может не @@ -2010,7 +2022,7 @@ begin if (Rx > 0) and not RxActive(Rx) then begin Reply(Client, TCIBuild('tci_error', - [LowerCase(M.Name), 'receiver is not running'])); + [TCIEscape(LowerCase(M.Name)), 'receiver is not running'])); Exit; end; // Ключ захвата (§3.5) — общий на радио, а не на приёмник: передатчик один, @@ -2539,8 +2551,8 @@ begin end; if M.Name = 'KEYER' then begin CmdKeyer(Client, M); Exit; end; - if M.Name = 'CW_MACROS' then begin CmdCWMacros(M, False); Exit; end; - if M.Name = 'CW_MSG' then begin CmdCWMacros(M, True); Exit; end; + if M.Name = 'CW_MACROS' then begin CmdCWMacros(Client, M, False); Exit; end; + if M.Name = 'CW_MSG' then begin CmdCWMacros(Client, M, True); Exit; end; if M.Name = 'CW_MACROS_STOP' then begin FLock.Enter; @@ -3066,7 +3078,7 @@ begin if not TCITryArgInt(M, 0, Rx) or not ValidRx(Rx) then begin Reply(Client, TCIBuild('tci_error', - [LowerCase(M.Name), 'bad receiver'])); + [TCIEscape(LowerCase(M.Name)), 'bad receiver'])); Exit; end; @@ -3086,7 +3098,7 @@ begin if not RxActive(Rx) then begin Reply(Client, TCIBuild('tci_error', - [LowerCase(M.Name), 'receiver is not running'])); + [TCIEscape(LowerCase(M.Name)), 'receiver is not running'])); Exit; end; StartStream(Client, K, Rx); @@ -3108,7 +3120,8 @@ var begin if not TCITryArgInt(M, 0, Rx) or not ValidRx(Rx) then begin - Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad receiver'])); + Reply(Client, TCIBuild('tci_error', + [TCIEscape(LowerCase(M.Name)), 'bad receiver'])); Exit; end; @@ -3120,7 +3133,7 @@ begin if not RxActive(Rx) then begin Reply(Client, TCIBuild('tci_error', - [LowerCase(M.Name), 'receiver is not running'])); + [TCIEscape(LowerCase(M.Name)), 'receiver is not running'])); Exit; end; if not TCITryArgInt(M, 1, Sec) then Sec := TCI_RECORD_MAX_SEC; @@ -3159,7 +3172,8 @@ begin Req := TCIUnescape(TCIArg(M, 1)); if Req = '' then begin - Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'no file name'])); + Reply(Client, TCIBuild('tci_error', + [TCIEscape(LowerCase(M.Name)), 'no file name'])); Exit; end; // MP3 у нас кодировать нечем — молча подсунуть WAV с расширением .mp3 хуже, @@ -3168,21 +3182,22 @@ begin if SameText(ExtractFileExt(Req), '.mp3') then begin Reply(Client, TCIBuild('tci_error', - [LowerCase(M.Name), 'only wav is supported'])); + [TCIEscape(LowerCase(M.Name)), 'only wav is supported'])); Exit; end; // ★Каталог всегда наш, из просьбы берётся одно имя файла — см. TCIRecordPath. Path := TCIRecordPath(RecordDir, Req); if Path = '' then begin - Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad file name'])); + Reply(Client, TCIBuild('tci_error', + [TCIEscape(LowerCase(M.Name)), 'bad file name'])); Exit; end; // Существующий файл не трогаем (окончательно это решит O_EXCL в писателе, // здесь — только чтобы клиент услышал причину). if FileExists(Path) then begin - Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'file exists'])); + Reply(Client, TCIBuild('tci_error', [TCIEscape(LowerCase(M.Name)), 'file exists'])); Exit; end; @@ -3216,7 +3231,8 @@ begin // тут же и пишется), но возвращаем на всякий случай: единственный путь, // на котором запись не доходит до писателя. TCIRecBudgetFree(Rec.Reserved); - Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'nothing recorded'])); + Reply(Client, TCIBuild('tci_error', + [TCIEscape(LowerCase(M.Name)), 'nothing recorded'])); Exit; end; // Пишет отдельный поток: файл может быть в десятки мегабайт, а мы сейчас в @@ -3228,7 +3244,7 @@ begin // Очередь полна (медленный диск) — запись отдать некому, значит и её // место в бюджете держать больше незачем. TCIRecBudgetFree(Rec.Reserved); - Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'writer busy'])); + Reply(Client, TCIBuild('tci_error', [TCIEscape(LowerCase(M.Name)), 'writer busy'])); end; end; diff --git a/doc/TCI.md b/doc/TCI.md index 8686eea..14dc3c7 100644 --- a/doc/TCI.md +++ b/doc/TCI.md @@ -85,10 +85,15 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─ том и в другом случае наверх уходит `OnDisconnect` (единая точка `Disconnected`): иначе после остановки у адаптера оставались висеть захваты параметров ушедших клиентов. -3. **`Stop` ждёт выхода клиентских потоков без таймаута** и прокачивает при +3. **`Stop` ждёт выхода ВСЕХ своих потоков без таймаута** и прокачивает при этом очередь `Synchronize`. Останавливает сервер поток контроллера (UI), а клиентский поток в этот момент может висеть как раз на `Invoke` в него же: - без прокачки это взаимный клин. Выйти по таймауту нельзя — следом + без прокачки это взаимный клин. ★Это относится и к accept- с тик-потоком + (общий `JoinPumped`), а не только к клиентским: тик-поток ходит в контроллер + через `ReapClients` → `Disconnected` → `Invoke`, и проверка `CanInvoke` от + клина не спасает — она читает `Stopping` ДО входа в `Synchronize`, так что + клиент, отвалившийся ровно в момент остановки, успевает проскочить в это + окно. Выйти по таймауту нельзя — следом освобождаются и клиенты, и сам сервер с адаптером, а не вышедший поток вернулся бы в эту память. Поэтому: `Stopping` (адаптер новых `Invoke` не начинает) + закрытые сокеты + повторный `shutdown` раз в полсекунды, и @@ -703,7 +708,13 @@ ExpertSDR3 давно бы не было. Текст приводится к тому, что понимает передатчик текста ewsdr (`CWXSend`): экранирование `^ ~ *` снимается, `CALL$N` разворачивается в N повторов -позывного, префикс/суффикс `_` считаются пустыми. **Не поддержано:** шаг +позывного, префикс/суффикс `_` считаются пустыми. Подтверждение +`callsign_send` уходит **автору сообщения** и содержит позывной ДО развёртки +повторов (`RA6LH`, а не «RA6LH RA6LH»). ★Расхождение со спекой, осознанное: +документ шлёт эту команду по факту окончания передачи позывного, а наш +передатчик текста моментов внутри очереди не отмечает — подтверждаем сразу. +Доотправка всё равно не поддержана, так что финальный вариант известен уже +здесь и позже не изменится. **Не поддержано:** шаг скорости внутри текста (`<` / `>`) и слитная передача аббревиатур (`|SK|`) — эти символы просто снимаются, потому что `TCWSender` работает на одной скорости и не знает прос-знаков. Доотправка позывного (`cw_msg:arg1;`) @@ -999,7 +1010,7 @@ running`), а сам телеграф уходит **текущему TX-ист движков и сети валится с AV — клиент получает `tci_error`, соединение живо) и неразрывность пачки инициализации под крутящейся ручкой. -### Стенд этапа 2 (бинарные потоки) — 238 проверок, все зелёные +### Стенд этапа 2 (бинарные потоки) — 244 проверки, все зелёные Отдельная программа (`test/tci/tcitest.pas`, прогон — `test/tci/run.sh`, внешних библиотек не требует) проверяет потоки на четырёх уровнях: