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>
This commit is contained in:
2026-08-17 16:49:31 +03:00
co-authored by Claude Opus 5
parent 82f0e4f771
commit 84c9e60b93
6 changed files with 960 additions and 276 deletions
+29 -3
View File
@@ -844,6 +844,7 @@ type
// отдавать клавиатуру). // отдавать клавиатуру).
procedure TCIFormActivate(Sender: TObject); procedure TCIFormActivate(Sender: TObject);
procedure TCIFormDeactivate(Sender: TObject); procedure TCIFormDeactivate(Sender: TObject);
procedure TCIFocusRequest;
procedure ApplyGridParams(RefLevel, Range, GridStep: Double); procedure ApplyGridParams(RefLevel, Range, GridStep: Double);
// Пушит активные grid-параметры (RX или TX в зависимости от FTransmitting) // Пушит активные grid-параметры (RX или TX в зависимости от FTransmitting)
// в FSpecView и сбрасывает кэш сетки. Вызывается при смене RX↔TX и при // в FSpecView и сбрасывает кэш сетки. Вызывается при смене RX↔TX и при
@@ -1428,6 +1429,11 @@ begin
FDXPending := False; FDXPending := False;
FController.FSettings.SaveDXClusterSettings(FDXPendingCfg); FController.FSettings.SaveDXClusterSettings(FDXPendingCfg);
end; end;
// TCI гасим ПЕРЕД базой спотов: его клиенты кладут споты (SPOT/SPOT_DELETE/
// SPOT_CLEAR) прямо в FDXStore из своих потоков, и команда, пришедшая между
// гибелью стора и гибелью адаптера, обратилась бы к освобождённой памяти.
// Destroy останавливает сервер и дожидается его потоков.
FreeAndNil(FTCIAdapter);
for i := 0 to MAX_PANS - 1 do for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then FPans[i].DetachDXSpots; if FPans[i] <> nil then FPans[i].DetachDXSpots;
FDXSpotOverlay := nil; FDXSpotOverlay := nil;
@@ -1456,7 +1462,7 @@ begin
FreeAndNil(FWebAdapter); // адаптер не владеет сервером — освобождаем после Stop FreeAndNil(FWebAdapter); // адаптер не владеет сервером — освобождаем после Stop
FWebServer.Free; FWebServer.Free;
FreeAndNil(FCATAdapter); // его Destroy останавливает+освобождает CAT движок/транспорты FreeAndNil(FCATAdapter); // его Destroy останавливает+освобождает CAT движок/транспорты
FreeAndNil(FTCIAdapter); // Destroy останавливает TCI-сервер и его потоки // (TCI освобождён выше — до базы спотов)
// Ядро освобождаем последним — его Destroy закрывает и освобождает движки // Ядро освобождаем последним — его Destroy закрывает и освобождает движки
// (FreeEngines) и FSettings. // (FreeEngines) и FSettings.
FreeAndNil(FController); FreeAndNil(FController);
@@ -2719,7 +2725,13 @@ begin
// InitDXCluster: споты от TCI-клиентов кладутся в тот же стор. // InitDXCluster: споты от TCI-клиентов кладутся в тот же стор.
FController.FSettings.LoadTCISettings(FTCICfg); FController.FSettings.LoadTCISettings(FTCICfg);
FTCIAdapter := TTCIAdapter.Create(FController, FDXStore); FTCIAdapter := TTCIAdapter.Create(FController, FDXStore);
FTCIAdapter.ApplySettings(FTCICfg); FTCIAdapter.OnFocusRequest := TCIFocusRequest;
// Порт может быть занят (второй экземпляр, чужая программа) — молчать об
// этом нельзя: галка стоит, а сервера нет.
if not FTCIAdapter.ApplySettings(FTCICfg) then
ShowMessage('TCI server failed to start on ' + FTCICfg.BindAddr + ':' +
IntToStr(FTCICfg.Port) + '.' + LineEnding +
'Port busy or address invalid — check Settings → Advanced.');
OnActivate := TCIFormActivate; OnActivate := TCIFormActivate;
OnDeactivate := TCIFormDeactivate; OnDeactivate := TCIFormDeactivate;
@@ -6576,7 +6588,21 @@ begin
FTCICfg.Port := Port; FTCICfg.Port := Port;
FTCICfg.BindAddr := BindAddr; FTCICfg.BindAddr := BindAddr;
FController.FSettings.SaveTCISettings(FTCICfg); FController.FSettings.SaveTCISettings(FTCICfg);
if FTCIAdapter <> nil then FTCIAdapter.ApplySettings(FTCICfg); if FTCIAdapter = nil then Exit;
// Отказ (порт занят, адрес не разобран) показываем сразу: иначе оператор
// останется с галкой «включено» и мёртвым сервером.
if not FTCIAdapter.ApplySettings(FTCICfg) then
ShowMessage('TCI server failed to start on ' + BindAddr + ':' +
IntToStr(Port) + '.' + LineEnding +
'Port busy or address invalid.');
end;
// SET_IN_FOCUS от TCI-клиента: логгер просит поднять окно программы.
// Вызывается в UI-потоке (адаптер маршалит через FController.Invoke).
procedure TMainForm.TCIFocusRequest;
begin
if WindowState = wsMinimized then WindowState := wsNormal;
BringToFront;
end; end;
procedure TMainForm.TCIFormActivate(Sender: TObject); procedure TMainForm.TCIFormActivate(Sender: TObject);
+20 -1
View File
@@ -88,7 +88,8 @@ type
rfDevice, // подключённое устройство сменилось rfDevice, // подключённое устройство сменилось
rfDeviceList, // список discovered устройств обновился rfDeviceList, // список discovered устройств обновился
rfPanFreq, // центр доп. пана уехал (ретюн DDC извне) rfPanFreq, // центр доп. пана уехал (ретюн DDC извне)
rfSliceFreq // слайс перестроен извне (CAT); Id — FSliceFreqId rfSliceFreq, // слайс перестроен извне (CAT); Id — FSliceFreqId
rfSliceState // у слайса сменились мода/фильтр/АРУ/DSP/громкость; Id — FSliceFreqId
); );
TRadioStateEvent = procedure(Sender: TObject; Field: TRadioField) of object; TRadioStateEvent = procedure(Sender: TObject; Field: TRadioField) of object;
@@ -907,6 +908,7 @@ type
// Уведомление UI «частота слайса пришла извне»: перезалить флаг (цифры + // Уведомление UI «частота слайса пришла извне»: перезалить флаг (цифры +
// кромки фильтра), позиция флага и так живая. // кромки фильтра), позиция флага и так живая.
procedure SliceFreqChanged(Id: Integer); procedure SliceFreqChanged(Id: Integer);
procedure SliceStateChanged(Id: Integer);
// ---- Панадаптеры на аппаратных DDC (этап 3.1; UI/движок — 3.2/3.3) ---- // ---- Панадаптеры на аппаратных DDC (этап 3.1; UI/движок — 3.2/3.3) ----
// Создаёт пан PanId (1..MAX_PANS-1) на своём DDC: FreqHz = центр в ВИДИМЫХ // Создаёт пан PanId (1..MAX_PANS-1) на своём DDC: FreqHz = центр в ВИДИМЫХ
@@ -2018,6 +2020,16 @@ begin
Changed(rfSliceFreq); Changed(rfSliceFreq);
end; end;
procedure TRadioController.SliceStateChanged(Id: Integer);
// У слайса сменилось что-то кроме частоты: режим, фильтр, АРУ, DSP, громкость,
// мьют, шумоподавитель. Зовут сами сеттеры — слайс правят и UI, и CAT, и TCI, а
// узнать об этом должны все фронтенды сразу (у главного тракта для этого есть
// свои поля rfMode/rfFilter/…, у слайсов не было ничего).
begin
FSliceFreqId := Id;
Changed(rfSliceState);
end;
function TRadioController.TuneSliceInBand(Id: Integer; TargetHz: Double): Boolean; function TRadioController.TuneSliceInBand(Id: Integer; TargetHz: Double): Boolean;
// Свобода внешней программы: она двигает слайс куда угодно ВНУТРИ включённого // Свобода внешней программы: она двигает слайс куда угодно ВНУТРИ включённого
// диапазона (2 м на трансвертере: 144.174 при окне на 145.5 — можно), но сменить // диапазона (2 м на трансвертере: 144.174 при окне на 145.5 — можно), но сменить
@@ -2548,6 +2560,7 @@ begin
end; end;
end; end;
if FTxSliceId = Id then SyncCWKeyer; if FTxSliceId = Id then SyncCWKeyer;
SliceStateChanged(Id);
end; end;
procedure TRadioController.SetSliceFilter(Id, Low, High: Integer); procedure TRadioController.SetSliceFilter(Id, Low, High: Integer);
@@ -2558,6 +2571,7 @@ begin
FSlices[idx].FilterLo := Low; FSlices[idx].FilterLo := Low;
FSlices[idx].FilterHi := High; FSlices[idx].FilterHi := High;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceFilter(Id, Low, High); if Assigned(FDSPEngine) then FDSPEngine.SetSliceFilter(Id, Low, High);
SliceStateChanged(Id);
end; end;
procedure TRadioController.SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode); procedure TRadioController.SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode);
@@ -2567,6 +2581,7 @@ begin
if idx < 0 then Exit; if idx < 0 then Exit;
FSlices[idx].AGC := AGC; FSlices[idx].AGC := AGC;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceAGC(Id, AGC); if Assigned(FDSPEngine) then FDSPEngine.SetSliceAGC(Id, AGC);
SliceStateChanged(Id);
end; end;
procedure TRadioController.SetSliceVolume(Id: Integer; Vol: Double); procedure TRadioController.SetSliceVolume(Id: Integer; Vol: Double);
@@ -2576,6 +2591,7 @@ begin
if idx < 0 then Exit; if idx < 0 then Exit;
FSlices[idx].Volume := Vol; FSlices[idx].Volume := Vol;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceVolume(Id, Vol); if Assigned(FDSPEngine) then FDSPEngine.SetSliceVolume(Id, Vol);
SliceStateChanged(Id);
end; end;
procedure TRadioController.SetSliceMute(Id: Integer; Mute: Boolean); procedure TRadioController.SetSliceMute(Id: Integer; Mute: Boolean);
@@ -2585,6 +2601,7 @@ begin
if idx < 0 then Exit; if idx < 0 then Exit;
FSlices[idx].Muted := Mute; FSlices[idx].Muted := Mute;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceMute(Id, Mute); if Assigned(FDSPEngine) then FDSPEngine.SetSliceMute(Id, Mute);
SliceStateChanged(Id);
end; end;
procedure TRadioController.SetSliceRxMuteOnTx(Id: Integer; On_: Boolean); procedure TRadioController.SetSliceRxMuteOnTx(Id: Integer; On_: Boolean);
@@ -2639,6 +2656,7 @@ begin
FDSPEngine.SetSliceSNB(Id, SNBOn); FDSPEngine.SetSliceSNB(Id, SNBOn);
FDSPEngine.SetSliceANF(Id, ANFOn); FDSPEngine.SetSliceANF(Id, ANFOn);
end; end;
SliceStateChanged(Id);
end; end;
procedure TRadioController.SetSliceFMSquelch(Id: Integer; On_: Boolean; Level: Integer); procedure TRadioController.SetSliceFMSquelch(Id: Integer; On_: Boolean; Level: Integer);
@@ -2650,6 +2668,7 @@ begin
FSlices[idx].FMSQLevel := Level; FSlices[idx].FMSQLevel := Level;
if Assigned(FDSPEngine) then if Assigned(FDSPEngine) then
FDSPEngine.SetSliceFMSquelch(Id, On_, Level); FDSPEngine.SetSliceFMSquelch(Id, On_, Level);
SliceStateChanged(Id);
end; end;
function TRadioController.SliceSMeter(Id: Integer): Double; function TRadioController.SliceSMeter(Id: Integer): Double;
+28 -3
View File
@@ -475,6 +475,11 @@ type
FChkTCIEnabled: TFlatCheckBox; FChkTCIEnabled: TFlatCheckBox;
FEdTCIPort: TFlatSpinEdit; FEdTCIPort: TFlatSpinEdit;
FEdTCIBind: TFlatEdit; FEdTCIBind: TFlatEdit;
// Последнее применённое: обработчик висит на уходе фокуса и на Close,
// а перезапускать сервер без правки нельзя (порвёт клиентов).
FTCILastEnabled: Boolean;
FTCILastPort: Integer;
FTCILastBind: string;
// ---- Close button ---- // ---- Close button ----
FBtnClose: TFlatButton; FBtnClose: TFlatButton;
@@ -3478,7 +3483,9 @@ begin
Spin.MinValue := 1; Spin.MinValue := 1;
Spin.MaxValue := 65535; Spin.MaxValue := 65535;
Spin.Value := 40001; Spin.Value := 40001;
Spin.OnChange := OnTCIAnyChange; // Применяем по уходу фокуса, а не на каждое нажатие: набирая «40001», через
// OnChange мы бы подряд перезапустили сервер на портах 4, 40, 400, 4000.
Spin.OnExit := OnTCIAnyChange;
FEdTCIPort := Spin; FEdTCIPort := Spin;
Y := Y + ROW_H; Y := Y + ROW_H;
@@ -3490,7 +3497,9 @@ begin
Ed.Font.Color := CLR_INPUT_TEXT; Ed.Font.Color := CLR_INPUT_TEXT;
Ed.Font.Size := 9; Ed.Font.Size := 9;
Ed.Text := '127.0.0.1'; Ed.Text := '127.0.0.1';
Ed.OnChange := OnTCIAnyChange; // Тем более адрес: промежуточное «127.0.0.» — не адрес, и раньше это молча
// означало «слушать на всех интерфейсах». Применяем по уходу фокуса.
Ed.OnExit := OnTCIAnyChange;
FEdTCIBind := Ed; FEdTCIBind := Ed;
// Авторизации в протоколе нет: открытый наружу порт = полный доступ к трансиверу. // Авторизации в протоколе нет: открытый наружу порт = полный доступ к трансиверу.
@@ -3749,7 +3758,16 @@ procedure TSettingsForm.OnTCIAnyChange(Sender: TObject);
begin begin
if FLoading then Exit; if FLoading then Exit;
if not Assigned(FOnTCISettingsChange) then Exit; if not Assigned(FOnTCISettingsChange) then Exit;
FOnTCISettingsChange(FChkTCIEnabled.Checked, FEdTCIPort.Value, FEdTCIBind.Text); // Применение перезапускает сервер и рвёт соединения клиентов, поэтому зовём
// только на РЕАЛЬНОЕ изменение: обработчик висит и на уходе фокуса, и на
// кнопке Close, а они срабатывают и без правок.
if (FChkTCIEnabled.Checked = FTCILastEnabled) and
(FEdTCIPort.Value = FTCILastPort) and
(FEdTCIBind.Text = FTCILastBind) then Exit;
FTCILastEnabled := FChkTCIEnabled.Checked;
FTCILastPort := FEdTCIPort.Value;
FTCILastBind := FEdTCIBind.Text;
FOnTCISettingsChange(FTCILastEnabled, FTCILastPort, FTCILastBind);
end; end;
procedure TSettingsForm.LoadTCISettings(Enabled: Boolean; Port: Integer; procedure TSettingsForm.LoadTCISettings(Enabled: Boolean; Port: Integer;
@@ -3760,6 +3778,9 @@ begin
FChkTCIEnabled.Checked := Enabled; FChkTCIEnabled.Checked := Enabled;
FEdTCIPort.Value := EnsureRange(Port, 1, 65535); FEdTCIPort.Value := EnsureRange(Port, 1, 65535);
FEdTCIBind.Text := BindAddr; FEdTCIBind.Text := BindAddr;
FTCILastEnabled := FChkTCIEnabled.Checked;
FTCILastPort := FEdTCIPort.Value;
FTCILastBind := FEdTCIBind.Text;
finally finally
FLoading := False; FLoading := False;
end; end;
@@ -4594,6 +4615,10 @@ end;
procedure TSettingsForm.BtnCloseClick(Sender: TObject); procedure TSettingsForm.BtnCloseClick(Sender: TObject);
begin begin
// Порт и адрес TCI применяются по уходу фокуса (см. BuildAdvancedTab), а из
// поля, в котором стоит курсор, при закрытии окна фокус может и не уйти —
// дожимаем правку здесь, иначе она молча пропадёт.
OnTCIAnyChange(nil);
Close; Close;
end; end;
+405 -160
View File
File diff suppressed because it is too large Load Diff
+402 -99
View File
@@ -15,6 +15,16 @@ unit TCIServer;
тикает OnTick (умолчание 20 мс) по нему адаптер шлёт показания тикает OnTick (умолчание 20 мс) по нему адаптер шлёт показания
измерителей с индивидуальным для каждого клиента периодом. измерителей с индивидуальным для каждого клиента периодом.
Отправка НИКОГДА не блокирует того, кто зовёт Send/Broadcast: строка кладётся
в очередь клиента, а в сокет её пишет тик-поток (FlushClients). Иначе
медленный клиент останавливал бы UI-поток на секунду за раз уведомления
рождаются в OnState, то есть внутри Changed() контроллера.
Владение объектом клиента: создаёт accept-поток, освобождает ТОЛЬКО тик-поток
(ReapClients) и только после того, как клиентский поток честно вышел. Никто
больше клиентов не освобождает поэтому указатель, взятый под FClientLock,
остаётся валидным, пока тик-поток не сделает следующий проход.
Бинарные фреймы (потоки IQ/аудио, §3.4) пока не обрабатываются: этап 2, Бинарные фреймы (потоки IQ/аудио, §3.4) пока не обрабатываются: этап 2,
см. doc/TCI.md. Приходящие от клиента binary-фреймы молча отбрасываются. см. doc/TCI.md. Приходящие от клиента binary-фреймы молча отбрасываются.
@@ -43,14 +53,20 @@ const
TCI_MAX_CLIENTS = 8; TCI_MAX_CLIENTS = 8;
TCI_TICK_MS = 20; // период OnTick (сенсоры троттлятся адаптером) TCI_TICK_MS = 20; // период OnTick (сенсоры троттлятся адаптером)
TCI_WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; TCI_WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
TCI_SEND_TIMEOUT = 1000; // мс на SockSend, иначе клиент считается мёртвым TCI_SEND_TIMEOUT = 300; // мс на SockSend, иначе клиент считается мёртвым
TCI_OUT_MAX = 4000; // потолок очереди отправки на клиента (строк)
TCI_OUT_CHUNK = 3800; // склейка очереди в один фрейм, символов
TCI_MSG_MAX = 65536; // потолок собираемого из фрагментов сообщения
TCI_STOP_WAIT_MS = 10000; // сколько ждём выхода клиентских потоков в Stop
type type
TTCIServer = class; TTCIServer = class;
{ Один подключённый клиент: WS-сокет + его личные подписки. Подписки на { Один подключённый клиент: WS-сокет, его личные подписки и очередь
сенсоры в TCI индивидуальны (RX_SENSORS_ENABLE «отправляется только отправки. Подписки на сенсоры в TCI индивидуальны (RX_SENSORS_ENABLE
клиентом»), поэтому живут здесь, а не в адаптере. } «отправляется только клиентом»), поэтому живут здесь, а не в адаптере.
Параметры потоков (§4.3) тоже клиентские, их держит адаптер по ссылке
на этот объект. }
TTCIClient = class TTCIClient = class
private private
FWs: TWsClient; FWs: TWsClient;
@@ -61,18 +77,45 @@ type
FTxSensors: Boolean; FTxSensors: Boolean;
FTxSensorsMs: Integer; FTxSensorsMs: Integer;
FTxSensorsAt: QWord; FTxSensorsAt: QWord;
// Параметры бинарных потоков (§3.4): по спецификации это настройки
// КЛИЕНТА, а не устройства — свои у каждого подключения.
FIQRate: Integer;
FAudioRate: Integer;
FAudioSamples: Integer;
FAudioChannels: Integer;
FAudioSampleType: string;
FTxBuffering: Integer;
// Очередь отправки: пишут любые потоки, читает тик-поток.
FOutLock: TCriticalSection;
FOut: array of string;
FOutCount: Integer;
FDead: Boolean; // сокет уже не пишется — гасим соединение
FKilled: Boolean; // shutdown сокета уже сделан
FClosed: Boolean; // клиентский поток вышел (можно освобождать)
public public
constructor Create(AWs: TWsClient); constructor Create(AWs: TWsClient);
{ Текстовый фрейм клиенту. False — соединение уже мертво. } destructor Destroy; override;
function Send(const S: string): Boolean; { Строку в очередь клиенту. False — соединение уже мертво. Не блокирует. }
function Send(const S: string): Boolean;
{ Слить очередь в сокет. Зовёт только тик-поток. False — клиент умер. }
function Flush: Boolean;
{ Пометить мёртвым и разбудить его поток (shutdown сокета). }
procedure Kill;
property Ws: TWsClient read FWs; property Ws: TWsClient read FWs;
property Ready: Boolean read FReady write FReady; property Ready: Boolean read FReady write FReady;
property Dead: Boolean read FDead;
property RxSensors: Boolean read FRxSensors write FRxSensors; property RxSensors: Boolean read FRxSensors write FRxSensors;
property RxSensorsMs: Integer read FRxSensorsMs write FRxSensorsMs; property RxSensorsMs: Integer read FRxSensorsMs write FRxSensorsMs;
property RxSensorsAt: QWord read FRxSensorsAt write FRxSensorsAt; property RxSensorsAt: QWord read FRxSensorsAt write FRxSensorsAt;
property TxSensors: Boolean read FTxSensors write FTxSensors; property TxSensors: Boolean read FTxSensors write FTxSensors;
property TxSensorsMs: Integer read FTxSensorsMs write FTxSensorsMs; property TxSensorsMs: Integer read FTxSensorsMs write FTxSensorsMs;
property TxSensorsAt: QWord read FTxSensorsAt write FTxSensorsAt; property TxSensorsAt: QWord read FTxSensorsAt write FTxSensorsAt;
property IQRate: Integer read FIQRate write FIQRate;
property AudioRate: Integer read FAudioRate write FAudioRate;
property AudioSamples: Integer read FAudioSamples write FAudioSamples;
property AudioChannels: Integer read FAudioChannels write FAudioChannels;
property AudioSampleType: string read FAudioSampleType write FAudioSampleType;
property TxBuffering: Integer read FTxBuffering write FTxBuffering;
end; end;
TTCIClientEvent = procedure(Client: TTCIClient) of object; TTCIClientEvent = procedure(Client: TTCIClient) of object;
@@ -88,6 +131,7 @@ type
FTickThread: TThread; FTickThread: TThread;
FThreadCount: LongInt; // живых клиентских потоков (Interlocked*) FThreadCount: LongInt; // живых клиентских потоков (Interlocked*)
FRunning: Boolean; FRunning: Boolean;
FStopping: Boolean;
FPort: Word; FPort: Word;
FBindIP: string; FBindIP: string;
FOnCommand: TTCICommandEvent; FOnCommand: TTCICommandEvent;
@@ -95,13 +139,15 @@ type
FOnDisconnect: TTCIClientEvent; FOnDisconnect: TTCIClientEvent;
FOnTick: TThreadMethod; FOnTick: TThreadMethod;
function InitListen: Boolean; function InitListen: Boolean;
procedure RemoveClient(Client: TTCIClient); procedure ReapClients; // освободить клиентов, чьи потоки вышли
procedure FlushClients; // слить очереди в сокеты (вне FClientLock)
public public
constructor Create; constructor Create;
destructor Destroy; override; destructor Destroy; override;
{ Настройка слушателя. Применяется при следующем Start. } { Настройка слушателя. Применяется при следующем Start.
procedure Configure(APort: Word; const ABindIP: string); False адрес не разобран (порт не откроется). }
function Configure(APort: Word; const ABindIP: string): Boolean;
function Start: Boolean; function Start: Boolean;
procedure Stop; procedure Stop;
@@ -109,10 +155,11 @@ type
{ Всем клиентам, прошедшим инициализацию. Skip кого пропустить { Всем клиентам, прошедшим инициализацию. Skip кого пропустить
(обычно автора изменения не пропускаем: сервер отвечает и ему тоже, (обычно автора изменения не пропускаем: сервер отвечает и ему тоже,
это и есть подтверждение установки). } это и есть подтверждение установки). Только кладёт в очереди. }
procedure Broadcast(const S: string; Skip: TTCIClient = nil); procedure Broadcast(const S: string; Skip: TTCIClient = nil);
{ Обход клиентов под локом — для рассылки с индивидуальным периодом. } { Обход клиентов под локом для рассылки с индивидуальным периодом.
Proc обязана быть быстрой: она держит FClientLock. }
procedure EnumClients(Proc: TTCIClientEvent); procedure EnumClients(Proc: TTCIClientEvent);
function ClientCount: Integer; function ClientCount: Integer;
@@ -123,14 +170,23 @@ type
procedure HandleClient(Client: TTCIClient); procedure HandleClient(Client: TTCIClient);
procedure ThreadDone; // клиентский поток отработал procedure ThreadDone; // клиентский поток отработал
property Port: Word read FPort; property Port: Word read FPort;
property BindIP: string read FBindIP; property BindIP: string read FBindIP;
{ Идёт остановка: адаптер не должен начинать новых вызовов в поток
контроллера тот, кто нас останавливает, обычно и есть поток
контроллера, и Synchronize из клиентского потока в него не вернётся. }
property Stopping: Boolean read FStopping;
property OnCommand: TTCICommandEvent read FOnCommand write FOnCommand; property OnCommand: TTCICommandEvent read FOnCommand write FOnCommand;
property OnConnect: TTCIClientEvent read FOnConnect write FOnConnect; property OnConnect: TTCIClientEvent read FOnConnect write FOnConnect;
property OnDisconnect: TTCIClientEvent read FOnDisconnect write FOnDisconnect; property OnDisconnect: TTCIClientEvent read FOnDisconnect write FOnDisconnect;
property OnTick: TThreadMethod read FOnTick write FOnTick; property OnTick: TThreadMethod read FOnTick write FOnTick;
end; end;
{ IPv4 из строки в сетевом порядке. Строгий: ровно четыре десятичных октета
0..255. '' и '0.0.0.0' это INADDR_ANY (слушать везде), и только они:
«ошибка разбора = слушаем всё» в протоколе без авторизации недопустима. }
function TCIParseIPv4(const S: string; out Addr: LongWord): Boolean;
implementation implementation
type type
@@ -187,36 +243,51 @@ end;
procedure TTCIClientThread.Execute; procedure TTCIClientThread.Execute;
begin begin
try try
FServer.HandleClient(FClient); try
FServer.HandleClient(FClient);
except
// Разбор фрейма рухнул — соединение всё равно закрываем штатно, иначе
// клиент остался бы висеть в массиве до остановки сервера.
end;
finally finally
FServer.ThreadDone; // Stop ждёт обнуления счётчика перед зачисткой // Освобождать себя нельзя: объект переиспользуется рассылкой из чужих
// потоков. Помечаем «поток вышел» — освободит тик-поток (ReapClients).
FClient.FClosed := True;
FServer.ThreadDone;
end; end;
end; end;
{ IPv4 из строки в сетевом порядке. Свой, потому что WebServer держит такой же function TCIParseIPv4(const S: string; out Addr: LongWord): Boolean;
в implementation и наружу не отдаёт. }
function TCIParseIPv4(const S: string): LongWord;
var var
Oct: array[0..3] of LongWord; Oct: array[0..3] of LongWord;
N, i, Start: Integer; N, i, Start, V: Integer;
Part: string; Part: string;
c: Char;
begin begin
Result := 0; // INADDR_ANY Addr := 0; // INADDR_ANY
if (S = '') or (S = '0.0.0.0') then Exit; Result := False;
if (Trim(S) = '') or (Trim(S) = '0.0.0.0') then Exit(True);
N := 0; N := 0;
Start := 1; Start := 1;
for i := 1 to Length(S) + 1 do for i := 1 to Length(S) + 1 do
if (i > Length(S)) or (S[i] = '.') then if (i > Length(S)) or (S[i] = '.') then
begin begin
if N > 3 then Exit; if N > 3 then Exit(False);
Part := Copy(S, Start, i - Start); Part := Copy(S, Start, i - Start);
Oct[N] := LongWord(StrToIntDef(Part, 0)) and $FF; if (Part = '') or (Length(Part) > 3) then Exit(False);
for c in Part do
if (c < '0') or (c > '9') then Exit(False);
V := StrToIntDef(Part, -1);
if (V < 0) or (V > 255) then Exit(False);
Oct[N] := LongWord(V);
Inc(N); Inc(N);
Start := i + 1; Start := i + 1;
end; end;
if N <> 4 then Exit; if N <> 4 then Exit(False);
// Сетевой порядок байт: первый октет — младший байт in_addr. // Сетевой порядок байт: первый октет — младший байт in_addr.
Result := Oct[0] or (Oct[1] shl 8) or (Oct[2] shl 16) or (Oct[3] shl 24); Addr := Oct[0] or (Oct[1] shl 8) or (Oct[2] shl 16) or (Oct[3] shl 24);
Result := True;
end; end;
{ ═══════════════════════════════════════════════════════════════════════════ { ═══════════════════════════════════════════════════════════════════════════
@@ -232,11 +303,109 @@ begin
FRxSensorsMs := 200; FRxSensorsMs := 200;
FTxSensors := False; FTxSensors := False;
FTxSensorsMs := 200; FTxSensorsMs := 200;
FOutLock := TCriticalSection.Create;
FOutCount := 0;
SetLength(FOut, 64);
// Умолчания параметров потоков — как в §4.3 (клиент их обычно переопределяет).
FIQRate := 48000;
FAudioRate := 48000;
FAudioSamples := 2048;
FAudioChannels := 2;
FAudioSampleType := 'float32';
FTxBuffering := 50;
end;
destructor TTCIClient.Destroy;
begin
FOutLock.Free;
inherited;
end; end;
function TTCIClient.Send(const S: string): Boolean; function TTCIClient.Send(const S: string): Boolean;
begin begin
Result := (FWs <> nil) and (FWs.State = wsOpen) and FWs.SendText(S); Result := False;
if (S = '') or FDead or (FWs = nil) then Exit;
FOutLock.Enter;
try
if FDead then Exit;
// Очередь переполнилась: клиент не читает сокет быстрее, чем мы пишем.
// Копить дальше нечестно (память + отставшее состояние), рвём соединение.
if FOutCount >= TCI_OUT_MAX then
begin
FDead := True;
Exit;
end;
if FOutCount >= Length(FOut) then SetLength(FOut, Length(FOut) * 2);
FOut[FOutCount] := S;
Inc(FOutCount);
Result := True;
finally
FOutLock.Leave;
end;
end;
function TTCIClient.Flush: Boolean;
var
Batch: array of string;
N, i: Integer;
Chunk: string;
begin
Result := not FDead;
// Мёртвым клиента могла пометить и очередь (переполнилась в чужом потоке —
// там гасить сокет нельзя, лок чужой). Добиваем здесь: без shutdown его
// поток так и висел бы в recv, а объект никогда бы не освободился.
if FDead then begin Kill; Exit; end;
FOutLock.Enter;
try
N := FOutCount;
if N > 0 then
begin
SetLength(Batch, N);
for i := 0 to N - 1 do
begin
Batch[i] := FOut[i];
FOut[i] := '';
end;
FOutCount := 0;
end;
finally
FOutLock.Leave;
end;
if N = 0 then Exit;
// Склейка: несколько команд в одном фрейме протокол разрешает (§3.1), а
// syscall'ов и заголовков становится в разы меньше.
Chunk := '';
for i := 0 to N - 1 do
begin
if (Chunk <> '') and (Length(Chunk) + Length(Batch[i]) > TCI_OUT_CHUNK) then
begin
if not FWs.SendText(Chunk) then begin Kill; Exit(False); end;
Chunk := '';
end;
Chunk := Chunk + Batch[i];
end;
if Chunk <> '' then
if not FWs.SendText(Chunk) then begin Kill; Exit(False); end;
end;
procedure TTCIClient.Kill;
begin
FOutLock.Enter;
try
FDead := True;
FOutCount := 0;
if FKilled then Exit; // shutdown уже был — второй раз незачем
FKilled := True;
finally
FOutLock.Leave;
end;
if FWs <> nil then
begin
FWs.State := wsClosed;
SockShutdown(FWs.Socket); // будим поток клиента, висящий в recv
end;
end; end;
{ ═══════════════════════════════════════════════════════════════════════════ { ═══════════════════════════════════════════════════════════════════════════
@@ -269,10 +438,12 @@ begin
inherited; inherited;
end; end;
procedure TTCIServer.Configure(APort: Word; const ABindIP: string); function TTCIServer.Configure(APort: Word; const ABindIP: string): Boolean;
var Dummy: LongWord;
begin begin
FPort := APort; FPort := APort;
FBindIP := ABindIP; FBindIP := ABindIP;
Result := (APort <> 0) and TCIParseIPv4(ABindIP, Dummy);
end; end;
function TTCIServer.Running: Boolean; function TTCIServer.Running: Boolean;
@@ -284,8 +455,14 @@ function TTCIServer.InitListen: Boolean;
var var
Addr: {$IFDEF WINDOWS}TSockAddrIn{$ELSE}TInetSockAddr{$ENDIF}; Addr: {$IFDEF WINDOWS}TSockAddrIn{$ELSE}TInetSockAddr{$ENDIF};
One: Integer; One: Integer;
IP: LongWord;
begin begin
Result := False; Result := False;
// Кривой адрес — отказ. Молча свалиться в INADDR_ANY нельзя: в TCI нет
// авторизации, и открытый наружу порт отдаёт управление передатчиком.
if not TCIParseIPv4(FBindIP, IP) then Exit;
if FPort = 0 then Exit;
{$IFDEF WINDOWS} {$IFDEF WINDOWS}
FListenSock := socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); FListenSock := socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
{$ELSE} {$ELSE}
@@ -299,7 +476,7 @@ begin
FillChar(Addr, SizeOf(Addr), 0); FillChar(Addr, SizeOf(Addr), 0);
Addr.sin_family := AF_INET; Addr.sin_family := AF_INET;
Addr.sin_port := htons(FPort); Addr.sin_port := htons(FPort);
Addr.sin_addr.S_addr := TCIParseIPv4(FBindIP); Addr.sin_addr.S_addr := IP;
if bind(FListenSock, @Addr, SizeOf(Addr)) = SOCKET_ERROR then Exit; if bind(FListenSock, @Addr, SizeOf(Addr)) = SOCKET_ERROR then Exit;
if listen(FListenSock, 5) = SOCKET_ERROR then Exit; if listen(FListenSock, 5) = SOCKET_ERROR then Exit;
{$ELSE} {$ELSE}
@@ -307,7 +484,7 @@ begin
FillChar(Addr, SizeOf(Addr), 0); FillChar(Addr, SizeOf(Addr), 0);
Addr.sin_family := AF_INET; Addr.sin_family := AF_INET;
Addr.sin_port := htons(FPort); Addr.sin_port := htons(FPort);
Addr.sin_addr.s_addr := TCIParseIPv4(FBindIP); Addr.sin_addr.s_addr := IP;
if fpBind(FListenSock, @Addr, SizeOf(Addr)) <> 0 then Exit; if fpBind(FListenSock, @Addr, SizeOf(Addr)) <> 0 then Exit;
if fpListen(FListenSock, 5) <> 0 then Exit; if fpListen(FListenSock, 5) <> 0 then Exit;
{$ENDIF} {$ENDIF}
@@ -327,7 +504,8 @@ begin
end; end;
Exit; Exit;
end; end;
FRunning := True; FStopping := False;
FRunning := True;
FAcceptThread := TTCIAcceptThread.Create(Self); FAcceptThread := TTCIAcceptThread.Create(Self);
TTCIAcceptThread(FAcceptThread).Start; TTCIAcceptThread(FAcceptThread).Start;
FTickThread := TTCITickThread.Create(Self); FTickThread := TTCITickThread.Create(Self);
@@ -339,7 +517,8 @@ procedure TTCIServer.Stop;
var i, Waited: Integer; var i, Waited: Integer;
begin begin
if not FRunning then Exit; if not FRunning then Exit;
FRunning := False; FStopping := True; // адаптер перестаёт звать Invoke (см. property Stopping)
FRunning := False;
// Шаг 1: гасим listen-сокет. SockShutdown обязателен до close — иначе // Шаг 1: гасим listen-сокет. SockShutdown обязателен до close — иначе
// fpAccept в accept-потоке не разблокируется (см. WebServer.Stop). // fpAccept в accept-потоке не разблокируется (см. WebServer.Stop).
@@ -354,11 +533,7 @@ begin
FClientLock.Enter; FClientLock.Enter;
try try
for i := 0 to FClientCount - 1 do for i := 0 to FClientCount - 1 do
if FClients[i] <> nil then if FClients[i] <> nil then FClients[i].Kill;
begin
FClients[i].Ws.State := wsClosed;
SockShutdown(FClients[i].Ws.Socket);
end;
finally finally
FClientLock.Leave; FClientLock.Leave;
end; end;
@@ -367,22 +542,24 @@ begin
if FAcceptThread <> nil then begin FAcceptThread.WaitFor; FreeAndNil(FAcceptThread); end; if FAcceptThread <> nil then begin FAcceptThread.WaitFor; FreeAndNil(FAcceptThread); end;
if FTickThread <> nil then begin FTickThread.WaitFor; FreeAndNil(FTickThread); end; if FTickThread <> nil then begin FTickThread.WaitFor; FreeAndNil(FTickThread); end;
// Шаг 4: даём клиентским потокам выйти самим. Освобождает клиента ТОТ, // Шаг 4: ждём выхода клиентских потоков. Прокачивая очередь Synchronize:
// кто вынул его из массива (RemoveClient в клиентском потоке), — иначе // Stop зовёт поток контроллера (UI), а клиентский поток может как раз в нём
// Stop освободил бы объект из-под работающего потока. // висеть на FController.Invoke. Без прокачки это гарантированный взаимный
// клин, а по его истечении — освобождение объекта из-под живого потока.
Waited := 0; Waited := 0;
while (FThreadCount > 0) and (Waited < 2000) do while (FThreadCount > 0) and (Waited < TCI_STOP_WAIT_MS) do
begin begin
Sleep(10); if GetCurrentThreadId = MainThreadID then CheckSynchronize(5) else Sleep(5);
Inc(Waited, 10); Inc(Waited, 5);
end; end;
// Вырожденный случай: поток завис (не должно случаться сокеты закрыты). // Шаг 5: зачистка. Если поток всё же не вышел (не должно случаться: сокеты
// Чистим остатки, чтобы не течь; объекты уже никем не используются. // закрыты, очередь прокачана), объект НЕ освобождаем — утечка на выходе
// несравнимо дешевле обращения к освобождённой памяти из живого потока.
FClientLock.Enter; FClientLock.Enter;
try try
for i := 0 to FClientCount - 1 do for i := 0 to FClientCount - 1 do
if FClients[i] <> nil then if (FClients[i] <> nil) and FClients[i].FClosed then
begin begin
FClients[i].Ws.Free; FClients[i].Ws.Free;
FreeAndNil(FClients[i]); FreeAndNil(FClients[i]);
@@ -404,6 +581,7 @@ var
ALen: {$IFDEF WINDOWS}Integer{$ELSE}TSockLen{$ENDIF}; ALen: {$IFDEF WINDOWS}Integer{$ELSE}TSockLen{$ENDIF};
Client: TTCIClient; Client: TTCIClient;
T: TTCIClientThread; T: TTCIClientThread;
Full: Boolean;
begin begin
while FRunning do while FRunning do
begin begin
@@ -418,46 +596,96 @@ begin
if FRunning then Sleep(10); if FRunning then Sleep(10);
Continue; Continue;
end; end;
if FClientCount >= TCI_MAX_CLIENTS then if not FRunning then
begin begin
SockClose(CSock); SockClose(CSock);
Continue; Break;
end; end;
SockSetSndTimeout(CSock, TCI_SEND_TIMEOUT); SockSetSndTimeout(CSock, TCI_SEND_TIMEOUT);
Client := TTCIClient.Create(TWsClient.Create(CSock)); Client := TTCIClient.Create(TWsClient.Create(CSock));
Full := False;
FClientLock.Enter; FClientLock.Enter;
try try
FClients[FClientCount] := Client; // Слот берём под локом: место в массиве освобождает тик-поток.
Inc(FClientCount); if FClientCount >= TCI_MAX_CLIENTS then Full := True
else
begin
FClients[FClientCount] := Client;
Inc(FClientCount);
end;
finally finally
FClientLock.Leave; FClientLock.Leave;
end; end;
if Full then
begin
Client.Ws.Free; // закрывает сокет
Client.Free;
Continue;
end;
InterLockedIncrement(FThreadCount); InterLockedIncrement(FThreadCount);
T := TTCIClientThread.Create(Self, Client); T := TTCIClientThread.Create(Self, Client);
T.Start; T.Start;
end; end;
end; end;
procedure TTCIServer.RemoveClient(Client: TTCIClient); procedure TTCIServer.ReapClients;
var i, j: Integer; // Освобождение клиентов — единственное место во всей программе. Зовёт только
// тик-поток, поэтому указатель, взятый кем угодно под FClientLock, живёт до
// следующего прохода тика (а вне лока указателей никто не держит).
var
i, j, N: Integer;
Doomed: array[0..TCI_MAX_CLIENTS-1] of TTCIClient;
begin begin
if Client = nil then Exit; N := 0;
if Assigned(FOnDisconnect) then FOnDisconnect(Client);
FClientLock.Enter; FClientLock.Enter;
try try
for i := 0 to FClientCount - 1 do i := 0;
if FClients[i] = Client then while i < FClientCount do
if (FClients[i] <> nil) and FClients[i].FClosed then
begin begin
Doomed[N] := FClients[i];
Inc(N);
for j := i to FClientCount - 2 do FClients[j] := FClients[j + 1]; for j := i to FClientCount - 2 do FClients[j] := FClients[j + 1];
FClients[FClientCount - 1] := nil; FClients[FClientCount - 1] := nil;
Dec(FClientCount); Dec(FClientCount);
Break; end
else
Inc(i);
finally
FClientLock.Leave;
end;
for i := 0 to N - 1 do
begin
if Assigned(FOnDisconnect) then FOnDisconnect(Doomed[i]);
Doomed[i].Ws.Free; // закрывает сокет
Doomed[i].Free;
end;
end;
procedure TTCIServer.FlushClients;
// Запись в сокеты — вне FClientLock: медленный клиент не должен держать лок,
// иначе Broadcast из потока контроллера снова начнёт ждать сеть.
var
Snap: array[0..TCI_MAX_CLIENTS-1] of TTCIClient;
i, N: Integer;
begin
N := 0;
FClientLock.Enter;
try
for i := 0 to FClientCount - 1 do
if (FClients[i] <> nil) and not FClients[i].FClosed then
begin
Snap[N] := FClients[i];
Inc(N);
end; end;
finally finally
FClientLock.Leave; FClientLock.Leave;
end; end;
Client.Ws.Free; // закрывает сокет for i := 0 to N - 1 do
Client.Free; Snap[i].Flush;
end; end;
{ ═══════════════════════════════════════════════════════════════════════════ { ═══════════════════════════════════════════════════════════════════════════
@@ -470,13 +698,15 @@ var
R, HeaderEnd: Integer; R, HeaderEnd: Integer;
Header, HeaderLC, Key, AcceptKey, Response, Text: string; Header, HeaderLC, Key, AcceptKey, Response, Text: string;
Raw: array[0..4095] of Byte; Raw: array[0..4095] of Byte;
RawLen: Integer; RawLen, Rest: Integer;
B0, B1: Byte; B0, B1: Byte;
Masked: Boolean; Masked, Fin, Pending: Boolean;
PayLen, Need, i, j, Consumed, KPos, KEnd: Integer; PayLen, Need, i, j, Consumed, KPos, KEnd: Integer;
Hi32: LongWord;
Mask: array[0..3] of Byte; Mask: array[0..3] of Byte;
Payload: array of Byte; Payload: array of Byte;
Opcode: Byte; Opcode, MsgOp: Byte;
Frag: string;
Cmds: TStringList; Cmds: TStringList;
begin begin
Ws := Client.Ws; Ws := Client.Ws;
@@ -494,12 +724,10 @@ begin
HeaderEnd := System.Pos(#13#10#13#10, Header); HeaderEnd := System.Pos(#13#10#13#10, Header);
until (HeaderEnd > 0) or (RawLen >= SizeOf(Raw)); until (HeaderEnd > 0) or (RawLen >= SizeOf(Raw));
if (Ws.State = wsClosed) or (HeaderEnd = 0) then if (Ws.State = wsClosed) or (HeaderEnd = 0) then Exit;
begin
RemoveClient(Client); Exit;
end;
Header := Copy(Header, 1, HeaderEnd + 3); Consumed := HeaderEnd + 3; // длина заголовков вместе с CRLFCRLF
Header := Copy(Header, 1, Consumed);
HeaderLC := LowerCase(Header); HeaderLC := LowerCase(Header);
// Путь не проверяем: клиенты ходят на '/', но протокол его не оговаривает. // Путь не проверяем: клиенты ходят на '/', но протокол его не оговаривает.
@@ -508,7 +736,7 @@ begin
Response := 'HTTP/1.1 426 Upgrade Required'#13#10 + Response := 'HTTP/1.1 426 Upgrade Required'#13#10 +
'Content-Length: 0'#13#10'Connection: close'#13#10#13#10; 'Content-Length: 0'#13#10'Connection: close'#13#10#13#10;
Ws.SendRaw(Response[1], Length(Response)); Ws.SendRaw(Response[1], Length(Response));
RemoveClient(Client); Exit; Exit;
end; end;
Key := ''; Key := '';
@@ -520,37 +748,60 @@ begin
if KEnd > 0 then Key := Copy(Key, 1, KEnd - 1); if KEnd > 0 then Key := Copy(Key, 1, KEnd - 1);
Key := Trim(Key); Key := Trim(Key);
end; end;
// Пустой ключ = не WebSocket-клиент (или сломанный): Accept без ключа
// формально считается валидным, и такое «соединение» потом молча висит.
if Key = '' then
begin
Response := 'HTTP/1.1 400 Bad Request'#13#10 +
'Content-Length: 0'#13#10'Connection: close'#13#10#13#10;
Ws.SendRaw(Response[1], Length(Response));
Exit;
end;
AcceptKey := Base64EncodeBytes(SHA1(Key + TCI_WS_GUID), 20); AcceptKey := Base64EncodeBytes(SHA1(Key + TCI_WS_GUID), 20);
Response := 'HTTP/1.1 101 Switching Protocols'#13#10 + Response := 'HTTP/1.1 101 Switching Protocols'#13#10 +
'Upgrade: websocket'#13#10 + 'Upgrade: websocket'#13#10 +
'Connection: Upgrade'#13#10 + 'Connection: Upgrade'#13#10 +
'Sec-WebSocket-Accept: ' + AcceptKey + #13#10#13#10; 'Sec-WebSocket-Accept: ' + AcceptKey + #13#10#13#10;
if not Ws.SendRaw(Response[1], Length(Response)) then if not Ws.SendRaw(Response[1], Length(Response)) then Exit;
begin
RemoveClient(Client); Exit;
end;
Ws.State := wsOpen; Ws.State := wsOpen;
// Хвост первого пакета: клиент вправе прислать первый WS-фрейм в том же
// сегменте, что и заголовки. Выбросить его — потерять первую команду.
Rest := RawLen - Consumed;
if Rest > 0 then Move(Raw[Consumed], Ws.BufData[0], Rest);
Ws.BufLen := Rest;
// Пачка инициализации + текущее состояние (§3.1) — дело адаптера. // Пачка инициализации + текущее состояние (§3.1) — дело адаптера.
if Assigned(FOnConnect) then FOnConnect(Client); if Assigned(FOnConnect) then FOnConnect(Client);
// ── Цикл WS-сообщений ──────────────────────────────────────────────────── // ── Цикл WS-сообщений ────────────────────────────────────────────────────
Cmds := TStringList.Create; Cmds := TStringList.Create;
Frag := '';
MsgOp := 0;
Pending := Rest > 0; // хвост handshake разбираем до первого recv
try try
Ws.BufLen := 0; while FRunning and (Ws.State = wsOpen) and not Client.Dead do
while FRunning and (Ws.State = wsOpen) do
begin begin
R := Ws.Recv; if not Pending then
if R <= 0 then Break; begin
R := Ws.Recv;
if R <= 0 then Break;
end;
Pending := False;
while Ws.BufLen >= 2 do while Ws.BufLen >= 2 do
begin begin
B0 := Ws.BufData[0]; B0 := Ws.BufData[0];
B1 := Ws.BufData[1]; B1 := Ws.BufData[1];
Fin := (B0 and $80) <> 0;
Opcode := B0 and $0F; Opcode := B0 and $0F;
Masked := (B1 and $80) <> 0; Masked := (B1 and $80) <> 0;
PayLen := B1 and $7F; PayLen := B1 and $7F;
// RSV1..3 без согласованных расширений обязаны быть нулями.
if (B0 and $70) <> 0 then begin Ws.State := wsClosed; Break; end;
Need := 2; Need := 2;
if PayLen = 126 then Inc(Need, 2) if PayLen = 126 then Inc(Need, 2)
else if PayLen = 127 then Inc(Need, 8); else if PayLen = 127 then Inc(Need, 8);
@@ -565,16 +816,34 @@ begin
end end
else if PayLen = 127 then else if PayLen = 127 then
begin begin
PayLen := (Ws.BufData[6] shl 24) or (Ws.BufData[7] shl 16) or // 64-битная длина: старшие четыре байта обязаны быть нулём, иначе
(Ws.BufData[8] shl 8) or Ws.BufData[9]; // значение не помещается в Integer и превращается в отрицательное.
Hi32 := (LongWord(Ws.BufData[2]) shl 24) or (LongWord(Ws.BufData[3]) shl 16) or
(LongWord(Ws.BufData[4]) shl 8) or LongWord(Ws.BufData[5]);
if Hi32 <> 0 then begin Ws.State := wsClosed; Break; end;
Hi32 := (LongWord(Ws.BufData[6]) shl 24) or (LongWord(Ws.BufData[7]) shl 16) or
(LongWord(Ws.BufData[8]) shl 8) or LongWord(Ws.BufData[9]);
if Hi32 > LongWord(SizeOf(Raw)) then begin Ws.State := wsClosed; Break; end;
PayLen := Integer(Hi32);
Inc(i, 8); Inc(i, 8);
end; end;
// Клиент ОБЯЗАН маскировать (RFC 6455 §5.1). Незамаскированный кадр —
// либо не клиент, либо попытка прогнать через нас чужой трафик.
if not Masked then begin Ws.State := wsClosed; Break; end;
// Управляющие кадры: только короткие и только целиком (§5.5).
if (Opcode >= $08) and ((PayLen > 125) or (not Fin)) then
begin
Ws.State := wsClosed;
Break;
end;
// Фрейм крупнее приёмного буфера TWsClient никогда не соберётся — // Фрейм крупнее приёмного буфера TWsClient никогда не соберётся —
// BufLen упрётся в потолок и цикл встанет намертво. Рвём соединение: // BufLen упрётся в потолок и цикл встанет намертво. Рвём соединение:
// команд такой длины у TCI нет, а бинарные потоки от клиента (TX-аудио) // команд такой длины у TCI нет, а бинарные потоки от клиента (TX-аудио)
// мы пока не принимаем. // мы пока не принимаем.
if Need + PayLen > 4096 then if Need + PayLen > SizeOf(Raw) then
begin begin
Ws.State := wsClosed; Ws.State := wsClosed;
Break; Break;
@@ -582,20 +851,16 @@ begin
if Ws.BufLen < Need + PayLen then Break; if Ws.BufLen < Need + PayLen then Break;
if Masked then Mask[0] := Ws.BufData[i]; Mask[1] := Ws.BufData[i+1];
begin Mask[2] := Ws.BufData[i+2]; Mask[3] := Ws.BufData[i+3];
Mask[0] := Ws.BufData[i]; Mask[1] := Ws.BufData[i+1]; Inc(i, 4);
Mask[2] := Ws.BufData[i+2]; Mask[3] := Ws.BufData[i+3];
Inc(i, 4);
end;
SetLength(Payload, PayLen); SetLength(Payload, PayLen);
if PayLen > 0 then if PayLen > 0 then
begin begin
Move(Ws.BufData[i], Payload[0], PayLen); Move(Ws.BufData[i], Payload[0], PayLen);
if Masked then for j := 0 to PayLen - 1 do
for j := 0 to PayLen - 1 do Payload[j] := Payload[j] xor Mask[j and 3];
Payload[j] := Payload[j] xor Mask[j and 3];
end; end;
Consumed := i + PayLen; Consumed := i + PayLen;
@@ -604,18 +869,49 @@ begin
Ws.BufLen := Ws.BufLen - Consumed; Ws.BufLen := Ws.BufLen - Consumed;
case Opcode of case Opcode of
$01: // текст — одна или несколько команд в одном фрейме $00, $01, $02: // данные: продолжение / текст / binary
begin begin
SetLength(Text, PayLen); if Opcode = $00 then
if PayLen > 0 then Move(Payload[0], Text[1], PayLen);
if Assigned(FOnCommand) then
begin begin
TCISplit(Text, Cmds); // Продолжение без начала — рассинхрон, дальше читать нечего.
for j := 0 to Cmds.Count - 1 do if MsgOp = 0 then begin Ws.State := wsClosed; Break; end;
FOnCommand(Client, Cmds[j]); end
else
begin
// Новое сообщение поверх недособранного — тоже рассинхрон.
if MsgOp <> 0 then begin Ws.State := wsClosed; Break; end;
MsgOp := Opcode;
Frag := '';
end;
// Копим только текст: binary — это TX-аудио от клиента, этап 2.
if MsgOp = $01 then
begin
if Length(Frag) + PayLen > TCI_MSG_MAX then
begin
Ws.State := wsClosed;
Break;
end;
if PayLen > 0 then
begin
SetLength(Text, PayLen);
Move(Payload[0], Text[1], PayLen);
Frag := Frag + Text;
end;
end;
if Fin then
begin
if (MsgOp = $01) and Assigned(FOnCommand) and (Frag <> '') then
begin
TCISplit(Frag, Cmds);
for j := 0 to Cmds.Count - 1 do
FOnCommand(Client, Cmds[j]);
end;
MsgOp := 0;
Frag := '';
end; end;
end; end;
$02: ; // binary: TX-аудио от клиента — этап 2, пока игнорируем
$08: // close $08: // close
begin begin
Ws.State := wsClosed; Ws.State := wsClosed;
@@ -624,14 +920,17 @@ begin
$09: // ping → pong $09: // ping → pong
if PayLen > 0 then Ws.SendWsFrame($0A, Payload[0], PayLen) if PayLen > 0 then Ws.SendWsFrame($0A, Payload[0], PayLen)
else Ws.SendWsFrame($0A, PayLen, 0); else Ws.SendWsFrame($0A, PayLen, 0);
$0A: ; // pong — ничего не ждём
else
// Незнакомый opcode: по RFC соединение обязано закрыться.
Ws.State := wsClosed;
Break;
end; end;
end; end;
end; end;
finally finally
Cmds.Free; Cmds.Free;
end; end;
RemoveClient(Client);
end; end;
{ ═══════════════════════════════════════════════════════════════════════════ { ═══════════════════════════════════════════════════════════════════════════
@@ -644,6 +943,8 @@ begin
if S = '' then Exit; if S = '' then Exit;
FClientLock.Enter; FClientLock.Enter;
try try
// Send только кладёт строку в очередь клиента — лок держится микросекунды,
// сколько бы клиент ни тормозил. В сокеты пишет тик-поток.
for i := 0 to FClientCount - 1 do for i := 0 to FClientCount - 1 do
if (FClients[i] <> nil) and (FClients[i] <> Skip) and FClients[i].Ready then if (FClients[i] <> nil) and (FClients[i] <> Skip) and FClients[i].Ready then
FClients[i].Send(S); FClients[i].Send(S);
@@ -659,7 +960,7 @@ begin
FClientLock.Enter; FClientLock.Enter;
try try
for i := 0 to FClientCount - 1 do for i := 0 to FClientCount - 1 do
if FClients[i] <> nil then Proc(FClients[i]); if (FClients[i] <> nil) and not FClients[i].FClosed then Proc(FClients[i]);
finally finally
FClientLock.Leave; FClientLock.Leave;
end; end;
@@ -681,7 +982,9 @@ begin
begin begin
Sleep(TCI_TICK_MS); Sleep(TCI_TICK_MS);
if not FRunning then Break; if not FRunning then Break;
ReapClients; // отключившиеся — освобождаем только здесь
if (FClientCount > 0) and Assigned(FOnTick) then FOnTick; if (FClientCount > 0) and Assigned(FOnTick) then FOnTick;
FlushClients; // очереди → сокеты
end; end;
end; end;
+76 -10
View File
@@ -39,7 +39,30 @@ TCI-клиенты ──WebSocket──► TTCIServer ──► TTCIAdapter ─
подключённым — как того требует §3.5 спецификации. Отвечающий на команду подключённым — как того требует §3.5 спецификации. Отвечающий на команду
клиент дополнительно получает прямой ответ. клиент дополнительно получает прямой ответ.
### 1.1 Настройки ### 1.1 Три правила, на которых держится транспорт
Всё это не украшения, а лечение конкретных отказов — менять с оглядкой.
1. **Отправка никогда не блокирует вызывающего.** `Send`/`Broadcast` кладут
строку в очередь клиента (микросекунды под его локом), в сокет пишет
тик-поток (`FlushClients`, 20 мс, вне общего лока). Уведомления рождаются
внутри `Changed()` контроллера, то есть в UI-потоке: писать оттуда прямо в
сокет означало бы отдать интерфейс во власть самого медленного клиента
(таймаут отправки × число клиентов на каждое движение ручки VFO).
Переполнилась очередь (`TCI_OUT_MAX`) или не прошла запись — клиент
выбрасывается, а не тормозит остальных.
2. **Объект клиента освобождает только тик-поток** (`ReapClients`) и только
после того, как клиентский поток честно вышел. Поэтому указатель, взятый
кем угодно под `FClientLock`, гарантированно жив внутри лока.
3. **`Stop` прокачивает очередь `Synchronize`.** Останавливает сервер поток
контроллера (UI), а клиентский поток в этот момент может висеть как раз на
`Invoke` в него же. Без прокачки это взаимный клин; по его таймауту сервер
освобождал бы объекты из-под живых потоков. Дополнительно на время
остановки взводится `Stopping`, и адаптер новых `Invoke` уже не начинает.
Если поток всё же не вышел — объект НЕ освобождается: утечка на выходе
дешевле обращения к освобождённой памяти.
### 1.2 Настройки
Секция `tci` в корне `settings.json`: Секция `tci` в корне `settings.json`:
@@ -51,7 +74,19 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
**нет авторизации**: открытый наружу порт означает полный доступ к трансиверу, **нет авторизации**: открытый наружу порт означает полный доступ к трансиверу,
поэтому умолчание слушает только петлю. поэтому умолчание слушает только петлю.
### 1.2 Маппинг модели Отсюда же два правила вокруг адреса:
- разбор `bind_addr` строгий (ровно четыре октета 0..255); всё непонятное —
отказ поднимать сервер, а не молчаливый `0.0.0.0`. Пустая строка и явный
`0.0.0.0` — единственные способы попросить «все интерфейсы»;
- порт и адрес применяются по уходу фокуса из поля и по кнопке Close, а не на
каждое нажатие клавиши: иначе набор `127.0.0.1` по дороге проходил бы через
«`127.0.0.`» и сервер успевал перезапуститься на всех интерфейсах.
Отказ старта (порт занят, адрес не разобран) виден оператору: `ApplySettings`
возвращает результат, MainForm показывает сообщение.
### 1.3 Маппинг модели
| TCI | EWSDR | | TCI | EWSDR |
|---|---| |---|---|
@@ -101,7 +136,7 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
| `RX_MUTE`, `RX_VOLUME` | громкость/мьют слайса | | `RX_MUTE`, `RX_VOLUME` | громкость/мьют слайса |
| `MON_VOLUME`, `MON_ENABLE` | `SetTXMonVolume`, `SetRxMuteOnTx` | | `MON_VOLUME`, `MON_ENABLE` | `SetTXMonVolume`, `SetRxMuteOnTx` |
| `AGC_MODE` | `off`→Off, `fast`→Fast, `normal`→Medium | | `AGC_MODE` | `off`→Off, `fast`→Fast, `normal`→Medium |
| `AGC_GAIN` | `SetAGCTop` (AGC-T) | | `AGC_GAIN` | `SetAGCTop` (AGC-T)**только приёмник 0**, см. §3 |
| `RX_NR_ENABLE`, `RX_NB_ENABLE`, `RX_ANF_ENABLE` | `SetNR/SetNB/SetANF`, для панов — `SetSliceDSP` | | `RX_NR_ENABLE`, `RX_NB_ENABLE`, `RX_ANF_ENABLE` | `SetNR/SetNB/SetANF`, для панов — `SetSliceDSP` |
| `LOCK` | `SetVfoLock` | | `LOCK` | `SetVfoLock` |
| `SQL_ENABLE`, `SQL_LEVEL` | FM-шумоподавитель; дБ (-140..0) ↔ порог 0..100 | | `SQL_ENABLE`, `SQL_LEVEL` | FM-шумоподавитель; дБ (-140..0) ↔ порог 0..100 |
@@ -111,10 +146,14 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
### 2.3 Однонаправленное управление (§4.3) ### 2.3 Однонаправленное управление (§4.3)
`TX_ENABLE` (по `BackendCaps.HasTX` + `TXProhibited`), `CW_MACROS_SPEED_UP/DOWN`, `TX_ENABLE` (по `BackendCaps.HasTX` + `TXProhibited`), `CW_MACROS_SPEED_UP/DOWN`,
`SET_IN_FOCUS` (поднимает окно программы через `OnFocusRequest` — адаптер до
окна не дотягивается, действие ставит MainForm),
`SPOT`, `SPOT_DELETE`, `SPOT_CLEAR``TDXSpotStore`, спот виден на всех `SPOT`, `SPOT_DELETE`, `SPOT_CLEAR``TDXSpotStore`, спот виден на всех
панадаптерах), `RX_SENSORS_ENABLE`, `TX_SENSORS_ENABLE` (период — на клиента), панадаптерах), `RX_SENSORS_ENABLE`, `TX_SENSORS_ENABLE` (период — на клиента),
`IQ_SAMPLERATE`, `AUDIO_SAMPLERATE`, `AUDIO_STREAM_*`, `TX_STREAM_AUDIO_BUFFERING` `IQ_SAMPLERATE`, `AUDIO_SAMPLERATE`, `AUDIO_STREAM_*`, `TX_STREAM_AUDIO_BUFFERING`
(значения принимаются и подтверждаются; сами потоки — этап 2). (значения принимаются и подтверждаются; сами потоки — этап 2). Параметры
потоков — настройки **клиента**, а не устройства: живут в `TTCIClient`, и один
клиент не переопределяет их остальным.
### 2.4 Уведомления (§4.4, §4.5) ### 2.4 Уведомления (§4.4, §4.5)
@@ -124,6 +163,16 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
`CLICKED_ON_SPOT` (клик по подписи спота на любом панадаптере), `CLICKED_ON_SPOT` (клик по подписи спота на любом панадаптере),
`CALLSIGN_SEND` (после `CW_MSG`). `CALLSIGN_SEND` (после `CW_MSG`).
Отдельная история — **доп. приёмники**. У главного тракта на каждое поле есть
своё `rfXxx`, а у слайсов не было ничего: правка слайса не доходила ни до UI,
ни до остальных клиентов. Поэтому в контроллере появилось `rfSliceState`
(полезная нагрузка — `FSliceFreqId`, как у `rfSliceFreq`), и его шлют сами
сеттеры слайса: `SetSliceMode`, `SetSliceFilter`, `SetSliceAGCMode`,
`SetSliceVolume`, `SetSliceMute`, `SetSliceDSP`, `SetSliceFMSquelch`.
Адаптер разворачивает Id обратно в пару (приёмник, канал) и рассылает
состояние именно этого канала. Частоту слайса, поставленную по TCI, тоже
сопровождает `SliceFreqChanged` — как это делает CAT.
### 2.5 Телеграф (§3.2) ### 2.5 Телеграф (§3.2)
`CW_MACROS`, `CW_MSG`, `CW_MACROS_STOP`, `CW_TERMINAL`. `CW_MACROS`, `CW_MSG`, `CW_MACROS_STOP`, `CW_TERMINAL`.
@@ -153,7 +202,17 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
| `RX_BALANCE` | баланса каналов у слайса нет | | `RX_BALANCE` | баланса каналов у слайса нет |
| `DIGL_OFFSET`, `DIGU_OFFSET` | смещения цифровых мод не реализованы | | `DIGL_OFFSET`, `DIGU_OFFSET` | смещения цифровых мод не реализованы |
| `RX_CHANNEL_ENABLE` | канал B главного приёмника — это VFO B, он есть всегда; создание второго слайса на пане по TCI — этап 2 | | `RX_CHANNEL_ENABLE` | канал B главного приёмника — это VFO B, он есть всегда; создание второго слайса на пане по TCI — этап 2 |
| `SET_IN_FOCUS` | окно программы не поднимаем |
## 3.1 Ограничения, о которых честнее знать заранее
| Что | Как ведёт себя |
|---|---|
| `AGC_GAIN` у приёмника > 0 | AGC-T в ewsdr один на приёмный тракт, у слайса своего нет. Команда от имени доп. приёмника **игнорируется** (раньше молча правила главный), в ответ уходит текущее значение |
| Цвет спота (`SPOT`, arg4 ARGB) | не читается: `TDXSpot` цвета не хранит, подписи красятся по моде/возрасту |
| `KEYER`, `TX_FOOTSWITCH` | не реализованы: своего ключа-уведомления и опроса педали наружу у контроллера нет |
| Арбитраж клиентов (§3.5, захват параметра ~200 мс) | нет. Команды разных клиентов идут подряд, последняя побеждает. С двумя активными логгерами возможна «перетяжка» частоты или моды |
| Мода, фильтр, АРУ, шумодавы у канала B доп. пана | в TCI это свойства **приёмника**, а не канала: они относятся к каналу A. У канала B по протоколу есть только частота, IF и громкость |
| Команды конфигурации потоков | подтверждаются как принятые, хотя самих потоков нет (этап 2). Клиент по ответу может решить, что функция доступна |
Отдельно: у `TX_SENSORS` второй аргумент — уровень микрофона; измерителя Отдельно: у `TX_SENSORS` второй аргумент — уровень микрофона; измерителя
микрофона в EWSDR нет, шлём нижнюю границу шкалы (-60 дБм), чтобы клиент не микрофона в EWSDR нет, шлём нижнюю границу шкалы (-60 дБм), чтобы клиент не
@@ -185,7 +244,11 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
5. **`LINEOUT_STREAM` + `LINE_OUT_RECORDER_*`** — запись в WAV/MP3. 5. **`LINEOUT_STREAM` + `LINE_OUT_RECORDER_*`** — запись в WAV/MP3.
Также в очереди: `RX_CHANNEL_ENABLE` как реальное создание/удаление второго Также в очереди: `RX_CHANNEL_ENABLE` как реальное создание/удаление второго
слайса пана и `SET_IN_FOCUS`. слайса пана, `KEYER` и арбитраж нескольких клиентов (§3.5).
Приёмный буфер `TWsClient` — 4 КБ, и сейчас это жёсткий потолок: кадр крупнее
рвёт соединение (команд такой длины у TCI нет). Под TX-аудио его придётся
растить вместе с этапом 2.
--- ---
@@ -193,10 +256,13 @@ UI — вкладка **Advanced → TCI Server** (галка, порт, инт
Стендом (WS-клиент на сыром сокете, без внешних библиотек): Стендом (WS-клиент на сыром сокете, без внешних библиотек):
- **Транспорт:** handshake, маска входящих фреймов, несколько команд в одном - **Транспорт:** строгий разбор bind-адреса и отказ подниматься на кривом;
фрейме, регистронезависимость, экранированный текст, ping/pong, игнорирование первый кадр, приклеенный к пакету handshake; сборка фрагментированного
бинарных фреймов, рассылка всем клиентам, чистая остановка сервера с живым сообщения; несколько команд в одном кадре; отказ от незамаскированных кадров
клиентом (потоки дренируются, зависаний нет). с разрывом соединения; ping/pong; рассылка двум клиентам; чистая остановка с
живыми клиентами; медленный клиент (5000 рассылок не блокируют вызывающего,
клиент вылетает сам); остановка сервера в тот момент, когда команда клиента
висит в `Synchronize` у потока контроллера.
- **Сквозной прогон** с настоящим `TRadioController` (движки созданы, железо не - **Сквозной прогон** с настоящим `TRadioController` (движки созданы, железо не
подключено): пачка инициализации из 57 строк со всеми обязательными подключено): пачка инициализации из 57 строк со всеми обязательными
командами и `READY` в конце; `VFO`, `MODULATION` (`cw` на 7 МГц дал CWL), командами и `READY` в конце; `VFO`, `MODULATION` (`cw` на 7 МГц дал CWL),