Files
ewsdr/BeaconScopeForm.pas
T

472 lines
16 KiB
ObjectPascal

unit BeaconScopeForm;
{ TBeaconScopeForm — окно диагностики QO-100 beacon-декодера (Milestone 1).
Показывает BPSK-констелляцию демодулированного маяка + метрики захвата:
carrier-lock, symbol-lock, SNR, измеренную скорость, снос NCO.
Это смотровое окно фронтенда ДО подключения FEC: если констелляция сжимается
в два чётких сгустка по оси I и горят оба лока — демодуляция корректна, можно
цеплять Viterbi/RS (libfec). Открывается из RX-блока MainForm (ПКМ по BEACON).
Данные тянутся из TRadioController.GetBeaconScope по таймеру (~25 Гц). }
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Math, StrUtils,
Forms, Controls, Graphics, ExtCtrls, StdCtrls,
AppTheme, BeaconDecoder, BeaconFEC, RadioController, DpiUtils;
type
TBeaconScopeForm = class(TForm)
private
FCtl: TRadioController;
FHeader: TPanel;
FTitle: TLabel;
FSubtitle: TLabel;
FBox: TPaintBox;
FBulletin: TPanel;
FBulletinTitle: TLabel;
FBulletinHint: TLabel;
FMemo: TMemo; // декодированный текст бюллетеня (AO-40 кадры)
FTimer: TTimer;
FTheme: TAppTheme;
FLastFrames: Int64; // счётчик кадров на прошлом тике (детект нового)
procedure DoTick(Sender: TObject);
procedure DoPaint(Sender: TObject);
procedure FormResize(Sender: TObject);
procedure PollFrame;
function FrameToText(const Frame: TBeaconFrame): string;
public
constructor CreateWith(AOwner: TComponent; ACtl: TRadioController);
procedure ApplyTheme(const T: TAppTheme);
end;
implementation
constructor TBeaconScopeForm.CreateWith(AOwner: TComponent; ACtl: TRadioController);
var
WorkArea: TRect;
MaxW, MaxH: Integer;
begin
inherited CreateNew(AOwner);
FCtl := ACtl;
FTheme := DarkTheme;
// Геометрия формы задаётся ниже уже в DPI-aware координатах.
Scaled := False;
Caption := 'QO-100 Beacon';
BorderStyle := bsSizeable;
if AOwner is TCustomForm then
begin
WorkArea := TCustomForm(AOwner).BoundsRect;
WorkArea := Screen.MonitorFromRect(WorkArea).WorkareaRect;
Position := poOwnerFormCenter;
end
else
begin
WorkArea := Screen.PrimaryMonitor.WorkareaRect;
Position := poScreenCenter;
end;
MaxW := WorkArea.Right - WorkArea.Left - DpiScale(48);
MaxH := WorkArea.Bottom - WorkArea.Top - DpiScale(48);
Width := Min(DpiScale(760), MaxW);
Height := Min(DpiScale(700), MaxH);
Constraints.MinWidth := Min(DpiScale(520), Width);
Constraints.MinHeight := Min(DpiScale(560), Height);
Color := FTheme.BG;
FHeader := TPanel.Create(Self);
FHeader.Parent := Self;
FHeader.Align := alTop;
FHeader.Height := DpiScale(72);
FHeader.BevelOuter := bvNone;
FHeader.Color := FTheme.Panel;
FTitle := TLabel.Create(Self);
FTitle.Parent := FHeader;
FTitle.Caption := 'QO-100 BEACON DECODER';
FTitle.AutoSize := False;
FTitle.SetBounds(DpiScale(20), DpiScale(12),
DpiScale(520), DpiScale(26));
FTitle.Font.Size := 15;
FTitle.Font.Style := [fsBold];
FTitle.Font.Color := FTheme.Text;
FSubtitle := TLabel.Create(Self);
FSubtitle.Parent := FHeader;
FSubtitle.Caption :=
'BPSK constellation, carrier recovery and AO-40 telemetry';
FSubtitle.AutoSize := False;
FSubtitle.SetBounds(DpiScale(20), DpiScale(40),
DpiScale(620), DpiScale(20));
FSubtitle.Font.Size := 9;
FSubtitle.Font.Color := FTheme.TextDim;
FBulletin := TPanel.Create(Self);
FBulletin.Parent := Self;
FBulletin.Align := alBottom;
FBulletin.Height := DpiScale(236);
FBulletin.BevelOuter := bvNone;
FBulletin.Color := FTheme.Panel;
FBulletinTitle := TLabel.Create(Self);
FBulletinTitle.Parent := FBulletin;
FBulletinTitle.Caption := 'DECODED BULLETIN';
FBulletinTitle.AutoSize := False;
FBulletinTitle.SetBounds(DpiScale(16), DpiScale(9),
DpiScale(220), DpiScale(20));
FBulletinTitle.Font.Size := 9;
FBulletinTitle.Font.Style := [fsBold];
FBulletinTitle.Font.Color := FTheme.MeterOn;
FBulletinHint := TLabel.Create(Self);
FBulletinHint.Parent := FBulletin;
FBulletinHint.Caption := 'latest AO-40 frames · read-only';
FBulletinHint.AutoSize := False;
FBulletinHint.SetBounds(DpiScale(250), DpiScale(9),
DpiScale(300), DpiScale(20));
FBulletinHint.Anchors := [akTop, akRight];
FBulletinHint.Left := FBulletin.ClientWidth - DpiScale(316);
FBulletinHint.Alignment := taRightJustify;
FBulletinHint.Font.Size := 8;
FBulletinHint.Font.Color := FTheme.TextDim;
// Бюллетень остаётся обычным read-only memo: текст можно выделить и скопировать.
FMemo := TMemo.Create(Self);
FMemo.Parent := FBulletin;
FMemo.Align := alClient;
FMemo.BorderSpacing.Left := DpiScale(12);
FMemo.BorderSpacing.Top := DpiScale(36);
FMemo.BorderSpacing.Right := DpiScale(12);
FMemo.BorderSpacing.Bottom := DpiScale(12);
FMemo.BorderStyle := bsNone;
FMemo.ReadOnly := True;
FMemo.ScrollBars := ssVertical;
FMemo.WordWrap := False;
FMemo.Font.Size := 9;
FMemo.Color := FTheme.BG;
FMemo.Font.Color := FTheme.Text;
FBox := TPaintBox.Create(Self);
FBox.Parent := Self;
FBox.Align := alClient;
FBox.OnPaint := @DoPaint;
OnResize := @FormResize;
FormResize(nil);
FTimer := TTimer.Create(Self);
FTimer.Interval := 40; // ~25 Гц
FTimer.OnTimer := @DoTick;
FTimer.Enabled := True;
end;
procedure TBeaconScopeForm.ApplyTheme(const T: TAppTheme);
begin
FTheme := T;
Color := T.BG;
if FHeader <> nil then FHeader.Color := T.Panel;
if FTitle <> nil then FTitle.Font.Color := T.Text;
if FSubtitle <> nil then FSubtitle.Font.Color := T.TextDim;
if FBulletin <> nil then FBulletin.Color := T.Panel;
if FBulletinTitle <> nil then FBulletinTitle.Font.Color := T.MeterOn;
if FBulletinHint <> nil then FBulletinHint.Font.Color := T.TextDim;
if FMemo <> nil then
begin
FMemo.Color := T.BG;
FMemo.Font.Color := T.Text;
end;
if FBox <> nil then FBox.Invalidate;
end;
procedure TBeaconScopeForm.FormResize(Sender: TObject);
var
BulletinH, MaxBulletinH: Integer;
begin
if (FHeader <> nil) and (FBulletin <> nil) then
begin
MaxBulletinH := ClientHeight - FHeader.Height - DpiScale(270);
BulletinH := Min(DpiScale(236),
Max(DpiScale(150), MaxBulletinH));
if FBulletin.Height <> BulletinH then
FBulletin.Height := BulletinH;
end;
if FTitle <> nil then
FTitle.Width := Max(0, FHeader.ClientWidth - DpiScale(40));
if FSubtitle <> nil then
FSubtitle.Width := Max(0, FHeader.ClientWidth - DpiScale(40));
if FBulletinHint <> nil then
begin
FBulletinHint.Left := DpiScale(250);
FBulletinHint.Width := Max(0,
FBulletin.ClientWidth - DpiScale(266));
end;
if FBox <> nil then FBox.Invalidate;
end;
procedure TBeaconScopeForm.DoTick(Sender: TObject);
begin
if not Visible then Exit;
PollFrame;
if FBox <> nil then FBox.Invalidate;
end;
function TBeaconScopeForm.FrameToText(const Frame: TBeaconFrame): string;
// AO-40 кадр QO-100 = ASCII-текст (space-padded), последние 2 байта = CRC-16.
// Как gr-satellites qo100.parse: отбрасываем CRC, печатные ASCII, строки по 64.
var
i, b: Integer;
line: string;
begin
Result := '';
i := 0;
while i <= 253 do
begin
line := '';
for b := i to Min(i + 63, 253) do
if (Frame[b] >= 32) and (Frame[b] < 127) then line := line + Chr(Frame[b])
else line := line + ' ';
Result := Result + TrimRight(line) + LineEnding;
Inc(i, 64);
end;
end;
procedure TBeaconScopeForm.PollFrame;
// Раз/тик: если декодер выдал НОВЫЙ кадр (S.Frames вырос) — печатаем бюллетень.
var
S: TBeaconScope;
Frame: TBeaconFrame;
rs: Integer;
begin
if FCtl = nil then Exit;
if not FCtl.GetBeaconScope(S) then Exit;
if S.Frames <= FLastFrames then Exit;
FLastFrames := S.Frames;
if not FCtl.GetBeaconFrame(Frame, rs) then Exit;
FMemo.Append(Format('--- frame %d (RS err %d) ---', [S.Frames, rs]));
FMemo.Append(FrameToText(Frame));
while FMemo.Lines.Count > 400 do FMemo.Lines.Delete(0); // ограничиваем рост
FMemo.SelStart := Length(FMemo.Text); // автоскролл вниз
end;
procedure TBeaconScopeForm.DoPaint(Sender: TObject);
var
C: TCanvas;
W, H, M, Gap, CardGap, GraphSize, cx, cy, r, i, px, py: Integer;
GraphR, MetricsR, PlotR: TRect;
S: TBeaconScope;
ok, Wide: Boolean;
CarrierText, SymbolText, SNRText, BaudText: string;
OffsetText, ResidText, FramesText, FECText: string;
TechText: string;
CarrierColor, SymbolColor: TColor;
procedure SetUIFont(ASize: Integer; AStyle: TFontStyles = []);
begin
C.Font.Assign(Self.Font);
C.Font.Size := ASize;
C.Font.Style := AStyle;
end;
procedure DrawCard(const R: TRect; const ATitle: string);
begin
C.Brush.Style := bsSolid;
C.Brush.Color := FTheme.Panel;
C.Pen.Style := psSolid;
C.Pen.Width := 1;
C.Pen.Color := FTheme.Border;
C.RoundRect(R.Left, R.Top, R.Right, R.Bottom,
DpiScale(8), DpiScale(8));
SetUIFont(8, [fsBold]);
C.Font.Color := FTheme.TextDim;
C.Brush.Style := bsClear;
C.TextOut(R.Left + DpiScale(14), R.Top + DpiScale(10), ATitle);
end;
procedure DrawMetricTile(const R: TRect; const ALabel, AValue: string;
AValueColor: TColor);
begin
C.Brush.Style := bsSolid;
C.Brush.Color := FTheme.BG;
C.Pen.Style := psSolid;
C.Pen.Color := FTheme.Border;
C.RoundRect(R.Left, R.Top, R.Right, R.Bottom,
DpiScale(5), DpiScale(5));
C.Brush.Style := bsClear;
SetUIFont(8);
C.Font.Color := FTheme.TextDim;
C.TextOut(R.Left + DpiScale(9), R.Top + DpiScale(6), ALabel);
SetUIFont(9, [fsBold]);
C.Font.Color := AValueColor;
C.TextOut(R.Left + DpiScale(9), R.Top + DpiScale(21), AValue);
end;
procedure DrawMetrics;
const
LABELS: array[0..7] of string =
('CARRIER', 'SYMBOL', 'SNR', 'SYMBOL RATE',
'TUNE OFFSET', 'RESIDUAL', 'FRAMES', 'FEC');
var
Values: array[0..7] of string;
Colors: array[0..7] of TColor;
Cols, Rows, Col, Row, TileW, TileH, X, Y, n: Integer;
R: TRect;
begin
Values[0] := CarrierText; Values[1] := SymbolText;
Values[2] := SNRText; Values[3] := BaudText;
Values[4] := OffsetText; Values[5] := ResidText;
Values[6] := FramesText; Values[7] := FECText;
for n := 0 to 7 do Colors[n] := FTheme.Text;
Colors[0] := CarrierColor;
Colors[1] := SymbolColor;
if ok and S.HasFrame then Colors[6] := FTheme.MeterOn;
if ok and S.HasFrame then Colors[7] := FTheme.MeterOn;
if Wide then begin Cols := 2; Rows := 4; end
else begin Cols := 4; Rows := 2; end;
CardGap := DpiScale(8);
TileW := (MetricsR.Right - MetricsR.Left - DpiScale(28)
- (Cols - 1) * CardGap) div Cols;
TileH := (MetricsR.Bottom - MetricsR.Top - DpiScale(70)
- (Rows - 1) * CardGap) div Rows;
for n := 0 to 7 do
begin
Col := n mod Cols;
Row := n div Cols;
X := MetricsR.Left + DpiScale(14) + Col * (TileW + CardGap);
Y := MetricsR.Top + DpiScale(34) + Row * (TileH + CardGap);
R := Rect(X, Y, X + TileW, Y + TileH);
DrawMetricTile(R, LABELS[n], Values[n], Colors[n]);
end;
SetUIFont(7);
C.Font.Color := FTheme.TextDim;
C.Brush.Style := bsClear;
C.TextOut(MetricsR.Left + DpiScale(14),
MetricsR.Bottom - DpiScale(20), TechText);
end;
begin
C := FBox.Canvas;
W := FBox.Width; H := FBox.Height;
C.Brush.Style := bsSolid;
C.Brush.Color := FTheme.BG;
C.FillRect(0, 0, W, H);
if (W <= 0) or (H <= 0) then Exit;
M := DpiScale(16);
Gap := DpiScale(14);
Wide := W >= DpiScale(600);
if Wide then
begin
GraphSize := Min(H - 2 * M, (W - 3 * M) div 2);
GraphR := Rect(M, M, M + GraphSize, H - M);
MetricsR := Rect(GraphR.Right + Gap, M, W - M, H - M);
end
else
begin
GraphSize := Max(DpiScale(74), H - DpiScale(154) - 3 * M);
GraphR := Rect(M, M, W - M, M + GraphSize);
MetricsR := Rect(M, GraphR.Bottom + Gap, W - M, H - M);
end;
DrawCard(GraphR, 'CONSTELLATION');
DrawCard(MetricsR, 'DECODER STATUS');
// Квадрат констелляции центрируется внутри своей карточки.
GraphSize := Min(GraphR.Right - GraphR.Left - DpiScale(28),
GraphR.Bottom - GraphR.Top - DpiScale(48));
GraphSize := Max(DpiScale(40), GraphSize);
PlotR.Left := GraphR.Left +
(GraphR.Right - GraphR.Left - GraphSize) div 2;
PlotR.Top := GraphR.Top + DpiScale(34) +
(GraphR.Bottom - GraphR.Top - DpiScale(40) - GraphSize) div 2;
PlotR.Right := PlotR.Left + GraphSize;
PlotR.Bottom := PlotR.Top + GraphSize;
cx := (PlotR.Left + PlotR.Right) div 2;
cy := (PlotR.Top + PlotR.Bottom) div 2;
r := GraphSize div 2 - DpiScale(8);
C.Brush.Style := bsSolid;
C.Brush.Color := FTheme.BG;
C.Pen.Style := psSolid;
C.Pen.Color := FTheme.Border;
C.Rectangle(PlotR.Left, PlotR.Top, PlotR.Right, PlotR.Bottom);
C.Pen.Style := psSolid;
C.Pen.Color := FTheme.SpecGrid;
C.Line(cx - r, cy, cx + r, cy);
C.Line(cx, cy - r, cx, cy + r);
C.Brush.Style := bsClear;
C.Ellipse(cx - r, cy - r, cx + r, cy + r);
C.Ellipse(cx - r div 2, cy - r div 2, cx + r div 2, cy + r div 2);
C.Pen.Color := FTheme.TextDim;
C.Ellipse(cx + r div 2 - DpiScale(3), cy - DpiScale(3),
cx + r div 2 + DpiScale(3), cy + DpiScale(3));
C.Ellipse(cx - r div 2 - DpiScale(3), cy - DpiScale(3),
cx - r div 2 + DpiScale(3), cy + DpiScale(3));
ok := (FCtl <> nil) and FCtl.GetBeaconScope(S);
if ok and S.Enabled then
begin
C.Pen.Style := psClear;
C.Brush.Style := bsSolid;
C.Brush.Color := FTheme.MeterOn;
for i := 0 to S.Count - 1 do
begin
px := cx + Round(S.PtI[i] * (r / 2));
py := cy - Round(S.PtQ[i] * (r / 2));
if (px >= cx - r) and (px <= cx + r) and (py >= cy - r) and (py <= cy + r) then
C.FillRect(px - DpiScale(1), py - DpiScale(1),
px + DpiScale(2), py + DpiScale(2));
end;
end;
if not ok then
begin
CarrierText := 'UNAVAILABLE';
SymbolText := 'UNAVAILABLE';
SNRText := '--'; BaudText := '--';
OffsetText := '--'; ResidText := '--';
FramesText := '--'; FECText := '--';
TechText := 'Decoder data unavailable';
CarrierColor := FTheme.TextDim;
SymbolColor := FTheme.TextDim;
end;
if ok and (not S.Enabled) then
begin
CarrierText := 'OFF'; SymbolText := 'OFF';
SNRText := '--'; BaudText := '--';
OffsetText := '--'; ResidText := '--';
FramesText := IntToStr(S.Frames);
FECText := '--';
TechText := 'Enable BEACON and select the beacon on the spectrum';
CarrierColor := FTheme.TextDim;
SymbolColor := FTheme.TextDim;
end;
if ok and S.Enabled then
begin
CarrierText := IfThen(S.CarrierLock, 'LOCKED', 'SEARCH');
SymbolText := IfThen(S.SymbolLock, 'LOCKED', 'SEARCH');
if S.SNRdB > -50 then SNRText := Format('%.1f dB', [S.SNRdB])
else SNRText := '--';
BaudText := Format('%.1f Bd', [S.SymRate]);
OffsetText := Format('%.0f Hz', [S.OffsetHz]);
ResidText := Format('%.0f Hz', [S.ResidHz]);
FramesText := IntToStr(S.Frames);
FECText := Format('RS %d · P%d', [S.LastRSErr, S.ManPhase]);
TechText := Format('INV %s · PROM %.0f · Fs %.0f · Fd %.0f · SPS %.1f',
[IfThen(FCtl.BeaconDecodeInvert, 'ON', 'OFF'), S.CarProm,
S.FsHz, S.FdecHz, S.Sps]);
if S.CarrierLock then CarrierColor := FTheme.MeterOn
else CarrierColor := FTheme.TextDim;
if S.SymbolLock then SymbolColor := FTheme.MeterOn
else SymbolColor := FTheme.TextDim;
end;
DrawMetrics;
end;
end.