feat: QO-100 beacon bulletin text display (Phase C)

Decode and show the AO-40 frame payload as text in the beacon scope window.

- RadioController.GetBeaconFrame exposes the latest 256-byte frame.
- BeaconScopeForm: poll the frame counter; on each new frame, parse the
  payload (drop trailing 2-byte CRC, printable ASCII, 64-char lines, as in
  gr-satellites qo100.parse) and append to a scrolling read-only memo.
- Window widened/heightened; constellation size capped to leave room.

Frame format verified offline against a real on-air capture: the decoder
produces the live QO-100 AMSAT bulletin text (RS errors=0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 10:30:36 +03:00
co-authored by Claude Opus 4.8
parent afc8775ddb
commit 8e4106b822
2 changed files with 80 additions and 11 deletions
+69 -6
View File
@@ -16,18 +16,22 @@ interface
uses uses
Classes, SysUtils, Math, StrUtils, Classes, SysUtils, Math, StrUtils,
Forms, Controls, Graphics, ExtCtrls, Forms, Controls, Graphics, ExtCtrls, StdCtrls,
AppTheme, BeaconDecoder, RadioController; AppTheme, BeaconDecoder, BeaconFEC, RadioController;
type type
TBeaconScopeForm = class(TForm) TBeaconScopeForm = class(TForm)
private private
FCtl: TRadioController; FCtl: TRadioController;
FBox: TPaintBox; FBox: TPaintBox;
FMemo: TMemo; // декодированный текст бюллетеня (AO-40 кадры)
FTimer: TTimer; FTimer: TTimer;
FTheme: TAppTheme; FTheme: TAppTheme;
FLastFrames: Int64; // счётчик кадров на прошлом тике (детект нового)
procedure DoTick(Sender: TObject); procedure DoTick(Sender: TObject);
procedure DoPaint(Sender: TObject); procedure DoPaint(Sender: TObject);
procedure PollFrame;
function FrameToText(const Frame: TBeaconFrame): string;
public public
constructor CreateWith(AOwner: TComponent; ACtl: TRadioController); constructor CreateWith(AOwner: TComponent; ACtl: TRadioController);
procedure ApplyTheme(const T: TAppTheme); procedure ApplyTheme(const T: TAppTheme);
@@ -41,12 +45,25 @@ begin
FCtl := ACtl; FCtl := ACtl;
FTheme := DarkTheme; FTheme := DarkTheme;
Caption := 'QO-100 Beacon — Constellation'; Caption := 'QO-100 Beacon — Constellation + Bulletin';
BorderStyle := bsSizeable; BorderStyle := bsSizeable;
Width := 360; Width := 600;
Height := 560; Height := 680;
Position := poScreenCenter; Position := poScreenCenter;
// Текст бюллетеня (декодированные AO-40 кадры) — снизу, моноширинный, ReadOnly.
FMemo := TMemo.Create(Self);
FMemo.Parent := Self;
FMemo.Align := alBottom;
FMemo.Height := 230;
FMemo.ReadOnly := True;
FMemo.ScrollBars := ssVertical;
FMemo.WordWrap := False;
FMemo.Font.Name := 'Courier New';
FMemo.Font.Size := 9;
FMemo.Color := FTheme.BG;
FMemo.Font.Color := FTheme.Text;
FBox := TPaintBox.Create(Self); FBox := TPaintBox.Create(Self);
FBox.Parent := Self; FBox.Parent := Self;
FBox.Align := alClient; FBox.Align := alClient;
@@ -61,12 +78,57 @@ end;
procedure TBeaconScopeForm.ApplyTheme(const T: TAppTheme); procedure TBeaconScopeForm.ApplyTheme(const T: TAppTheme);
begin begin
FTheme := T; FTheme := T;
if FMemo <> nil then
begin
FMemo.Color := FTheme.BG;
FMemo.Font.Color := FTheme.Text;
end;
if FBox <> nil then FBox.Invalidate; if FBox <> nil then FBox.Invalidate;
end; end;
procedure TBeaconScopeForm.DoTick(Sender: TObject); procedure TBeaconScopeForm.DoTick(Sender: TObject);
begin begin
if (FBox <> nil) and Visible then FBox.Invalidate; 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; end;
procedure TBeaconScopeForm.DoPaint(Sender: TObject); procedure TBeaconScopeForm.DoPaint(Sender: TObject);
@@ -89,6 +151,7 @@ begin
// область констелляции — квадрат сверху, текст снизу // область констелляции — квадрат сверху, текст снизу
r := (Min(W, H - 70) - 20) div 2; r := (Min(W, H - 70) - 20) div 2;
if r < 20 then r := 20; if r < 20 then r := 20;
if r > 110 then r := 110; // кап: оставляем место метрикам над memo
cx := W div 2; cx := W div 2;
cy := 10 + r; cy := 10 + r;
+7 -1
View File
@@ -49,7 +49,7 @@ interface
uses uses
Classes, SysUtils, Math, Classes, SysUtils, Math,
HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC,
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore; Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore;
type type
@@ -503,6 +503,7 @@ type
procedure SetBeaconDecodeInvert(On_: Boolean); // зеркальный baseband вкл/выкл procedure SetBeaconDecodeInvert(On_: Boolean); // зеркальный baseband вкл/выкл
function BeaconDecodeInvert: Boolean; function BeaconDecodeInvert: Boolean;
function GetBeaconScope(out S: TBeaconScope): Boolean; function GetBeaconScope(out S: TBeaconScope): Boolean;
function GetBeaconFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean;
// Отображение / приём // Отображение / приём
procedure SetCTun(On_: Boolean); procedure SetCTun(On_: Boolean);
@@ -1473,6 +1474,11 @@ begin
else FillChar(S, SizeOf(S), 0); else FillChar(S, SizeOf(S), 0);
end; end;
function TRadioController.GetBeaconFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean;
begin
Result := Assigned(FBeaconDec) and FBeaconDec.GetLastFrame(Frame, RSErrors);
end;
procedure TRadioController.ApplyModeFilter; procedure TRadioController.ApplyModeFilter;
var var
Lo, Hi, Half: Integer; Lo, Hi, Half: Integer;