mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 17:27:32 +00:00
Аттенюатор был только в web и только тремя ступенями (0/-10/-20), а его дБ
никуда не возвращались — при ATT 20 дБ S-метр и спектр уезжали на 20 дБ, и
свежая калибровка уровней была верна лишь при ATT=0.
- RadioController: SetAtten(Db) 0..31 (как умеет железо) вместо индекса *10.
AttenCalDB входит в SMeterCalFor и DispCalFor — стрелка, спектр, водопад и
паны компенсируются, шкала dBm остаётся абсолютной (как Thetis/piHPSDR).
У AD936x аттенюатора нет, там поправка 0 (усиление тракта — hardwaregain).
- Память на диапазон/слот трансвертера по образцу RxGainMode/RxGainDb:
TBandSettings.AttenDB (band 'atten_db') и TXvtrEntry.LastAttenDB
('last_atten_db'); восстановление и отправка в железо — в ApplyBandDSP,
сбор — в MakeBandSettings/SaveCurrentBand, вход в слот — через B.AttenDB.
SetAtten сразу кладёт значение в кэш диапазона/слота (JSON пишется общим
сохранением, а не на каждый шаг ползунка — как у drive).
- MainForm: ряд «ATT [ползунок] NNdB» в RX-блоке левой панели, отступы как у
VOL; на Pluto ряд скрыт и RX-блок ужимается (LayoutLeftPanel двигает
панели ниже). Внешние изменения приходят по rfAtten.
- Web: select → ползунок 0..31 с цифрой, attn_idx -> attn_db, cmd attn {db:}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1963 lines
77 KiB
ObjectPascal
1963 lines
77 KiB
ObjectPascal
unit WebServer;
|
||
|
||
{
|
||
WebServer.pas — HTTP + WebSocket сервер для удалённого управления трансивером.
|
||
|
||
Архитектура (по образцу OpenWebRX):
|
||
─────────────────────────────────
|
||
HTTP GET / → index.html (см. WebPageHtml)
|
||
HTTP GET /ws → Upgrade: WebSocket
|
||
WebSocket сессия:
|
||
• Сервер → клиент:
|
||
- каждые ~50 ms: бинарный фрейм типа 'S' + 1024×Float32 спектр
|
||
- каждые ~50 ms: бинарный фрейм типа 'W' + N×Float32 waterfall строка
|
||
- каждые ~100ms: бинарный фрейм типа 'A' + Opus-пакет (48kHz mono)
|
||
- каждые ~200ms: JSON-текст со state (freq, mode, smeter, …)
|
||
• Клиент → сервер: JSON-команды
|
||
"cmd":"freq","hz":14200000
|
||
"cmd":"mode","mode":1
|
||
"cmd":"filter","bw":2700
|
||
"cmd":"agc","mode":1
|
||
"cmd":"agctop","db":90
|
||
"cmd":"band","idx":5
|
||
"cmd":"span","hz":192000
|
||
"cmd":"volume","v":70
|
||
"cmd":"wfagc","on":true
|
||
"cmd":"wfnf","on":true
|
||
|
||
Аудио: 48kHz mono Float32 → Opus (20ms frames, 32 kbps)
|
||
Спектр: 1024 Float32 dBm значений
|
||
Авторизация: Basic Auth через HTTP заголовок при первом запросе
|
||
|
||
Зависимости: WebUtils, WsClient, WebPageHtml + RTL + libopus (динамическая загрузка)
|
||
Платформы: Windows + Linux (Winsock2 / BSD sockets)
|
||
|
||
ИСПРАВЛЕНИЯ:
|
||
- (Windows build fix) SyncObjs перенесён в конец блока uses — устраняет
|
||
конфликт идентификатора Create с символами из WinSock2 в {$MODE Delphi}.
|
||
- (Windows runtime fix) Добавлены WSAStartup/WSACleanup в конструктор и
|
||
деструктор — без этого socket/bind/listen возвращают WSANOTINITIALISED.
|
||
- (Linux shutdown fix) В Stop: перед SockClose вызывается SockShutdown для
|
||
listen-сокета и для каждого клиентского сокета. На Linux закрытие
|
||
дескриптора не прерывает блокирующий fpAccept/fpRecv в чужом потоке —
|
||
только shutdown(SHUT_RDWR) гарантированно разблокирует их, позволяя
|
||
потокам выйти и WaitFor завершиться без зависания.
|
||
- (Audio fix 1) Исправлена константа OPUS_APPLICATION_AUDIO: было 2101
|
||
(невалидное значение), стало 2049 — правильное значение. Неверная
|
||
константа приводила к Err!=0 из opus_encoder_create, FOpusEnc=nil,
|
||
FOpusReady=false — аудио не кодировалось совсем.
|
||
- (Audio fix 2) Заголовки COOP/COEP убраны — они блокировали WebSocket
|
||
и загрузку CDN ресурсов (fonts, opus-decoder), из-за чего
|
||
FWebClientActive никогда не становился true и десктоп звук не
|
||
отключался при подключении веб-клиента.
|
||
- (Audio fix 3) В JS исправлен вызов декодера: decodeFrame → decode
|
||
(актуальный API opus-decoder@0.7.7). decodeFrame не существует в этой
|
||
версии — silent fail, звука нет.
|
||
- (Audio fix 4) Добавлен оверлей "Click to start audio" — AudioContext
|
||
нельзя создать из WebSocket callback (не user gesture). Оверлей
|
||
гарантирует создание AudioContext при первом кликe пользователя.
|
||
- (Audio fix 5) Буферизация Opus-пакетов пока WASM не инициализирован —
|
||
первые пакеты больше не теряются при медленной загрузке CDN.
|
||
}
|
||
|
||
{$IFDEF FPC}
|
||
{$MODE Delphi}
|
||
{$LONGSTRINGS ON}
|
||
{$ENDIF}
|
||
|
||
interface
|
||
|
||
uses
|
||
Classes, SysUtils, Math,
|
||
WebUtils, WsClient, WebPageHtml, RadioModes
|
||
{$IFDEF WINDOWS}, Windows, WinSock2{$ELSE}, BaseUnix, Sockets{$ENDIF},
|
||
SyncObjs; // ← после платформенных юнитов: исключает конфликт идентификатора Create
|
||
|
||
const
|
||
WEB_PORT = 8080;
|
||
OPUS_SAMPLE_RATE = 48000;
|
||
OPUS_FRAME_MS = 20;
|
||
OPUS_FRAME_SAMP = OPUS_SAMPLE_RATE * OPUS_FRAME_MS div 1000; // 960 samples
|
||
OPUS_BITRATE = 32000;
|
||
OPUS_CHANNELS = 1;
|
||
// ── TX-mic jitter handling (web → server) ──
|
||
// Pre-roll: при старте новой TX-сессии задерживаем 40мс аудио, чтобы дать
|
||
// FTXMicRing запас перед началом потребления — иначе при джиттере WiFi
|
||
// ring мгновенно пустеет и WDSP TX-thread заливает тишину = "робот".
|
||
MIC_PREROLL_MS = 40;
|
||
MIC_PREROLL_SAMP = OPUS_SAMPLE_RATE * MIC_PREROLL_MS div 1000; // 1920
|
||
// Опоздание > MIC_IDLE_MS считаем концом TX-сессии: сбрасываем pre-roll
|
||
// (на следующий пакет — снова накапливаем cushion).
|
||
MIC_IDLE_MS = 200;
|
||
// Максимум подряд синтезированных PLC-кадров (Opus PLC деградирует после ~3–5 кадров).
|
||
MIC_MAX_PLC = 5;
|
||
MAX_WS_CLIENTS = 4;
|
||
WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||
|
||
// Типы бинарных фреймов (первый байт = тип)
|
||
WS_MSG_SPECTRUM = Byte(Ord('S')); // S + 1024×Float32
|
||
WS_MSG_WATERFALL = Byte(Ord('W')); // W + N×Float32
|
||
WS_MSG_AUDIO = Byte(Ord('A')); // A + Opus bytes
|
||
WS_MSG_AUDIO_PCM = Byte(Ord('P')); // P + N×Float32 (mono 48k)
|
||
WS_MSG_STATE = Byte(Ord('J')); // J + JSON text
|
||
|
||
type
|
||
// ── Opus dynamic binding ──────────────────────────────────────────────────
|
||
POpusEncoder = Pointer;
|
||
|
||
TOpus_encoder_create = function(Fs, channels, application: Integer;
|
||
error: PInteger): POpusEncoder; cdecl;
|
||
TOpus_encoder_destroy = procedure(st: POpusEncoder); cdecl;
|
||
TOpus_encode_float = function(st: POpusEncoder;
|
||
pcm: PSingle; frame_size: Integer;
|
||
data: PByte; max_data_bytes: Integer): Integer; cdecl;
|
||
TOpus_encoder_ctl_set = function(st: POpusEncoder;
|
||
request: Integer; value: Integer): Integer; cdecl;
|
||
|
||
POpusDecoder = Pointer;
|
||
TOpus_decoder_create = function(Fs, channels: Integer;
|
||
error: PInteger): POpusDecoder; cdecl;
|
||
TOpus_decode_float = function(st: POpusDecoder; data: PByte; len: Integer;
|
||
pcm: PSingle; frame_size, decode_fec: Integer): Integer; cdecl;
|
||
TOpus_decoder_destroy = procedure(st: POpusDecoder); cdecl;
|
||
|
||
// ── Callbacks в MainForm ──────────────────────────────────────────────────
|
||
TWebCmdFreq = procedure(Hz: Double) of object;
|
||
TWebCmdMode = procedure(Mode: Integer) of object;
|
||
TWebCmdFilter = procedure(BW: Integer) of object;
|
||
TWebCmdAGC = procedure(Mode: Integer) of object;
|
||
TWebCmdAGCTop = procedure(DB: Integer) of object;
|
||
TWebCmdBand = procedure(Idx: Integer) of object;
|
||
TWebCmdSpan = procedure(Hz: Integer) of object;
|
||
TWebCmdVolume = procedure(V: Integer) of object;
|
||
TWebCmdWfAGC = procedure(On_: Boolean) of object;
|
||
TWebCmdWfNF = procedure(On_: Boolean) of object;
|
||
TWebCmdRun = procedure(On_: Boolean) of object;
|
||
TWebCmdMute = procedure(On_: Boolean) of object;
|
||
TWebCmdCtun = procedure(On_: Boolean) of object;
|
||
TWebCmdNRMode = procedure(Mode: Integer) of object;
|
||
TWebCmdNBMode = procedure(Mode: Integer) of object;
|
||
TWebCmdSNB = procedure(On_: Boolean) of object;
|
||
TWebCmdANF = procedure(On_: Boolean) of object;
|
||
TWebCmdFreqB = procedure(Hz: Double) of object;
|
||
TWebCmdActiveVfo = procedure(Idx: Integer) of object;
|
||
TWebCmdCenter = procedure(Hz: Double) of object;
|
||
TWebCmdMOX = procedure(On_: Boolean) of object;
|
||
TWebCmdDrive = procedure(V: Integer) of object;
|
||
TWebCmdFreqA = procedure(Hz: Double) of object;
|
||
TWebCmdAttn = procedure(Idx: Integer) of object;
|
||
TWebCmdTun = procedure(On_: Boolean) of object;
|
||
TWebCmdFMStep = procedure(Idx: Integer) of object;
|
||
// XVTR-band: web клиент кликнул кнопку трансвертера (Idx 0..CFG_XVTR_COUNT-1).
|
||
// Idx=-1 — выход в HF.
|
||
TWebCmdXvtrBand = procedure(Idx: Integer) of object;
|
||
// QO-100 beacon lock: вкл/выкл лок и наведение (клик по маяку, доля 0..1 по ширине).
|
||
TWebCmdBeacon = procedure(On_: Boolean) of object;
|
||
TWebCmdBeaconSeed = procedure(Frac: Double) of object;
|
||
// Pluto RF-AGC (hw gain): режим FAST/SLOW/HYB/MAN + ручной gain дБ. RX MUTE — QO-100 self-monitor.
|
||
TWebCmdRxGainMode = procedure(Mode: Integer) of object;
|
||
TWebCmdRxGain = procedure(Db: Integer) of object;
|
||
TWebCmdRxMute = procedure(On_: Boolean) of object;
|
||
// Активность web-клиента изменилась (подключился/отключился последний клиент).
|
||
// Хозяин зеркалит это в ядро (FController.FWebClientActive) для выбора mic-source.
|
||
TWebClientActiveEvent = procedure(Active: Boolean) of object;
|
||
// Один XVTR-слот для статуса (для отображения в web-bandSel).
|
||
TWebXvtrInfo = record
|
||
Idx: Integer;
|
||
Name: string;
|
||
end;
|
||
TWebXvtrArray = array of TWebXvtrInfo;
|
||
TWebMicCB = procedure(Samples: PSingle; Count: Integer) of object;
|
||
|
||
// ── Discovery / connect / saved-CRUD (паритет с десктоп-диалогом) ──────────
|
||
TWebCmdConnect = procedure(const IP: string) of object; // подключиться к IP
|
||
TWebCmdSimple = procedure of object; // discover / disconnect
|
||
TWebCmdDevAdd = procedure(const AName, IP: string) of object;
|
||
TWebCmdDevIdx = procedure(Idx: Integer) of object; // dev_remove / dev_autostart
|
||
// Элемент списка устройств для web-overlay (saved или discovered).
|
||
TWebDeviceInfo = record
|
||
Name: string; // имя (saved) или display-строка (discovered)
|
||
IP: string;
|
||
BoardType: Integer;
|
||
IsSaved: Boolean; // true=сохранённое (с именем), false=найденное
|
||
AutoStart: Boolean; // только для saved
|
||
end;
|
||
TWebDeviceArray = array of TWebDeviceInfo;
|
||
|
||
// ── Главный класс сервера ─────────────────────────────────────────────────
|
||
TWebServer = class
|
||
private
|
||
// ── Opus ──
|
||
FOpusLib: THandle;
|
||
FOpusEnc: POpusEncoder;
|
||
FOpusCreate: TOpus_encoder_create;
|
||
FOpusDestroy: TOpus_encoder_destroy;
|
||
FOpusEncode: TOpus_encode_float;
|
||
FOpusCtl: TOpus_encoder_ctl_set;
|
||
FOpusBuf: array[0..OPUS_FRAME_SAMP-1] of Single;
|
||
FOpusBufPos: Integer;
|
||
FOpusOut: array[0..3999] of Byte;
|
||
FWsAudioBuf: array[0..4000] of Byte; // 1 байт типа + до 4000 байт Opus
|
||
FOpusReady: Boolean;
|
||
// Opus decoder — для RX TX-mic аудио от браузера
|
||
FOpusDec: POpusDecoder;
|
||
FOpusDecCreate: TOpus_decoder_create;
|
||
FOpusDecDecode: TOpus_decode_float;
|
||
FOpusDecDestroy: TOpus_decoder_destroy;
|
||
FOnWebMic: TWebMicCB;
|
||
// ── Jitter buffer для TX-mic от веб-клиента ──
|
||
// FMicLastTick = 0 → нет активной TX-сессии (на следующий пакет — pre-roll сброс)
|
||
// FMicPreRollPos < MIC_PREROLL_SAMP → ещё копим pre-roll
|
||
FMicLastTick: QWord;
|
||
FMicPreRollPos: Integer;
|
||
FMicPreRoll: array[0..MIC_PREROLL_SAMP-1] of Single;
|
||
|
||
// ── Staging buffer: выравнивание по границе DSP-блока ──────────────────
|
||
// Opus декодирует по 960 сэмплов, а TX DSP потребляет по 512.
|
||
// 960 mod 512 = 448 — в ring всегда остаётся хвост меньше порога,
|
||
// из-за чего TTXDSPThread пропускает тики и DUC hardware голодает.
|
||
// FlushMicToCallback накапливает сэмплы здесь и вызывает FOnWebMic
|
||
// ровно тогда, когда накопился полный блок 512 сэмплов.
|
||
// Гарантия: FTXMicRing всегда получает данные кратными 512.
|
||
FMicStageBuf: array[0..511] of Single; // буфер одного DSP-блока
|
||
FMicStageLen: Integer; // сколько сэмплов накоплено
|
||
|
||
// ── TCP ──
|
||
FListenSock: TSocket;
|
||
FClients: array[0..MAX_WS_CLIENTS-1] of TWsClient;
|
||
FClientCount: Integer;
|
||
FClientLock: TCriticalSection;
|
||
|
||
// ── Потоки ──
|
||
FAcceptThread: TThread;
|
||
FPushThread: TThread;
|
||
FRunning: Boolean;
|
||
|
||
// ── Авторизация ──
|
||
FAuthToken: string; // Base64(user:pass)
|
||
// ── Сетевая конфигурация ──
|
||
FPort: Word;
|
||
FBindIP: string;
|
||
|
||
// ── Состояние (обновляется из MainForm) ──
|
||
FSpectrumBuf: array[0..1023] of Single;
|
||
FWfBuf: array[0..1023] of Single;
|
||
FWfCount: Integer;
|
||
FSMeter: Double;
|
||
FFreq: Double;
|
||
FMode: Integer;
|
||
FFilterBW: Integer;
|
||
FAGCMode: Integer;
|
||
FAGCTop: Integer;
|
||
FSpanHz: Double;
|
||
FVolume: Integer;
|
||
FWfAGC: Boolean;
|
||
FWfNF: Boolean;
|
||
FBandIdx: Integer;
|
||
FConnected: Boolean;
|
||
FTrxRunning: Boolean;
|
||
FMuted: Boolean;
|
||
FCtun: Boolean;
|
||
FNRMode: Integer;
|
||
FNBMode: Integer;
|
||
FSNB: Boolean;
|
||
FANF: Boolean;
|
||
FCenterHz: Double;
|
||
FFilterIdx: Integer;
|
||
FVfoB: Double;
|
||
FActiveVfo: Integer; // 0=A, 1=B
|
||
FFMStepIdx: Integer;
|
||
FStateLock: TCriticalSection;
|
||
|
||
// ── Callbacks ──
|
||
FOnFreq: TWebCmdFreq;
|
||
FOnMode: TWebCmdMode;
|
||
FOnFilter: TWebCmdFilter;
|
||
FOnAGC: TWebCmdAGC;
|
||
FOnAGCTop: TWebCmdAGCTop;
|
||
FOnBand: TWebCmdBand;
|
||
FOnSpan: TWebCmdSpan;
|
||
FOnVolume: TWebCmdVolume;
|
||
FOnWfAGC: TWebCmdWfAGC;
|
||
FOnWfNF: TWebCmdWfNF;
|
||
FOnRun: TWebCmdRun;
|
||
FOnMute: TWebCmdMute;
|
||
FOnCtun: TWebCmdCtun;
|
||
FOnNR: TWebCmdNRMode;
|
||
FOnNB: TWebCmdNBMode;
|
||
FOnSNB: TWebCmdSNB;
|
||
FOnANF: TWebCmdANF;
|
||
FOnFreqB: TWebCmdFreqB;
|
||
FOnActiveVfo: TWebCmdActiveVfo;
|
||
FOnCenter: TWebCmdCenter;
|
||
FOnMOX: TWebCmdMOX;
|
||
FOnDrive: TWebCmdDrive;
|
||
FTransmitting: Boolean;
|
||
FDriveLevel: Integer;
|
||
FOnFreqA: TWebCmdFreqA;
|
||
FOnAttn: TWebCmdAttn;
|
||
FAttnDB: Integer; // шаговый аттенюатор ADC, дБ (0..31)
|
||
FOnTun: TWebCmdTun;
|
||
FOnFMStep: TWebCmdFMStep;
|
||
FTuning: Boolean;
|
||
FDuplex: Boolean;
|
||
FFreqMhzDigits: Integer; // 3=999MHz, 4=9.999GHz, 5=99.999GHz
|
||
FXvtrBands: TWebXvtrArray; // список enabled XVTR (для web-UI)
|
||
FDevices: TWebDeviceArray; // saved+discovered устройства (для web-overlay)
|
||
FRatePresets: array of Integer; // sample-rate пресеты текущего бэкенда (для span-кнопок)
|
||
FBandNames: array of string; // band-план текущего бэкенда (имена кнопок)
|
||
FBandFreqs: array of Double; // band-план: дефолтные частоты (для подсветки selector)
|
||
FFreqMin: Double; // диапазон перестройки бэкенда (клампинг drag/wheel)
|
||
FFreqMax: Double;
|
||
FCurrentXvtr: Integer; // -1 = HF, иначе индекс активного XVTR
|
||
FOnXvtrBand: TWebCmdXvtrBand;
|
||
|
||
FFwdW: Double;
|
||
FSWR: Double;
|
||
FPAMaxPower: Double;
|
||
FStatusText: string;
|
||
FBoardText: string;
|
||
FIPText: string;
|
||
FSupplyText: string;
|
||
FPLLText: string;
|
||
FRXText: string;
|
||
FTXText: string;
|
||
FSeqText: string;
|
||
|
||
// QO-100 beacon lock (Pluto в трансвертере). Зеркалится в state как
|
||
// beacon_visible/on/text/ref_hz/track_hz; команды set_beacon/beacon_seed.
|
||
FBeaconVisible: Boolean; // кнопка/статус активны (Pluto + XVTR)
|
||
FBeaconOn: Boolean; // лок включён (подсветка кнопки)
|
||
FBeaconText: string; // компактный статус (ячейка PLL, как десктоп)
|
||
FBeaconRefHz: Double; // опорная частота маяка (маркер)
|
||
FBeaconTrackHz: Double; // измеренная частота маяка (маркер)
|
||
FOnBeacon: TWebCmdBeacon;
|
||
FOnBeaconSeed: TWebCmdBeaconSeed;
|
||
|
||
// Pluto RF-AGC (hw gain) + QO-100 RX MUTE. Зеркалится в state как
|
||
// rf_agc_visible/rx_gain_mode/rx_gain_db и rx_mute_visible/rx_mute_on;
|
||
// команды set_rx_gain_mode/set_rx_gain/set_rx_mute.
|
||
FRfAgcVisible: Boolean; // RF-AGC доступен (Pluto подключён, HasHWGain)
|
||
FRxGainMode: Integer; // 0=manual,1=fast,2=slow,3=hybrid
|
||
FRxGainDb: Integer; // ручной gain дБ (0..73)
|
||
FRxMuteVisible: Boolean; // RX MUTE доступна (Pluto + QO-100)
|
||
FRxMuteOn: Boolean; // self-monitor mute активен
|
||
FOnRxGainMode: TWebCmdRxGainMode;
|
||
FOnRxGain: TWebCmdRxGain;
|
||
FOnRxMute: TWebCmdRxMute;
|
||
|
||
FWebClientActive: Boolean;
|
||
FOnClientActiveChanged: TWebClientActiveEvent;
|
||
|
||
// Discovery / connect / saved-CRUD callbacks (маршалятся хозяином в GUI).
|
||
FOnConnect: TWebCmdConnect;
|
||
FOnDiscoverDev: TWebCmdSimple;
|
||
FOnDisconnectDev: TWebCmdSimple;
|
||
FOnDevAdd: TWebCmdDevAdd;
|
||
FOnDevRemove: TWebCmdDevIdx;
|
||
FOnDevAutoStart: TWebCmdDevIdx;
|
||
|
||
// Обновляет FWebClientActive и при изменении уведомляет хозяина. Вызывать
|
||
// под уже взятой блокировкой поля (FStateLock/FClientLock) — сам не лочит.
|
||
procedure SetClientActive(Value: Boolean);
|
||
|
||
// ── Внутренние методы ──
|
||
function LoadOpus: Boolean;
|
||
procedure UnloadOpus;
|
||
procedure PushMicSamples(P: PSingle; N: Integer);
|
||
procedure FlushMicToCallback(P: PSingle; N: Integer);
|
||
function InitListen: Boolean;
|
||
procedure AcceptLoop;
|
||
procedure PushLoop;
|
||
procedure HandleClient(Client: TWsClient);
|
||
procedure ProcessCommand(Client: TWsClient; const Json: string);
|
||
procedure BroadcastBinary(const Data; Len: Integer);
|
||
procedure BroadcastText(const S: string);
|
||
procedure RemoveClient(Client: TWsClient);
|
||
function BuildStateJson: string;
|
||
function CheckAuth(const Header: string): Boolean;
|
||
procedure SendHttp(Client: TWsClient; Code: Integer; const ContentType, Body: string);
|
||
// Stub-методы (реализация встроена в HandleClient)
|
||
procedure DoHandshake(Client: TWsClient);
|
||
procedure ProcessWsFrame(Client: TWsClient; const Data: array of Byte; Len: Integer; Opcode: Byte);
|
||
|
||
public
|
||
constructor Create(const Username, Password: string;
|
||
Port: Word = 8080; const BindIP: string = '0.0.0.0');
|
||
destructor Destroy; override;
|
||
|
||
function Start: Boolean;
|
||
procedure Stop;
|
||
procedure Reconfigure(const Username, Password, BindIP: string; Port: Word);
|
||
|
||
// Вызывается из DSP-потока (аудио, 48kHz mono)
|
||
procedure PushAudio(const Samples: PSingle; Count: Integer);
|
||
// Вызывается из таймера спектра (UI thread)
|
||
procedure PushSpectrum(
|
||
const Buf: array of Single; Count: Integer;
|
||
const WfBuf_: array of Single;
|
||
SMeter: Double;
|
||
Freq: Double; Mode, FilterBW, AGCMode, AGCTop: Integer;
|
||
SpanHz: Double; Volume: Integer;
|
||
WfAGC, WfNF: Boolean; BandIdx: Integer;
|
||
TrxConnected: Boolean;
|
||
TrxRunning, Muted, Ctun: Boolean;
|
||
NRMode, NBMode: Integer; SNB, ANF: Boolean;
|
||
CenterHz: Double; FilterIdx: Integer;
|
||
VfoB: Double; ActiveVfo: Integer;
|
||
Transmitting: Boolean; DriveLevel: Integer;
|
||
AttnDB: Integer; Tuning: Boolean; Duplex: Boolean;
|
||
FwdW, SWRV, PAMaxPower: Double;
|
||
const StatusText, BoardText, IPText, SupplyText, PLLText,
|
||
RXText, TXText, SeqText: string);
|
||
|
||
property WebClientActive: Boolean read FWebClientActive;
|
||
property OnClientActiveChanged: TWebClientActiveEvent
|
||
read FOnClientActiveChanged write FOnClientActiveChanged;
|
||
property FreqMhzDigits: Integer read FFreqMhzDigits write FFreqMhzDigits;
|
||
property FMStepIdx: Integer read FFMStepIdx write FFMStepIdx;
|
||
|
||
property OnFreq: TWebCmdFreq read FOnFreq write FOnFreq;
|
||
property OnMode: TWebCmdMode read FOnMode write FOnMode;
|
||
property OnFilter: TWebCmdFilter read FOnFilter write FOnFilter;
|
||
property OnAGC: TWebCmdAGC read FOnAGC write FOnAGC;
|
||
property OnAGCTop: TWebCmdAGCTop read FOnAGCTop write FOnAGCTop;
|
||
property OnBand: TWebCmdBand read FOnBand write FOnBand;
|
||
property OnXvtrBand: TWebCmdXvtrBand read FOnXvtrBand write FOnXvtrBand;
|
||
procedure SetXvtrBands(const ABands: TWebXvtrArray; ACurrent: Integer);
|
||
procedure SetDeviceList(const ADevices: TWebDeviceArray);
|
||
procedure SetRatePresets(const ARates: array of Integer);
|
||
procedure SetBands(const ANames: array of string; const AFreqs: array of Double);
|
||
procedure SetFreqRange(AMin, AMax: Double);
|
||
// QO-100 beacon lock: зеркало статуса в state (зовётся из PushState хозяина).
|
||
procedure SetBeaconStatus(AVisible, AOn: Boolean; const AText: string;
|
||
ARefHz, ATrackHz: Double);
|
||
// Pluto RF-AGC + QO-100 RX MUTE: зеркало в state (зовётся из PushState хозяина).
|
||
procedure SetRxGainStatus(AVisible: Boolean; AMode, ADb: Integer);
|
||
procedure SetRxMuteStatus(AVisible, AOn: Boolean);
|
||
property OnBeacon: TWebCmdBeacon read FOnBeacon write FOnBeacon;
|
||
property OnBeaconSeed: TWebCmdBeaconSeed read FOnBeaconSeed write FOnBeaconSeed;
|
||
property OnRxGainMode: TWebCmdRxGainMode read FOnRxGainMode write FOnRxGainMode;
|
||
property OnRxGain: TWebCmdRxGain read FOnRxGain write FOnRxGain;
|
||
property OnRxMute: TWebCmdRxMute read FOnRxMute write FOnRxMute;
|
||
property OnSpan: TWebCmdSpan read FOnSpan write FOnSpan;
|
||
property OnVolume: TWebCmdVolume read FOnVolume write FOnVolume;
|
||
property OnWfAGC: TWebCmdWfAGC read FOnWfAGC write FOnWfAGC;
|
||
property OnWfNF: TWebCmdWfNF read FOnWfNF write FOnWfNF;
|
||
property OnRun: TWebCmdRun read FOnRun write FOnRun;
|
||
property OnMute: TWebCmdMute read FOnMute write FOnMute;
|
||
property OnCtun: TWebCmdCtun read FOnCtun write FOnCtun;
|
||
property OnNR: TWebCmdNRMode read FOnNR write FOnNR;
|
||
property OnNB: TWebCmdNBMode read FOnNB write FOnNB;
|
||
property OnSNB: TWebCmdSNB read FOnSNB write FOnSNB;
|
||
property OnANF: TWebCmdANF read FOnANF write FOnANF;
|
||
property OnFreqB: TWebCmdFreqB read FOnFreqB write FOnFreqB;
|
||
property OnActiveVfo: TWebCmdActiveVfo read FOnActiveVfo write FOnActiveVfo;
|
||
property OnCenter: TWebCmdCenter read FOnCenter write FOnCenter;
|
||
property OnMOX: TWebCmdMOX read FOnMOX write FOnMOX;
|
||
property OnDrive: TWebCmdDrive read FOnDrive write FOnDrive;
|
||
property OnFreqA: TWebCmdFreqA read FOnFreqA write FOnFreqA;
|
||
property OnAttn: TWebCmdAttn read FOnAttn write FOnAttn;
|
||
property OnTun: TWebCmdTun read FOnTun write FOnTun;
|
||
property OnFMStep: TWebCmdFMStep read FOnFMStep write FOnFMStep;
|
||
property OnWebMic: TWebMicCB read FOnWebMic write FOnWebMic;
|
||
property OnConnect: TWebCmdConnect read FOnConnect write FOnConnect;
|
||
property OnDiscoverDev: TWebCmdSimple read FOnDiscoverDev write FOnDiscoverDev;
|
||
property OnDisconnectDev: TWebCmdSimple read FOnDisconnectDev write FOnDisconnectDev;
|
||
property OnDevAdd: TWebCmdDevAdd read FOnDevAdd write FOnDevAdd;
|
||
property OnDevRemove: TWebCmdDevIdx read FOnDevRemove write FOnDevRemove;
|
||
property OnDevAutoStart: TWebCmdDevIdx read FOnDevAutoStart write FOnDevAutoStart;
|
||
end;
|
||
|
||
implementation
|
||
|
||
{ ═══════════════════════════════════════════════════════════════════════════
|
||
Внутренние классы потоков
|
||
═══════════════════════════════════════════════════════════════════════════ }
|
||
|
||
type
|
||
TAcceptThread = class(TThread)
|
||
private FServer: TWebServer;
|
||
protected procedure Execute; override;
|
||
public constructor Create(AServer: TWebServer);
|
||
end;
|
||
|
||
TPushThread = class(TThread)
|
||
private FServer: TWebServer;
|
||
protected procedure Execute; override;
|
||
public constructor Create(AServer: TWebServer);
|
||
end;
|
||
|
||
TClientThread = class(TThread)
|
||
private FServer: TWebServer; FClient: TWsClient;
|
||
protected procedure Execute; override;
|
||
public constructor Create(AServer: TWebServer; AClient: TWsClient);
|
||
end;
|
||
|
||
constructor TAcceptThread.Create(AServer: TWebServer);
|
||
begin
|
||
inherited Create(True);
|
||
FServer := AServer;
|
||
FreeOnTerminate := False;
|
||
end;
|
||
|
||
procedure TAcceptThread.Execute;
|
||
begin
|
||
FServer.AcceptLoop;
|
||
end;
|
||
|
||
constructor TPushThread.Create(AServer: TWebServer);
|
||
begin
|
||
inherited Create(True);
|
||
FServer := AServer;
|
||
FreeOnTerminate := False;
|
||
end;
|
||
|
||
procedure TPushThread.Execute;
|
||
begin
|
||
FServer.PushLoop;
|
||
end;
|
||
|
||
constructor TClientThread.Create(AServer: TWebServer; AClient: TWsClient);
|
||
begin
|
||
inherited Create(True);
|
||
FServer := AServer;
|
||
FClient := AClient;
|
||
FreeOnTerminate := True;
|
||
end;
|
||
|
||
procedure TClientThread.Execute;
|
||
begin
|
||
FServer.HandleClient(FClient);
|
||
end;
|
||
|
||
{ ═══════════════════════════════════════════════════════════════════════════
|
||
TWebServer — конструктор / деструктор
|
||
═══════════════════════════════════════════════════════════════════════════ }
|
||
|
||
constructor TWebServer.Create(const Username, Password: string;
|
||
Port: Word; const BindIP: string);
|
||
{$IFDEF WINDOWS}
|
||
var
|
||
WSAData: TWSAData;
|
||
{$ENDIF}
|
||
begin
|
||
{$IFDEF WINDOWS}
|
||
// Инициализация Winsock2 — обязательна перед любыми вызовами socket API
|
||
WSAStartup($0202, WSAData);
|
||
{$ENDIF}
|
||
inherited Create;
|
||
FAuthToken := Base64EncodeStr(Username + ':' + Password);
|
||
FPort := Port;
|
||
FBindIP := BindIP;
|
||
FListenSock := SOCK_INVALID;
|
||
FRunning := False;
|
||
FClientCount := 0;
|
||
FOpusReady := False;
|
||
FOpusBufPos := 0;
|
||
FWebClientActive := False;
|
||
FClientLock := TCriticalSection.Create;
|
||
FStateLock := TCriticalSection.Create;
|
||
// Начальные значения состояния
|
||
FFreq := 14200000;
|
||
FMode := MODE_USB;
|
||
FFMStepIdx := 3; // 25 kHz default
|
||
FFilterBW:= 2700;
|
||
FAGCMode := 1;
|
||
FAGCTop := 90;
|
||
FSpanHz := 192000;
|
||
FVolume := 70;
|
||
FSMeter := -120;
|
||
FFwdW := 0;
|
||
FSWR := 1.0;
|
||
FPAMaxPower := 100.0;
|
||
FStatusText := 'Disconnected';
|
||
FBoardText := 'Board --';
|
||
FIPText := 'IP --';
|
||
FSupplyText := 'Supply --';
|
||
FPLLText := 'PLL --';
|
||
FRXText := 'RX idle';
|
||
FTXText := 'TX idle';
|
||
FSeqText := 'SEQ --';
|
||
FBandIdx := 5;
|
||
FCurrentXvtr := -1;
|
||
FFreqMhzDigits := 3;
|
||
FBeaconVisible := False;
|
||
FBeaconOn := False;
|
||
FBeaconText := '';
|
||
FBeaconRefHz := 0;
|
||
FBeaconTrackHz := 0;
|
||
FRfAgcVisible := False;
|
||
FRxGainMode := 2; // slow_attack по умолчанию (как контроллер)
|
||
FRxGainDb := 40;
|
||
FRxMuteVisible := False;
|
||
FRxMuteOn := False;
|
||
SetLength(FXvtrBands, 0);
|
||
// Bootstrap-набор рейтов (до connect): хост перезапишет под бэкенд (HPSDR/Pluto).
|
||
SetRatePresets([48000, 96000, 192000, 384000, 768000, 1536000]);
|
||
// Bootstrap band-план (HF): хост перезапишет под бэкенд на connect.
|
||
SetBands(['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m'],
|
||
[1840000,3700000,5357000,7100000,10120000,14200000,
|
||
18130000,21300000,24940000,28400000,50150000]);
|
||
SetFreqRange(0, 61440000); // HPSDR bootstrap; хост перезапишет под бэкенд
|
||
end;
|
||
|
||
procedure TWebServer.SetXvtrBands(const ABands: TWebXvtrArray; ACurrent: Integer);
|
||
var i: Integer;
|
||
begin
|
||
FStateLock.Enter;
|
||
try
|
||
SetLength(FXvtrBands, Length(ABands));
|
||
for i := 0 to High(ABands) do
|
||
FXvtrBands[i] := ABands[i];
|
||
FCurrentXvtr := ACurrent;
|
||
finally
|
||
FStateLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
procedure TWebServer.SetBeaconStatus(AVisible, AOn: Boolean; const AText: string;
|
||
ARefHz, ATrackHz: Double);
|
||
begin
|
||
FStateLock.Enter;
|
||
try
|
||
FBeaconVisible := AVisible;
|
||
FBeaconOn := AOn;
|
||
FBeaconText := AText;
|
||
FBeaconRefHz := ARefHz;
|
||
FBeaconTrackHz := ATrackHz;
|
||
finally
|
||
FStateLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
procedure TWebServer.SetRxGainStatus(AVisible: Boolean; AMode, ADb: Integer);
|
||
begin
|
||
FStateLock.Enter;
|
||
try
|
||
FRfAgcVisible := AVisible;
|
||
FRxGainMode := AMode;
|
||
FRxGainDb := ADb;
|
||
finally
|
||
FStateLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
procedure TWebServer.SetRxMuteStatus(AVisible, AOn: Boolean);
|
||
begin
|
||
FStateLock.Enter;
|
||
try
|
||
FRxMuteVisible := AVisible;
|
||
FRxMuteOn := AOn;
|
||
finally
|
||
FStateLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
procedure TWebServer.SetDeviceList(const ADevices: TWebDeviceArray);
|
||
var i: Integer;
|
||
begin
|
||
FStateLock.Enter;
|
||
try
|
||
SetLength(FDevices, Length(ADevices));
|
||
for i := 0 to High(ADevices) do
|
||
FDevices[i] := ADevices[i];
|
||
finally
|
||
FStateLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
procedure TWebServer.SetRatePresets(const ARates: array of Integer);
|
||
// Список пресетов sample-rate текущего бэкенда (из TRadioController). Web строит
|
||
// span-кнопки из него — HPSDR и Pluto имеют разные наборы.
|
||
var i: Integer;
|
||
begin
|
||
FStateLock.Enter;
|
||
try
|
||
SetLength(FRatePresets, Length(ARates));
|
||
for i := 0 to High(ARates) do
|
||
FRatePresets[i] := ARates[i];
|
||
finally
|
||
FStateLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
procedure TWebServer.SetBands(const ANames: array of string; const AFreqs: array of Double);
|
||
// Band-план текущего бэкенда (из TRadioController). Web строит band-selector из
|
||
// него — HPSDR (HF) и Pluto (VHF/UHF) имеют разные планы.
|
||
var i: Integer;
|
||
begin
|
||
FStateLock.Enter;
|
||
try
|
||
SetLength(FBandNames, Length(ANames));
|
||
SetLength(FBandFreqs, Length(AFreqs));
|
||
for i := 0 to High(ANames) do FBandNames[i] := ANames[i];
|
||
for i := 0 to High(AFreqs) do FBandFreqs[i] := AFreqs[i];
|
||
finally
|
||
FStateLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
procedure TWebServer.SetFreqRange(AMin, AMax: Double);
|
||
// Диапазон перестройки текущего бэкенда (caps): web клампит drag/wheel/click по
|
||
// спектру в эти границы. HPSDR 0..61.44МГц / Pluto 46.875МГц..6ГГц.
|
||
begin
|
||
FStateLock.Enter;
|
||
try
|
||
FFreqMin := AMin;
|
||
FFreqMax := AMax;
|
||
finally
|
||
FStateLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
destructor TWebServer.Destroy;
|
||
begin
|
||
Stop;
|
||
FClientLock.Free;
|
||
FStateLock.Free;
|
||
inherited;
|
||
{$IFDEF WINDOWS}
|
||
// Освобождение ресурсов Winsock2
|
||
WSACleanup;
|
||
{$ENDIF}
|
||
end;
|
||
|
||
{ ═══════════════════════════════════════════════════════════════════════════
|
||
Загрузка / выгрузка Opus
|
||
═══════════════════════════════════════════════════════════════════════════ }
|
||
|
||
function TWebServer.LoadOpus: Boolean;
|
||
const
|
||
{$IFDEF WINDOWS} LIBNAME = 'libopus-0.dll';
|
||
{$ELSE} LIBNAME = 'libopus.so.0';
|
||
{$ENDIF}
|
||
var Err: Integer;
|
||
begin
|
||
Result := False;
|
||
FOpusLib := LoadLibrary(LIBNAME);
|
||
if FOpusLib = 0 then Exit;
|
||
|
||
FOpusCreate := TOpus_encoder_create( GetProcAddress(FOpusLib, 'opus_encoder_create'));
|
||
FOpusDestroy := TOpus_encoder_destroy(GetProcAddress(FOpusLib, 'opus_encoder_destroy'));
|
||
FOpusEncode := TOpus_encode_float( GetProcAddress(FOpusLib, 'opus_encode_float'));
|
||
FOpusCtl := TOpus_encoder_ctl_set(GetProcAddress(FOpusLib, 'opus_encoder_ctl'));
|
||
FOpusDecCreate := TOpus_decoder_create( GetProcAddress(FOpusLib, 'opus_decoder_create'));
|
||
FOpusDecDecode := TOpus_decode_float( GetProcAddress(FOpusLib, 'opus_decode_float'));
|
||
FOpusDecDestroy := TOpus_decoder_destroy(GetProcAddress(FOpusLib, 'opus_decoder_destroy'));
|
||
|
||
if not Assigned(FOpusCreate) or not Assigned(FOpusEncode) then
|
||
begin
|
||
FreeLibrary(FOpusLib); FOpusLib := 0; Exit;
|
||
end;
|
||
|
||
FOpusEnc := FOpusCreate(OPUS_SAMPLE_RATE, OPUS_CHANNELS,
|
||
2049 {OPUS_APPLICATION_AUDIO}, @Err);
|
||
if (FOpusEnc = nil) or (Err <> 0) then
|
||
begin
|
||
FreeLibrary(FOpusLib); FOpusLib := 0; Exit;
|
||
end;
|
||
// OPUS_SET_BITRATE_REQUEST = 4002
|
||
if Assigned(FOpusCtl) then
|
||
FOpusCtl(FOpusEnc, 4002, OPUS_BITRATE);
|
||
|
||
// Декодер для TX mic (браузер → Opus → WDSP)
|
||
if Assigned(FOpusDecCreate) then
|
||
begin
|
||
Err := 0;
|
||
FOpusDec := FOpusDecCreate(OPUS_SAMPLE_RATE, OPUS_CHANNELS, @Err);
|
||
if Err <> 0 then FOpusDec := nil;
|
||
end;
|
||
|
||
FOpusBufPos := 0;
|
||
FOpusReady := True;
|
||
Result := True;
|
||
end;
|
||
|
||
procedure TWebServer.FlushMicToCallback(P: PSingle; N: Integer);
|
||
// Аккумулирует сэмплы в FMicStageBuf и вызывает FOnWebMic ровно когда
|
||
// накопился полный DSP-блок (512 сэмплов). Неполный остаток хранится
|
||
// в буфере до следующего вызова.
|
||
//
|
||
// Почему 512: TX DSP поток читает из FTXMicRing блоками по FAudioBufSize=512.
|
||
// Если ring содержит 0 < Avail < 512, поток пропускает тик → DUC underflow.
|
||
// Staging гарантирует что ring всегда получает данные кратными 512.
|
||
var
|
||
Src: PSingle;
|
||
Fill: Integer;
|
||
begin
|
||
if not Assigned(FOnWebMic) or (N <= 0) then Exit;
|
||
Src := P;
|
||
while N > 0 do
|
||
begin
|
||
// Докладываем в stage сколько нужно до полного блока (или сколько есть)
|
||
Fill := Min(N, 512 - FMicStageLen);
|
||
Move(Src^, FMicStageBuf[FMicStageLen], Fill * SizeOf(Single));
|
||
Inc(FMicStageLen, Fill);
|
||
Inc(Src, Fill);
|
||
Dec(N, Fill);
|
||
|
||
// Полный блок готов — передаём в DSP-цепь и сбрасываем stage
|
||
if FMicStageLen = 512 then
|
||
begin
|
||
FOnWebMic(@FMicStageBuf[0], 512);
|
||
FMicStageLen := 0;
|
||
end;
|
||
end;
|
||
end;
|
||
|
||
procedure TWebServer.PushMicSamples(P: PSingle; N: Integer);
|
||
// Прокладка между Opus-декодером и FlushMicToCallback с pre-roll cushion.
|
||
// Первые MIC_PREROLL_SAMP сэмплов TX-сессии копим в FMicPreRoll и сливаем
|
||
// одним блоком — это даёт FTXMicRing запас глубины ~40мс на джиттер.
|
||
// После pre-roll все сэмплы уходят через FlushMicToCallback, которая
|
||
// выравнивает поток по границе 512 сэмплов.
|
||
var
|
||
Want, Remainder: Integer;
|
||
P2: PSingle;
|
||
begin
|
||
if not Assigned(FOnWebMic) or (N <= 0) then Exit;
|
||
if FMicPreRollPos < MIC_PREROLL_SAMP then
|
||
begin
|
||
Want := MIC_PREROLL_SAMP - FMicPreRollPos;
|
||
if N <= Want then
|
||
begin
|
||
Move(P^, FMicPreRoll[FMicPreRollPos], N * SizeOf(Single));
|
||
Inc(FMicPreRollPos, N);
|
||
// N = Want точно заполняет pre-roll — надо слить (иначе буфер потерян)
|
||
if FMicPreRollPos = MIC_PREROLL_SAMP then
|
||
FlushMicToCallback(@FMicPreRoll[0], MIC_PREROLL_SAMP);
|
||
end
|
||
else
|
||
begin
|
||
// Pre-roll заполнен: сливаем накопленный буфер + остаток пакета
|
||
Move(P^, FMicPreRoll[FMicPreRollPos], Want * SizeOf(Single));
|
||
FMicPreRollPos := MIC_PREROLL_SAMP;
|
||
FlushMicToCallback(@FMicPreRoll[0], MIC_PREROLL_SAMP);
|
||
Remainder := N - Want;
|
||
P2 := P;
|
||
Inc(P2, Want);
|
||
FlushMicToCallback(P2, Remainder);
|
||
end;
|
||
end
|
||
else
|
||
FlushMicToCallback(P, N);
|
||
end;
|
||
|
||
procedure TWebServer.UnloadOpus;
|
||
begin
|
||
if FOpusReady and Assigned(FOpusDestroy) and (FOpusEnc <> nil) then
|
||
FOpusDestroy(FOpusEnc);
|
||
FOpusEnc := nil;
|
||
if Assigned(FOpusDec) and Assigned(FOpusDecDestroy) then
|
||
FOpusDecDestroy(FOpusDec);
|
||
FOpusDec := nil;
|
||
FOpusReady := False;
|
||
if FOpusLib <> 0 then
|
||
begin
|
||
FreeLibrary(FOpusLib);
|
||
FOpusLib := 0;
|
||
end;
|
||
end;
|
||
|
||
{ ═══════════════════════════════════════════════════════════════════════════
|
||
Start / Stop
|
||
═══════════════════════════════════════════════════════════════════════════ }
|
||
|
||
function ParseIPv4(const S: string): LongWord;
|
||
// Парсит dotted-decimal '1.2.3.4', возвращает сетевой порядок байт.
|
||
// '0.0.0.0' и '' → INADDR_ANY (0).
|
||
var
|
||
P, Start: PChar;
|
||
Parts: array[0..3] of Byte;
|
||
Idx, V: Integer;
|
||
begin
|
||
Result := 0;
|
||
if (S = '') or (S = '0.0.0.0') then Exit;
|
||
Idx := 0;
|
||
P := PChar(S);
|
||
Start := P;
|
||
while True do
|
||
begin
|
||
if (P^ = '.') or (P^ = #0) then
|
||
begin
|
||
if Idx > 3 then Exit;
|
||
V := StrToIntDef(Copy(S, Start - PChar(S) + 1, P - Start), -1);
|
||
if (V < 0) or (V > 255) then Exit;
|
||
Parts[Idx] := Byte(V);
|
||
Inc(Idx);
|
||
if P^ = #0 then Break;
|
||
Inc(P);
|
||
Start := P;
|
||
end else
|
||
Inc(P);
|
||
end;
|
||
if Idx <> 4 then Exit;
|
||
// Сетевой порядок: старший байт первый
|
||
Result := (LongWord(Parts[0]) shl 24) or (LongWord(Parts[1]) shl 16)
|
||
or (LongWord(Parts[2]) shl 8) or LongWord(Parts[3]);
|
||
Result := htonl(Result);
|
||
end;
|
||
|
||
function TWebServer.InitListen: Boolean;
|
||
var
|
||
Addr: {$IFDEF WINDOWS}TSockAddrIn{$ELSE}TInetSockAddr{$ENDIF};
|
||
One: Integer;
|
||
begin
|
||
Result := False;
|
||
{$IFDEF WINDOWS}
|
||
FListenSock := socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||
{$ELSE}
|
||
FListenSock := fpSocket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||
{$ENDIF}
|
||
if FListenSock = SOCK_INVALID then Exit;
|
||
|
||
One := 1;
|
||
{$IFDEF WINDOWS}
|
||
setsockopt(FListenSock, SOL_SOCKET, SO_REUSEADDR, @One, SizeOf(One));
|
||
FillChar(Addr, SizeOf(Addr), 0);
|
||
Addr.sin_family := AF_INET;
|
||
Addr.sin_port := htons(FPort);
|
||
Addr.sin_addr.S_addr := ParseIPv4(FBindIP);
|
||
if bind(FListenSock, @Addr, SizeOf(Addr)) = SOCKET_ERROR then Exit;
|
||
if listen(FListenSock, 5) = SOCKET_ERROR then Exit;
|
||
{$ELSE}
|
||
fpSetSockOpt(FListenSock, SOL_SOCKET, SO_REUSEADDR, @One, SizeOf(One));
|
||
FillChar(Addr, SizeOf(Addr), 0);
|
||
Addr.sin_family := AF_INET;
|
||
Addr.sin_port := htons(FPort);
|
||
Addr.sin_addr.s_addr := ParseIPv4(FBindIP);
|
||
if fpBind(FListenSock, @Addr, SizeOf(Addr)) <> 0 then Exit;
|
||
if fpListen(FListenSock, 5) <> 0 then Exit;
|
||
{$ENDIF}
|
||
Result := True;
|
||
end;
|
||
|
||
function TWebServer.Start: Boolean;
|
||
begin
|
||
Result := False;
|
||
if FRunning then Exit;
|
||
if not LoadOpus then ; // Opus опционален — продолжаем без него
|
||
if not InitListen then Exit;
|
||
FRunning := True;
|
||
FAcceptThread := TAcceptThread.Create(Self);
|
||
TAcceptThread(FAcceptThread).Start;
|
||
FPushThread := TPushThread.Create(Self);
|
||
TPushThread(FPushThread).Start;
|
||
Result := True;
|
||
end;
|
||
|
||
procedure TWebServer.Stop;
|
||
var i: Integer;
|
||
begin
|
||
if not FRunning then Exit;
|
||
FRunning := False;
|
||
|
||
// ── Шаг 1: shutdown + close listen-сокета ────────────────────────────────
|
||
// SockShutdown ОБЯЗАТЕЛЕН перед SockClose на Linux: закрытие дескриптора
|
||
// не прерывает fpAccept в AcceptThread — только shutdown разблокирует его.
|
||
// На Windows это тоже корректно (SD_BOTH).
|
||
if FListenSock <> SOCK_INVALID then
|
||
begin
|
||
SockShutdown(FListenSock);
|
||
SockClose(FListenSock);
|
||
FListenSock := SOCK_INVALID;
|
||
end;
|
||
|
||
// ── Шаг 2: shutdown всех клиентских сокетов ──────────────────────────────
|
||
// Разблокирует все HandleClient, заблокированные в Client.Recv (fpRecv).
|
||
// FreeOnTerminate=True у TClientThread — они освободятся сами после выхода.
|
||
FClientLock.Enter;
|
||
try
|
||
for i := 0 to FClientCount - 1 do
|
||
if FClients[i] <> nil then
|
||
begin
|
||
FClients[i].State := wsClosed;
|
||
SockShutdown(FClients[i].Socket); // ← разблокирует fpRecv в клиентском потоке
|
||
end;
|
||
finally
|
||
FClientLock.Leave;
|
||
end;
|
||
|
||
// ── Шаг 3: ждём завершения фоновых потоков ───────────────────────────────
|
||
// После shutdown потоки получат ошибку из recv/accept и выйдут сами.
|
||
if FAcceptThread <> nil then begin FAcceptThread.WaitFor; FreeAndNil(FAcceptThread); end;
|
||
if FPushThread <> nil then begin FPushThread.WaitFor; FreeAndNil(FPushThread); end;
|
||
|
||
// ── Шаг 4: освобождаем клиентов ──────────────────────────────────────────
|
||
FClientLock.Enter;
|
||
try
|
||
for i := 0 to FClientCount - 1 do FreeAndNil(FClients[i]);
|
||
FClientCount := 0;
|
||
finally
|
||
FClientLock.Leave;
|
||
end;
|
||
|
||
UnloadOpus;
|
||
end;
|
||
|
||
procedure TWebServer.Reconfigure(const Username, Password, BindIP: string; Port: Word);
|
||
begin
|
||
Stop;
|
||
FAuthToken := Base64EncodeStr(Username + ':' + Password);
|
||
FPort := Port;
|
||
FBindIP := BindIP;
|
||
end;
|
||
|
||
{ ═══════════════════════════════════════════════════════════════════════════
|
||
Accept loop
|
||
═══════════════════════════════════════════════════════════════════════════ }
|
||
|
||
procedure TWebServer.AcceptLoop;
|
||
var
|
||
CSock: TSocket;
|
||
Addr: {$IFDEF WINDOWS}TSockAddrIn{$ELSE}TInetSockAddr{$ENDIF};
|
||
ALen: {$IFDEF WINDOWS}Integer{$ELSE}TSockLen{$ENDIF};
|
||
Client: TWsClient;
|
||
T: TClientThread;
|
||
begin
|
||
while FRunning do
|
||
begin
|
||
ALen := SizeOf(Addr);
|
||
{$IFDEF WINDOWS}
|
||
CSock := accept(FListenSock, @Addr, @ALen);
|
||
{$ELSE}
|
||
CSock := fpAccept(FListenSock, @Addr, @ALen);
|
||
{$ENDIF}
|
||
if CSock = SOCK_INVALID then
|
||
begin
|
||
if FRunning then Sleep(10);
|
||
Continue;
|
||
end;
|
||
if FClientCount >= MAX_WS_CLIENTS then
|
||
begin
|
||
SockClose(CSock);
|
||
Continue;
|
||
end;
|
||
// 1 секунда на отправку: если TCP-буфер клиента переполнен, SockSend
|
||
// вернёт ошибку вместо того чтобы висеть и держать FClientLock вечно.
|
||
SockSetSndTimeout(CSock, 1000);
|
||
Client := TWsClient.Create(CSock);
|
||
FClientLock.Enter;
|
||
try
|
||
FClients[FClientCount] := Client;
|
||
Inc(FClientCount);
|
||
finally
|
||
FClientLock.Leave;
|
||
end;
|
||
T := TClientThread.Create(Self, Client);
|
||
T.Start;
|
||
end;
|
||
end;
|
||
|
||
{ ═══════════════════════════════════════════════════════════════════════════
|
||
HTTP / WebSocket обработчик клиента
|
||
═══════════════════════════════════════════════════════════════════════════ }
|
||
|
||
function TWebServer.CheckAuth(const Header: string): Boolean;
|
||
var
|
||
Pos_: Integer;
|
||
Token, HeaderLC: string;
|
||
begin
|
||
Result := False;
|
||
HeaderLC := LowerCase(Header);
|
||
Pos_ := System.Pos('authorization: basic ', HeaderLC);
|
||
if Pos_ = 0 then Exit;
|
||
Token := Copy(Header, Pos_ + 21, 200);
|
||
Pos_ := System.Pos(#13, Token); if Pos_ > 0 then Token := Copy(Token, 1, Pos_ - 1);
|
||
Pos_ := System.Pos(#10, Token); if Pos_ > 0 then Token := Copy(Token, 1, Pos_ - 1);
|
||
Token := Trim(Token);
|
||
Result := (Token = FAuthToken);
|
||
end;
|
||
|
||
procedure TWebServer.SendHttp(Client: TWsClient; Code: Integer;
|
||
const ContentType, Body: string);
|
||
var
|
||
StatusText, Response: string;
|
||
begin
|
||
case Code of
|
||
200: StatusText := 'OK';
|
||
401: StatusText := 'Unauthorized';
|
||
404: StatusText := 'Not Found';
|
||
else StatusText := 'Error';
|
||
end;
|
||
Response := Format('HTTP/1.1 %d %s'#13#10 +
|
||
'Content-Type: %s'#13#10 +
|
||
'Content-Length: %d'#13#10 +
|
||
'Connection: close'#13#10 +
|
||
#13#10 + '%s', [Code, StatusText, ContentType, Length(Body), Body]);
|
||
Client.SendRaw(Response[1], Length(Response));
|
||
end;
|
||
|
||
procedure TWebServer.HandleClient(Client: TWsClient);
|
||
var
|
||
R, HeaderEnd: Integer;
|
||
Header, HeaderLC, Key, Path, AcceptKey: string;
|
||
Response: string;
|
||
WsHandled: Boolean;
|
||
IsWsRequest: Boolean;
|
||
// WS frame parsing
|
||
B0, B1: Byte;
|
||
Masked: Boolean;
|
||
PayLen: Integer;
|
||
Mask: array[0..3] of Byte;
|
||
Payload: array of Byte;
|
||
Opcode: Byte;
|
||
i, Need: Integer;
|
||
j: Integer;
|
||
P1, P2: Integer;
|
||
KPos, KEnd: Integer;
|
||
Consumed: Integer;
|
||
Raw: array[0..8191] of Byte;
|
||
RawLen: Integer;
|
||
PcmBuf: array[0..5759] of Single; // 120ms max @ 48kHz (для RX TX-mic)
|
||
Decoded: Integer;
|
||
NowTick: QWord;
|
||
MissedFrames: Integer;
|
||
PlcDecoded: Integer;
|
||
k: Integer;
|
||
begin
|
||
WsHandled := False;
|
||
RawLen := 0;
|
||
|
||
// ── Фаза 1: чтение HTTP-запроса ──────────────────────────────────────────
|
||
Header := '';
|
||
repeat
|
||
R := SockRecv(Client.Socket, @Raw[RawLen], SizeOf(Raw) - RawLen, 0);
|
||
if R <= 0 then begin Client.State := wsClosed; Break; end;
|
||
Inc(RawLen, R);
|
||
SetLength(Header, RawLen);
|
||
Move(Raw[0], Header[1], RawLen);
|
||
HeaderEnd := System.Pos(#13#10#13#10, Header);
|
||
until (HeaderEnd > 0) or (RawLen >= SizeOf(Raw));
|
||
|
||
if (Client.State = wsClosed) or (HeaderEnd = 0) then
|
||
begin
|
||
RemoveClient(Client); Exit;
|
||
end;
|
||
|
||
Header := Copy(Header, 1, HeaderEnd + 3);
|
||
HeaderLC := LowerCase(Header);
|
||
|
||
// Извлечь путь
|
||
Path := '';
|
||
if System.Pos('GET /', Header) > 0 then
|
||
begin
|
||
P1 := System.Pos('GET ', Header) + 4;
|
||
P2 := System.Pos(' HTTP', Header);
|
||
if P2 > P1 then Path := Copy(Header, P1, P2 - P1);
|
||
end;
|
||
|
||
IsWsRequest := (Path = '/ws') and (System.Pos('upgrade: websocket', HeaderLC) > 0);
|
||
|
||
// Basic Auth (только для HTTP-страниц; WS handshake без авторизации)
|
||
if (not IsWsRequest) and (not CheckAuth(Header)) then
|
||
begin
|
||
Response := 'HTTP/1.1 401 Unauthorized'#13#10 +
|
||
'WWW-Authenticate: Basic realm="HPSDR"'#13#10 +
|
||
'Content-Length: 0'#13#10 +
|
||
'Connection: close'#13#10#13#10;
|
||
Client.SendRaw(Response[1], Length(Response));
|
||
RemoveClient(Client); Exit;
|
||
end;
|
||
|
||
// WebSocket upgrade
|
||
if System.Pos('upgrade: websocket', HeaderLC) > 0 then
|
||
begin
|
||
KPos := System.Pos('sec-websocket-key: ', HeaderLC);
|
||
if KPos > 0 then
|
||
begin
|
||
Key := Copy(Header, KPos + 19, 100);
|
||
KEnd := System.Pos(#13, Key);
|
||
if KEnd > 0 then Key := Copy(Key, 1, KEnd - 1);
|
||
Key := Trim(Key);
|
||
end;
|
||
AcceptKey := Base64EncodeBytes(SHA1(Key + WS_GUID), 20);
|
||
Response := 'HTTP/1.1 101 Switching Protocols'#13#10 +
|
||
'Upgrade: websocket'#13#10 +
|
||
'Connection: Upgrade'#13#10 +
|
||
'Sec-WebSocket-Accept: ' + AcceptKey + #13#10#13#10;
|
||
Client.SendRaw(Response[1], Length(Response));
|
||
Client.State := wsOpen;
|
||
|
||
FStateLock.Enter;
|
||
SetClientActive(True);
|
||
FStateLock.Leave;
|
||
|
||
Client.SendText(BuildStateJson);
|
||
WsHandled := True;
|
||
end
|
||
else if Path = '/' then
|
||
begin
|
||
SendHttp(Client, 200, 'text/html; charset=utf-8', GetIndexHtml);
|
||
RemoveClient(Client); Exit;
|
||
end
|
||
else
|
||
begin
|
||
SendHttp(Client, 404, 'text/plain', 'Not Found');
|
||
RemoveClient(Client); Exit;
|
||
end;
|
||
|
||
if not WsHandled then begin RemoveClient(Client); Exit; end;
|
||
|
||
// ── Фаза 2: цикл WebSocket-сообщений ─────────────────────────────────────
|
||
Client.BufLen := 0;
|
||
while FRunning and (Client.State = wsOpen) do
|
||
begin
|
||
R := Client.Recv;
|
||
if R <= 0 then Break;
|
||
|
||
while Client.BufLen >= 2 do
|
||
begin
|
||
B0 := Client.BufData[0];
|
||
B1 := Client.BufData[1];
|
||
Opcode := B0 and $0F;
|
||
Masked := (B1 and $80) <> 0;
|
||
PayLen := B1 and $7F;
|
||
|
||
Need := 2;
|
||
if PayLen = 126 then Inc(Need, 2)
|
||
else if PayLen = 127 then Inc(Need, 8);
|
||
if Masked then Inc(Need, 4);
|
||
|
||
if Client.BufLen < Need then Break;
|
||
|
||
i := 2;
|
||
if PayLen = 126 then
|
||
begin
|
||
PayLen := (Client.BufData[2] shl 8) or Client.BufData[3];
|
||
Inc(i, 2);
|
||
end
|
||
else if PayLen = 127 then
|
||
begin
|
||
PayLen := (Client.BufData[6] shl 24) or (Client.BufData[7] shl 16) or
|
||
(Client.BufData[8] shl 8) or Client.BufData[9];
|
||
Inc(i, 8);
|
||
end;
|
||
|
||
if Client.BufLen < Need + PayLen then Break;
|
||
|
||
if Masked then
|
||
begin
|
||
Mask[0] := Client.BufData[i]; Mask[1] := Client.BufData[i+1];
|
||
Mask[2] := Client.BufData[i+2]; Mask[3] := Client.BufData[i+3];
|
||
Inc(i, 4);
|
||
end;
|
||
|
||
SetLength(Payload, PayLen);
|
||
if PayLen > 0 then
|
||
begin
|
||
Move(Client.BufData[i], Payload[0], PayLen);
|
||
if Masked then
|
||
for j := 0 to PayLen - 1 do
|
||
Payload[j] := Payload[j] xor Mask[j and 3];
|
||
end;
|
||
|
||
Consumed := i + PayLen;
|
||
if Client.BufLen > Consumed then
|
||
Move(Client.BufData[Consumed], Client.BufData[0], Client.BufLen - Consumed);
|
||
Client.BufLen := Client.BufLen - Consumed;
|
||
|
||
case Opcode of
|
||
$01: // Text → команда
|
||
begin
|
||
SetLength(Header, PayLen);
|
||
if PayLen > 0 then Move(Payload[0], Header[1], PayLen);
|
||
ProcessCommand(Client, Header);
|
||
end;
|
||
$02: // Binary — TX mic Opus frame: byte 'M' + raw Opus packet
|
||
begin
|
||
if (PayLen > 1) and (Payload[0] = Ord('M')) and
|
||
Assigned(FOpusDec) and Assigned(FOpusDecDecode) and
|
||
Assigned(FOnWebMic) then
|
||
begin
|
||
NowTick := GetTickCount64;
|
||
if (FMicLastTick = 0) or ((NowTick - FMicLastTick) > MIC_IDLE_MS) then
|
||
begin
|
||
// Новая TX-сессия (первый пакет или длинный простой):
|
||
// сбрасываем pre-roll и staging, PLC не применяем —
|
||
// нет состояния для предсказания.
|
||
FMicPreRollPos := 0;
|
||
FMicStageLen := 0;
|
||
end
|
||
else
|
||
begin
|
||
// PLC для опоздавших/потерянных кадров по wall-clock.
|
||
// expected_packets ≈ elapsed_ms / 20ms; out of them один — текущий,
|
||
// остальные считаем "потерянными" и синтезируем Opus PLC.
|
||
MissedFrames := Integer((NowTick - FMicLastTick) div OPUS_FRAME_MS);
|
||
if MissedFrames > 0 then Dec(MissedFrames);
|
||
if MissedFrames > MIC_MAX_PLC then MissedFrames := MIC_MAX_PLC;
|
||
for k := 1 to MissedFrames do
|
||
begin
|
||
// opus_decode_float(dec, NULL, 0, pcm, frame_size, 0) → PLC frame
|
||
PlcDecoded := FOpusDecDecode(FOpusDec, nil, 0,
|
||
@PcmBuf[0], OPUS_FRAME_SAMP, 0);
|
||
if PlcDecoded > 0 then PushMicSamples(@PcmBuf[0], PlcDecoded);
|
||
end;
|
||
end;
|
||
FMicLastTick := NowTick;
|
||
Decoded := FOpusDecDecode(FOpusDec, @Payload[1], PayLen - 1,
|
||
@PcmBuf[0], Length(PcmBuf), 0);
|
||
if Decoded > 0 then
|
||
PushMicSamples(@PcmBuf[0], Decoded);
|
||
end;
|
||
end;
|
||
$08: // Close
|
||
begin
|
||
Client.State := wsClosed;
|
||
Break;
|
||
end;
|
||
$09: // Ping → Pong
|
||
Client.SendWsFrame($0A, Payload[0], PayLen);
|
||
end;
|
||
end;
|
||
end;
|
||
|
||
FStateLock.Enter;
|
||
SetClientActive(FClientCount > 1);
|
||
FStateLock.Leave;
|
||
|
||
RemoveClient(Client);
|
||
end;
|
||
|
||
procedure TWebServer.SetClientActive(Value: Boolean);
|
||
begin
|
||
if FWebClientActive = Value then Exit;
|
||
FWebClientActive := Value;
|
||
if Assigned(FOnClientActiveChanged) then FOnClientActiveChanged(Value);
|
||
end;
|
||
|
||
procedure TWebServer.RemoveClient(Client: TWsClient);
|
||
var i, j: Integer; NewActive: Boolean;
|
||
begin
|
||
FClientLock.Enter;
|
||
try
|
||
for i := 0 to FClientCount - 1 do
|
||
if FClients[i] = Client then
|
||
begin
|
||
FClients[i].Free;
|
||
for j := i to FClientCount - 2 do FClients[j] := FClients[j+1];
|
||
FClients[FClientCount-1] := nil;
|
||
Dec(FClientCount);
|
||
Break;
|
||
end;
|
||
NewActive := False;
|
||
for i := 0 to FClientCount - 1 do
|
||
if (FClients[i] <> nil) and (FClients[i].State = wsOpen) then
|
||
begin NewActive := True; Break; end;
|
||
SetClientActive(NewActive);
|
||
finally
|
||
FClientLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
{ ═══════════════════════════════════════════════════════════════════════════
|
||
Обработка JSON-команд от браузера
|
||
═══════════════════════════════════════════════════════════════════════════ }
|
||
|
||
procedure TWebServer.ProcessCommand(Client: TWsClient; const Json: string);
|
||
var
|
||
Cmd: string;
|
||
HzF: Double;
|
||
HzI, ModeValue, BW, DB, Idx, V: Integer;
|
||
On_: Boolean;
|
||
S1, S2: string;
|
||
begin
|
||
Cmd := JsonGetStr(Json, 'cmd');
|
||
|
||
if Cmd = 'freq' then
|
||
begin
|
||
HzF := JsonGetFloat(Json, 'hz', FFreq);
|
||
FStateLock.Enter; FFreq := HzF; FStateLock.Leave;
|
||
if Assigned(FOnFreq) then FOnFreq(HzF);
|
||
end
|
||
else if Cmd = 'mode' then
|
||
begin
|
||
ModeValue := JsonGetInt(Json, 'mode', FMode);
|
||
FStateLock.Enter; FMode := ModeValue; FStateLock.Leave;
|
||
if Assigned(FOnMode) then FOnMode(ModeValue);
|
||
end
|
||
else if Cmd = 'filter' then
|
||
begin
|
||
BW := JsonGetInt(Json, 'bw', FFilterBW);
|
||
FStateLock.Enter; FFilterBW := BW; FStateLock.Leave;
|
||
if Assigned(FOnFilter) then FOnFilter(BW);
|
||
end
|
||
else if Cmd = 'agc' then
|
||
begin
|
||
ModeValue := JsonGetInt(Json, 'mode', FAGCMode);
|
||
FStateLock.Enter; FAGCMode := ModeValue; FStateLock.Leave;
|
||
if Assigned(FOnAGC) then FOnAGC(ModeValue);
|
||
end
|
||
else if Cmd = 'agctop' then
|
||
begin
|
||
DB := JsonGetInt(Json, 'db', FAGCTop);
|
||
FStateLock.Enter; FAGCTop := DB; FStateLock.Leave;
|
||
if Assigned(FOnAGCTop) then FOnAGCTop(DB);
|
||
end
|
||
else if Cmd = 'band' then
|
||
begin
|
||
Idx := JsonGetInt(Json, 'idx', FBandIdx);
|
||
FStateLock.Enter; FBandIdx := Idx; FStateLock.Leave;
|
||
if Assigned(FOnBand) then FOnBand(Idx);
|
||
end
|
||
else if Cmd = 'xvtr_band' then
|
||
begin
|
||
// Idx 0..CFG_XVTR_COUNT-1 — активировать XVTR; -1 — выйти в HF
|
||
Idx := JsonGetInt(Json, 'idx', -1);
|
||
if Assigned(FOnXvtrBand) then FOnXvtrBand(Idx);
|
||
end
|
||
else if Cmd = 'span' then
|
||
begin
|
||
HzI := JsonGetInt(Json, 'hz', Round(FSpanHz));
|
||
FStateLock.Enter; FSpanHz := HzI; FStateLock.Leave;
|
||
if Assigned(FOnSpan) then FOnSpan(HzI);
|
||
end
|
||
else if Cmd = 'volume' then
|
||
begin
|
||
V := JsonGetInt(Json, 'v', FVolume);
|
||
FStateLock.Enter; FVolume := V; FStateLock.Leave;
|
||
if Assigned(FOnVolume) then FOnVolume(V);
|
||
end
|
||
else if Cmd = 'wfagc' then
|
||
begin
|
||
On_ := JsonGetBool(Json, 'on', FWfAGC);
|
||
FStateLock.Enter; FWfAGC := On_; FStateLock.Leave;
|
||
if Assigned(FOnWfAGC) then FOnWfAGC(On_);
|
||
end
|
||
else if Cmd = 'wfnf' then
|
||
begin
|
||
On_ := JsonGetBool(Json, 'on', FWfNF);
|
||
FStateLock.Enter; FWfNF := On_; FStateLock.Leave;
|
||
if Assigned(FOnWfNF) then FOnWfNF(On_);
|
||
end
|
||
else if Cmd = 'set_run' then
|
||
begin
|
||
On_ := JsonGetBool(Json, 'on', FTrxRunning);
|
||
FStateLock.Enter; FTrxRunning := On_; FStateLock.Leave;
|
||
if Assigned(FOnRun) then FOnRun(On_);
|
||
end
|
||
else if Cmd = 'set_mute' then
|
||
begin
|
||
On_ := JsonGetBool(Json, 'on', FMuted);
|
||
FStateLock.Enter; FMuted := On_; FStateLock.Leave;
|
||
if Assigned(FOnMute) then FOnMute(On_);
|
||
end
|
||
else if Cmd = 'set_ctun' then
|
||
begin
|
||
On_ := JsonGetBool(Json, 'on', FCtun);
|
||
FStateLock.Enter; FCtun := On_; FStateLock.Leave;
|
||
if Assigned(FOnCtun) then FOnCtun(On_);
|
||
end
|
||
else if Cmd = 'set_nr' then
|
||
begin
|
||
On_ := JsonGetBool(Json, 'on', FNRMode <> 0);
|
||
ModeValue := Ord(On_);
|
||
FStateLock.Enter; FNRMode := ModeValue; FStateLock.Leave;
|
||
if Assigned(FOnNR) then FOnNR(ModeValue);
|
||
end
|
||
else if Cmd = 'set_nr_mode' then
|
||
begin
|
||
ModeValue := JsonGetInt(Json, 'mode', FNRMode);
|
||
if ModeValue < 0 then ModeValue := 0;
|
||
if ModeValue > 4 then ModeValue := 4;
|
||
FStateLock.Enter; FNRMode := ModeValue; FStateLock.Leave;
|
||
if Assigned(FOnNR) then FOnNR(ModeValue);
|
||
end
|
||
else if Cmd = 'set_nb' then
|
||
begin
|
||
On_ := JsonGetBool(Json, 'on', FNBMode <> 0);
|
||
ModeValue := Ord(On_);
|
||
FStateLock.Enter; FNBMode := ModeValue; FStateLock.Leave;
|
||
if Assigned(FOnNB) then FOnNB(ModeValue);
|
||
end
|
||
else if Cmd = 'set_nb_mode' then
|
||
begin
|
||
ModeValue := JsonGetInt(Json, 'mode', FNBMode);
|
||
if ModeValue < 0 then ModeValue := 0;
|
||
if ModeValue > 2 then ModeValue := 2;
|
||
FStateLock.Enter; FNBMode := ModeValue; FStateLock.Leave;
|
||
if Assigned(FOnNB) then FOnNB(ModeValue);
|
||
end
|
||
else if Cmd = 'set_snb' then
|
||
begin
|
||
On_ := JsonGetBool(Json, 'on', FSNB);
|
||
FStateLock.Enter; FSNB := On_; FStateLock.Leave;
|
||
if Assigned(FOnSNB) then FOnSNB(On_);
|
||
end
|
||
else if Cmd = 'set_anf' then
|
||
begin
|
||
On_ := JsonGetBool(Json, 'on', FANF);
|
||
FStateLock.Enter; FANF := On_; FStateLock.Leave;
|
||
if Assigned(FOnANF) then FOnANF(On_);
|
||
end
|
||
else if Cmd = 'freq_b' then
|
||
begin
|
||
HzF := JsonGetFloat(Json, 'hz', FVfoB);
|
||
FStateLock.Enter; FVfoB := HzF; FStateLock.Leave;
|
||
if Assigned(FOnFreqB) then FOnFreqB(HzF);
|
||
end
|
||
else if Cmd = 'set_active_vfo' then
|
||
begin
|
||
ModeValue := JsonGetInt(Json, 'idx', FActiveVfo);
|
||
if ModeValue < 0 then ModeValue := 0;
|
||
if ModeValue > 1 then ModeValue := 1;
|
||
FStateLock.Enter; FActiveVfo := ModeValue; FStateLock.Leave;
|
||
if Assigned(FOnActiveVfo) then FOnActiveVfo(ModeValue);
|
||
end
|
||
else if Cmd = 'set_center' then
|
||
begin
|
||
HzF := JsonGetFloat(Json, 'hz', FCenterHz);
|
||
FStateLock.Enter; FCenterHz := HzF; FStateLock.Leave;
|
||
if Assigned(FOnCenter) then FOnCenter(HzF);
|
||
end
|
||
else if Cmd = 'set_mox' then
|
||
begin
|
||
On_ := JsonGetBool(Json, 'on', FTransmitting);
|
||
FStateLock.Enter; FTransmitting := On_; FStateLock.Leave;
|
||
if Assigned(FOnMOX) then FOnMOX(On_);
|
||
end
|
||
else if Cmd = 'drive' then
|
||
begin
|
||
V := JsonGetInt(Json, 'v', FDriveLevel);
|
||
if V < 0 then V := 0;
|
||
if V > 100 then V := 100;
|
||
FStateLock.Enter; FDriveLevel := V; FStateLock.Leave;
|
||
if Assigned(FOnDrive) then FOnDrive(V);
|
||
end
|
||
else if Cmd = 'set_tun' then
|
||
begin
|
||
On_ := JsonGetBool(Json, 'on', FTuning);
|
||
FStateLock.Enter; FTuning := On_; FStateLock.Leave;
|
||
if Assigned(FOnTun) then FOnTun(On_);
|
||
end
|
||
else if Cmd = 'set_beacon' then
|
||
begin
|
||
On_ := JsonGetBool(Json, 'on', FBeaconOn);
|
||
FStateLock.Enter; FBeaconOn := On_; FStateLock.Leave;
|
||
if Assigned(FOnBeacon) then FOnBeacon(On_);
|
||
end
|
||
else if Cmd = 'beacon_seed' then
|
||
begin
|
||
// frac = доля позиции клика по ширине спектра (0..1). Абсолютную display-частоту
|
||
// считает контроллер из ЖИВЫХ FCenterFreq/FSpanHz (как десктоп PixelToFreq) —
|
||
// клиентский localCenter может отставать при ретюне LO во время лока.
|
||
HzF := JsonGetFloat(Json, 'frac', -1);
|
||
if (HzF >= 0) and Assigned(FOnBeaconSeed) then FOnBeaconSeed(HzF);
|
||
end
|
||
else if Cmd = 'set_rx_gain_mode' then
|
||
begin
|
||
Idx := JsonGetInt(Json, 'mode', FRxGainMode); // 0=man,1=fast,2=slow,3=hybrid
|
||
FStateLock.Enter; FRxGainMode := Idx; FStateLock.Leave;
|
||
if Assigned(FOnRxGainMode) then FOnRxGainMode(Idx);
|
||
end
|
||
else if Cmd = 'set_rx_gain' then
|
||
begin
|
||
DB := JsonGetInt(Json, 'db', FRxGainDb); // ручной gain 0..73 дБ
|
||
FStateLock.Enter; FRxGainDb := DB; FStateLock.Leave;
|
||
if Assigned(FOnRxGain) then FOnRxGain(DB);
|
||
end
|
||
else if Cmd = 'set_rx_mute' then
|
||
begin
|
||
On_ := JsonGetBool(Json, 'on', FRxMuteOn);
|
||
FStateLock.Enter; FRxMuteOn := On_; FStateLock.Leave;
|
||
if Assigned(FOnRxMute) then FOnRxMute(On_);
|
||
end
|
||
else if Cmd = 'freq_a' then
|
||
begin
|
||
HzF := JsonGetFloat(Json, 'hz', FFreq);
|
||
FStateLock.Enter; FFreq := HzF; FStateLock.Leave;
|
||
if Assigned(FOnFreqA) then FOnFreqA(HzF);
|
||
end
|
||
else if Cmd = 'attn' then
|
||
begin
|
||
Idx := JsonGetInt(Json, 'db', FAttnDB);
|
||
if Idx < 0 then Idx := 0;
|
||
if Idx > 31 then Idx := 31;
|
||
FStateLock.Enter; FAttnDB := Idx; FStateLock.Leave;
|
||
if Assigned(FOnAttn) then FOnAttn(Idx);
|
||
end
|
||
else if Cmd = 'fm_step' then
|
||
begin
|
||
Idx := JsonGetInt(Json, 'idx', FFMStepIdx);
|
||
if Idx < 0 then Idx := 0;
|
||
if Idx > 3 then Idx := 3;
|
||
FStateLock.Enter; FFMStepIdx := Idx; FStateLock.Leave;
|
||
if Assigned(FOnFMStep) then FOnFMStep(Idx);
|
||
end
|
||
// ── Discovery / connect / saved-CRUD ──────────────────────────────────────
|
||
else if Cmd = 'discover' then
|
||
begin
|
||
if Assigned(FOnDiscoverDev) then FOnDiscoverDev;
|
||
end
|
||
else if Cmd = 'connect' then
|
||
begin
|
||
S1 := JsonGetStr(Json, 'ip');
|
||
if (S1 <> '') and Assigned(FOnConnect) then FOnConnect(S1);
|
||
end
|
||
else if Cmd = 'disconnect' then
|
||
begin
|
||
if Assigned(FOnDisconnectDev) then FOnDisconnectDev;
|
||
end
|
||
else if Cmd = 'dev_add' then
|
||
begin
|
||
S1 := JsonGetStr(Json, 'name');
|
||
S2 := JsonGetStr(Json, 'ip');
|
||
if (S2 <> '') and Assigned(FOnDevAdd) then FOnDevAdd(S1, S2);
|
||
end
|
||
else if Cmd = 'dev_remove' then
|
||
begin
|
||
Idx := JsonGetInt(Json, 'idx', -1);
|
||
if (Idx >= 0) and Assigned(FOnDevRemove) then FOnDevRemove(Idx);
|
||
end
|
||
else if Cmd = 'dev_autostart' then
|
||
begin
|
||
Idx := JsonGetInt(Json, 'idx', -1);
|
||
if (Idx >= 0) and Assigned(FOnDevAutoStart) then FOnDevAutoStart(Idx);
|
||
end;
|
||
end;
|
||
|
||
{ ═══════════════════════════════════════════════════════════════════════════
|
||
Push-цикл — рассылает спектр / водопад / состояние всем клиентам
|
||
═══════════════════════════════════════════════════════════════════════════ }
|
||
|
||
procedure TWebServer.PushLoop;
|
||
var
|
||
Tick, StateLastTick: QWord;
|
||
SpecBuf: array[0..4096] of Byte;
|
||
WfBuf: array[0..4096] of Byte;
|
||
i: Integer;
|
||
HasClients: Boolean;
|
||
begin
|
||
StateLastTick := GetTickCount64;
|
||
while FRunning do
|
||
begin
|
||
Sleep(50); // 20 fps
|
||
Tick := GetTickCount64;
|
||
|
||
FClientLock.Enter;
|
||
HasClients := FClientCount > 0;
|
||
FClientLock.Leave;
|
||
|
||
if not HasClients then Continue;
|
||
|
||
FStateLock.Enter;
|
||
try
|
||
SpecBuf[0] := WS_MSG_SPECTRUM;
|
||
for i := 0 to 1023 do
|
||
PSingle(Pointer(PByte(@SpecBuf[1]) + i*4))^ := FSpectrumBuf[i];
|
||
|
||
WfBuf[0] := WS_MSG_WATERFALL;
|
||
for i := 0 to 1023 do
|
||
PSingle(Pointer(PByte(@WfBuf[1]) + i*4))^ := FWfBuf[i];
|
||
finally
|
||
FStateLock.Leave;
|
||
end;
|
||
|
||
BroadcastBinary(SpecBuf[0], 1 + 1024*4);
|
||
BroadcastBinary(WfBuf[0], 1 + 1024*4);
|
||
|
||
if Tick - StateLastTick >= 200 then
|
||
begin
|
||
StateLastTick := Tick;
|
||
BroadcastText(BuildStateJson);
|
||
end;
|
||
end;
|
||
end;
|
||
|
||
procedure TWebServer.BroadcastBinary(const Data; Len: Integer);
|
||
var i: Integer;
|
||
begin
|
||
FClientLock.Enter;
|
||
try
|
||
for i := 0 to FClientCount - 1 do
|
||
if (FClients[i] <> nil) and (FClients[i].State = wsOpen) then
|
||
FClients[i].SendBinary(Data, Len);
|
||
finally
|
||
FClientLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
procedure TWebServer.BroadcastText(const S: string);
|
||
var i: Integer;
|
||
begin
|
||
FClientLock.Enter;
|
||
try
|
||
for i := 0 to FClientCount - 1 do
|
||
if (FClients[i] <> nil) and (FClients[i].State = wsOpen) then
|
||
FClients[i].SendText(S);
|
||
finally
|
||
FClientLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
{ ═══════════════════════════════════════════════════════════════════════════
|
||
Формирование JSON-состояния
|
||
═══════════════════════════════════════════════════════════════════════════ }
|
||
|
||
function JsonEscape(const S: string): string;
|
||
var
|
||
I: Integer;
|
||
begin
|
||
Result := '';
|
||
for I := 1 to Length(S) do
|
||
case S[I] of
|
||
'\': Result := Result + '\\';
|
||
'"': Result := Result + '\"';
|
||
#8: Result := Result + '\b';
|
||
#9: Result := Result + '\t';
|
||
#10: Result := Result + '\n';
|
||
#12: Result := Result + '\f';
|
||
#13: Result := Result + '\r';
|
||
else
|
||
Result := Result + S[I];
|
||
end;
|
||
end;
|
||
|
||
function TWebServer.BuildStateJson: string;
|
||
const
|
||
MODE_N: array[RadioModes.MODE_MIN..RadioModes.MODE_MAX] of string =
|
||
('LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR','FM RAW');
|
||
var
|
||
FS: TFormatSettings;
|
||
XvtrJson, DevJson, RateJson, BandJson: string;
|
||
i: Integer;
|
||
begin
|
||
FS := DefaultFormatSettings;
|
||
FS.DecimalSeparator := '.';
|
||
FStateLock.Enter;
|
||
try
|
||
// Сборка массива rate_presets: [48000,96000,...] (span-кнопки web).
|
||
RateJson := '[';
|
||
for i := 0 to High(FRatePresets) do
|
||
begin
|
||
if i > 0 then RateJson := RateJson + ',';
|
||
RateJson := RateJson + IntToStr(FRatePresets[i]);
|
||
end;
|
||
RateJson := RateJson + ']';
|
||
// Сборка массива bands: [{"name":"6m","freq":50150000},...] (band-selector web).
|
||
BandJson := '[';
|
||
for i := 0 to High(FBandNames) do
|
||
begin
|
||
if i > 0 then BandJson := BandJson + ',';
|
||
BandJson := BandJson +
|
||
Format('{"name":"%s","freq":%.0f}',
|
||
[JsonEscape(FBandNames[i]), FBandFreqs[i]], FS);
|
||
end;
|
||
BandJson := BandJson + ']';
|
||
// Сборка массива xvtr_bands: [{"idx":0,"name":"2m"},...]
|
||
XvtrJson := '[';
|
||
for i := 0 to High(FXvtrBands) do
|
||
begin
|
||
if i > 0 then XvtrJson := XvtrJson + ',';
|
||
XvtrJson := XvtrJson +
|
||
Format('{"idx":%d,"name":"%s"}',
|
||
[FXvtrBands[i].Idx, FXvtrBands[i].Name], FS);
|
||
end;
|
||
XvtrJson := XvtrJson + ']';
|
||
// Сборка списка устройств (saved+discovered) для overlay-диалога.
|
||
DevJson := '[';
|
||
for i := 0 to High(FDevices) do
|
||
begin
|
||
if i > 0 then DevJson := DevJson + ',';
|
||
DevJson := DevJson +
|
||
Format('{"name":"%s","ip":"%s","board":%d,"saved":%s,"autostart":%s}',
|
||
[JsonEscape(FDevices[i].Name), JsonEscape(FDevices[i].IP),
|
||
FDevices[i].BoardType,
|
||
BoolToStr(FDevices[i].IsSaved, 'true', 'false'),
|
||
BoolToStr(FDevices[i].AutoStart, 'true', 'false')], FS);
|
||
end;
|
||
DevJson := DevJson + ']';
|
||
Result := Format(
|
||
'{"vfo_a_hz":%.0f,"vfo_b_hz":%.0f,"active_vfo":%d,' +
|
||
'"mode":%d,"mode_name":"%s",' +
|
||
'"filter":%d,"filter_bw":%d,' +
|
||
'"agc_mode":%d,"agc_top":%d,' +
|
||
'"span_hz":%.0f,"center_hz":%.0f,"volume":%d,' +
|
||
'"wf_agc":%s,"wf_nf":%s,"band_idx":%d,"smeter_dbm":%.1f,' +
|
||
'"running":%s,"mute":%s,"ctun":%s,' +
|
||
'"nr_mode":%d,"nr":%s,"nb_mode":%d,"nb":%s,"snb":%s,"anf":%s,"connected":%s,' +
|
||
'"transmitting":%s,"drive":%d,"attn_db":%d,"tuning":%s,"dup":%s,' +
|
||
'"fwd_w":%.1f,"swr":%.2f,"pa_max_power":%.0f,' +
|
||
'"status_text":"%s","board_text":"%s","ip_text":"%s","supply_text":"%s",' +
|
||
'"pll_text":"%s","rx_text":"%s","tx_text":"%s","seq_text":"%s",' +
|
||
'"xvtr_current":%d,"xvtr_bands":%s,"freq_mhz_digits":%d,' +
|
||
'"fmstep_idx":%d,"devices":%s,"rate_presets":%s,"bands":%s,' +
|
||
'"freq_min":%.0f,"freq_max":%.0f,' +
|
||
'"beacon_visible":%s,"beacon_on":%s,"beacon_text":"%s",' +
|
||
'"beacon_ref_hz":%.0f,"beacon_track_hz":%.0f,' +
|
||
'"rf_agc_visible":%s,"rx_gain_mode":%d,"rx_gain_db":%d,' +
|
||
'"rx_mute_visible":%s,"rx_mute_on":%s}',
|
||
[FFreq, FVfoB, FActiveVfo,
|
||
FMode, MODE_N[EnsureRange(FMode, MODE_MIN, MODE_MAX)],
|
||
FFilterIdx, FFilterBW,
|
||
FAGCMode, FAGCTop,
|
||
FSpanHz, FCenterHz, FVolume,
|
||
BoolToStr(FWfAGC, 'true', 'false'),
|
||
BoolToStr(FWfNF, 'true', 'false'),
|
||
FBandIdx, FSMeter,
|
||
BoolToStr(FTrxRunning, 'true', 'false'),
|
||
BoolToStr(FMuted, 'true', 'false'),
|
||
BoolToStr(FCtun, 'true', 'false'),
|
||
FNRMode,
|
||
BoolToStr(FNRMode <> 0, 'true', 'false'),
|
||
FNBMode,
|
||
BoolToStr(FNBMode <> 0, 'true', 'false'),
|
||
BoolToStr(FSNB, 'true', 'false'),
|
||
BoolToStr(FANF, 'true', 'false'),
|
||
BoolToStr(FConnected, 'true', 'false'),
|
||
BoolToStr(FTransmitting, 'true', 'false'),
|
||
FDriveLevel,
|
||
FAttnDB,
|
||
BoolToStr(FTuning, 'true', 'false'),
|
||
BoolToStr(FDuplex, 'true', 'false'),
|
||
FFwdW, FSWR, FPAMaxPower,
|
||
JsonEscape(FStatusText), JsonEscape(FBoardText), JsonEscape(FIPText),
|
||
JsonEscape(FSupplyText), JsonEscape(FPLLText), JsonEscape(FRXText),
|
||
JsonEscape(FTXText), JsonEscape(FSeqText),
|
||
FCurrentXvtr, XvtrJson, FFreqMhzDigits,
|
||
FFMStepIdx, DevJson, RateJson, BandJson,
|
||
FFreqMin, FFreqMax,
|
||
BoolToStr(FBeaconVisible, 'true', 'false'),
|
||
BoolToStr(FBeaconOn, 'true', 'false'),
|
||
JsonEscape(FBeaconText), FBeaconRefHz, FBeaconTrackHz,
|
||
BoolToStr(FRfAgcVisible, 'true', 'false'), FRxGainMode, FRxGainDb,
|
||
BoolToStr(FRxMuteVisible, 'true', 'false'),
|
||
BoolToStr(FRxMuteOn, 'true', 'false')
|
||
], FS);
|
||
finally
|
||
FStateLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
{ ═══════════════════════════════════════════════════════════════════════════
|
||
Аудио push (вызывается из DSP-потока)
|
||
═══════════════════════════════════════════════════════════════════════════ }
|
||
|
||
procedure TWebServer.PushAudio(const Samples: PSingle; Count: Integer);
|
||
var
|
||
HasWs: Boolean;
|
||
i, n, Enc: Integer;
|
||
begin
|
||
if not FRunning then Exit;
|
||
FClientLock.Enter;
|
||
HasWs := FClientCount > 0;
|
||
FClientLock.Leave;
|
||
if not HasWs then Exit;
|
||
if (Samples = nil) or (Count <= 0) then Exit;
|
||
if not FOpusReady then Exit;
|
||
|
||
i := 0;
|
||
while i < Count do
|
||
begin
|
||
n := Count - i;
|
||
if n > OPUS_FRAME_SAMP - FOpusBufPos then
|
||
n := OPUS_FRAME_SAMP - FOpusBufPos;
|
||
Move(Samples[i], FOpusBuf[FOpusBufPos], n * SizeOf(Single));
|
||
Inc(FOpusBufPos, n);
|
||
Inc(i, n);
|
||
if FOpusBufPos >= OPUS_FRAME_SAMP then
|
||
begin
|
||
Enc := FOpusEncode(FOpusEnc, @FOpusBuf[0], OPUS_FRAME_SAMP,
|
||
@FOpusOut[0], SizeOf(FOpusOut));
|
||
if Enc > 0 then
|
||
begin
|
||
FWsAudioBuf[0] := WS_MSG_AUDIO;
|
||
Move(FOpusOut[0], FWsAudioBuf[1], Enc);
|
||
BroadcastBinary(FWsAudioBuf[0], 1 + Enc);
|
||
end;
|
||
FOpusBufPos := 0;
|
||
end;
|
||
end;
|
||
end;
|
||
|
||
{ ═══════════════════════════════════════════════════════════════════════════
|
||
Обновление состояния из MainForm (UI thread, из таймера спектра)
|
||
═══════════════════════════════════════════════════════════════════════════ }
|
||
|
||
procedure TWebServer.PushSpectrum(
|
||
const Buf: array of Single; Count: Integer;
|
||
const WfBuf_: array of Single;
|
||
SMeter: Double;
|
||
Freq: Double; Mode, FilterBW, AGCMode, AGCTop: Integer;
|
||
SpanHz: Double; Volume: Integer;
|
||
WfAGC, WfNF: Boolean; BandIdx: Integer;
|
||
TrxConnected: Boolean;
|
||
TrxRunning, Muted, Ctun: Boolean;
|
||
NRMode, NBMode: Integer; SNB, ANF: Boolean;
|
||
CenterHz: Double; FilterIdx: Integer;
|
||
VfoB: Double; ActiveVfo: Integer;
|
||
Transmitting: Boolean; DriveLevel: Integer;
|
||
AttnDB: Integer; Tuning: Boolean; Duplex: Boolean;
|
||
FwdW, SWRV, PAMaxPower: Double;
|
||
const StatusText, BoardText, IPText, SupplyText, PLLText,
|
||
RXText, TXText, SeqText: string);
|
||
var N, i: Integer;
|
||
begin
|
||
if not FRunning then Exit;
|
||
N := Min(Count, 1024);
|
||
FStateLock.Enter;
|
||
try
|
||
for i := 0 to N-1 do FSpectrumBuf[i] := Buf[i];
|
||
for i := 0 to Min(High(WfBuf_), 1023) do FWfBuf[i] := WfBuf_[i];
|
||
FSMeter := SMeter;
|
||
FFreq := Freq;
|
||
FMode := Mode;
|
||
FFilterBW := FilterBW;
|
||
FAGCMode := AGCMode;
|
||
FAGCTop := AGCTop;
|
||
FSpanHz := SpanHz;
|
||
FVolume := Volume;
|
||
FWfAGC := WfAGC;
|
||
FWfNF := WfNF;
|
||
FBandIdx := BandIdx;
|
||
FConnected := TrxConnected;
|
||
FTrxRunning := TrxRunning;
|
||
FMuted := Muted;
|
||
FCtun := Ctun;
|
||
FNRMode := NRMode;
|
||
FNBMode := NBMode;
|
||
FSNB := SNB;
|
||
FANF := ANF;
|
||
FCenterHz := CenterHz;
|
||
FFilterIdx := FilterIdx;
|
||
FVfoB := VfoB;
|
||
FActiveVfo := ActiveVfo;
|
||
FTransmitting := Transmitting;
|
||
FDriveLevel := DriveLevel;
|
||
FAttnDB := AttnDB;
|
||
FTuning := Tuning;
|
||
FDuplex := Duplex;
|
||
FFwdW := FwdW;
|
||
FSWR := SWRV;
|
||
FPAMaxPower := PAMaxPower;
|
||
FStatusText := StatusText;
|
||
FBoardText := BoardText;
|
||
FIPText := IPText;
|
||
FSupplyText := SupplyText;
|
||
FPLLText := PLLText;
|
||
FRXText := RXText;
|
||
FTXText := TXText;
|
||
FSeqText := SeqText;
|
||
finally
|
||
FStateLock.Leave;
|
||
end;
|
||
end;
|
||
{ ═══════════════════════════════════════════════════════════════════════════
|
||
Stub-методы (реализация встроена в HandleClient)
|
||
═══════════════════════════════════════════════════════════════════════════ }
|
||
|
||
procedure TWebServer.DoHandshake(Client: TWsClient);
|
||
begin
|
||
// Not used separately — handshake is in HandleClient
|
||
end;
|
||
|
||
procedure TWebServer.ProcessWsFrame(Client: TWsClient;
|
||
const Data: array of Byte; Len: Integer; Opcode: Byte);
|
||
begin
|
||
// Not used separately — frame processing is inlined in HandleClient
|
||
end;
|
||
|
||
end.
|