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, FlatMemo; type TBeaconScopeForm = class(TForm) private FCtl: TRadioController; FHeader: TPanel; FTitle: TLabel; FSubtitle: TLabel; FBox: TPaintBox; FBulletin: TPanel; FBulletinTitle: TLabel; FBulletinHint: TLabel; FMemo: TFlatMemo; // декодированный текст бюллетеня (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 := TFlatMemo.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.ReadOnly := True; FMemo.WordWrap := False; FMemo.Font.Size := 9; FMemo.SetAppTheme(FTheme); 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 FMemo.SetAppTheme(T); 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.