fix(sat): the antenna stops flickering, and the pass is readable from the header

THE FLICKER was a real bug and not a rotator problem. The rotator is asked where
it is at most every three seconds — a controller query binds a socket and waits —
but satTrackStep built a fresh status every second and carried over only Radio
and Error. So the antenna readout was filled in on one tick in three and blank
on the other two, which on screen is a rotator that keeps disconnecting. The
status is now rebuilt FROM the previous one, so a field nobody wrote this tick
keeps the value somebody wrote last tick.

THE HEADER now carries the two frequencies and the antenna bearing, beside the
button that started tracking. During a pass an operator watches the radio and
the antenna, not a column on the far side of the window — and that column is the
first thing they hide to get the map full width, which until now took the
numbers with it. The compass spins while the antenna is still on its way: a mast
takes tens of seconds to cross a pass, and "moving" against "stuck" is the whole
reason to look at it, which a number alone cannot show.

THE PRECISION drops from one hertz to a hundred. The Doppler moves about sixty
hertz a second on 70 cm, so the last two digits changed on every tick and the
display was a blur that could not be read and did not need to be. The radio
still gets the whole figure — the correction is computed and sent to the hertz —
this is only how much of it is worth putting in front of somebody. The shift
beside it says "+9.7 kHz" rather than "+9741 Hz", which is how it is read aloud.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-09-09 23:30:57 +02:00
co-authored by Claude Opus 5
parent 5ddc5e38a9
commit b8491f3038
3 changed files with 93 additions and 16 deletions
+15 -9
View File
@@ -338,22 +338,28 @@ func (a *App) satTrackStep(t *satTracker) {
down, up := sh.DownHz, sh.UpHz down, up := sh.DownHz, sh.UpHz
t.mu.Lock() t.mu.Lock()
t.status = SatTrackStatus{ // Rebuilt from the old one, not from nothing.
On: true, Name: b.Name, Transponder: tp.Label, Mode: tp.Mode, //
NominalDown: nominal, NominalUp: nomUp, // The rotator fields are written by readRotator, which runs at most every
DownHz: down, UpHz: up, // three seconds — a controller query binds a socket and waits. Building a
Az: pos.Az, El: pos.El, Visible: visible, // fresh status here dropped them on every OTHER tick, so the antenna
Radio: t.status.Radio, Error: t.status.Error, // readout appeared for one second in three and vanished again, which reads
} // as a rotator that keeps disconnecting.
st := t.status
st.On, st.Name, st.Transponder, st.Mode = true, b.Name, tp.Label, tp.Mode
st.NominalDown, st.NominalUp = nominal, nomUp
st.DownHz, st.UpHz = down, up
st.Az, st.El, st.Visible = pos.Az, pos.El, visible
t.status = st
t.mu.Unlock() t.mu.Unlock()
t.pointRotator(pos, b.Geostationary) t.pointRotator(pos, b.Geostationary)
t.readRotator() t.readRotator()
t.mu.Lock() t.mu.Lock()
st := t.status out := t.status
t.mu.Unlock() t.mu.Unlock()
a.emitSatTrack(st) a.emitSatTrack(out)
// Only send what has actually moved. The step is the smallest change worth a // Only send what has actually moved. The step is the smallest change worth a
// command: on SSB a listener hears twenty hertz, on an FM channel nothing // command: on SSB a listener hears twenty hertz, on an FM channel nothing
+14
View File
@@ -1,4 +1,18 @@
[ [
{
"version": "0.27.22",
"date": "",
"en": [
"The antenna readout no longer flickers in and out during a pass. The rotator is asked where it is every three seconds, but the tracking status was rebuilt from scratch every second and dropped the answer in between — so the antenna appeared for one second in three, which reads as a rotator that keeps disconnecting.",
"While tracking, the two frequencies and the antenna bearing sit beside the Tracking button. During a pass an operator watches the radio and the antenna, not a column on the far side of the window — and that column is the first thing hidden to get the map full width. The compass spins while the antenna is still slewing: a mast takes tens of seconds to cross a pass, and the difference between \"on its way\" and \"stuck\" is the whole reason to look at it.",
"Satellite frequencies are shown to a hundred hertz instead of one. The Doppler moves about sixty hertz a second on 70 cm, so the last two digits changed every tick and the display was a blur of numbers nobody could read and nobody needed. The radio still gets the whole figure — this is only how much of it is worth putting in front of you. The shift beside it now reads \"+9.7 kHz\" rather than \"+9741 Hz\"."
],
"fr": [
"Laffichage de lantenne ne clignote plus pendant un passage. Le rotor est interrogé toutes les trois secondes, mais l’état du suivi était reconstruit de zéro chaque seconde et perdait la réponse entre-temps — lantenne apparaissait donc une seconde sur trois, ce qui se lit comme un rotor qui se déconnecte sans arrêt.",
"Pendant le suivi, les deux fréquences et le cap de lantenne sont affichés à côté du bouton Tracking. Pendant un passage, on regarde la radio et lantenne, pas une colonne à lautre bout de la fenêtre — et cest la première chose quon masque pour avoir la carte en pleine largeur. La boussole tourne tant que lantenne est en mouvement : un pylône met des dizaines de secondes à traverser un passage, et distinguer « en route » de « bloqué » est toute la raison de la regarder.",
"Les fréquences satellite sont affichées à la centaine de hertz au lieu du hertz. Le Doppler se déplace denviron soixante hertz par seconde en 70 cm : les deux derniers chiffres changeaient à chaque tick et laffichage était une bouillie de chiffres illisible et inutile. La radio reçoit toujours la valeur complète — il ne sagit que de ce qui vaut la peine d’être mis sous vos yeux. Le décalage à côté indique désormais « +9,7 kHz » plutôt que « +9741 Hz »."
]
},
{ {
"version": "0.27.21", "version": "0.27.21",
"date": "", "date": "",
+64 -7
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar } from 'lucide-react'; import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar, Compass } from 'lucide-react';
import { import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning, GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack, GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
@@ -74,11 +74,28 @@ const SIDE_SHOWN_KEY = 'opslog.satSideShown';
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720; const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
const SKY_SHOWN_KEY = 'opslog.satSkyShown'; const SKY_SHOWN_KEY = 'opslog.satSkyShown';
// Four decimals — a hundred hertz, which is what a linear transponder is
// actually tuned to.
//
// It used to be six, and the last two digits changed every tick: the Doppler
// moves about sixty hertz a second on 70 cm, so the display was a blur of
// numbers nobody could read and nobody needed. The RADIO still gets the whole
// figure — the correction is computed and sent to the hertz — this is only how
// much of it is worth putting in front of an operator. The shift beside it, in
// kilohertz, is where the fine movement shows.
const fmtHz = (hz: number) => { const fmtHz = (hz: number) => {
if (!hz) return '—'; if (!hz) return '—';
// Six decimals: a linear transponder is tuned to the hundred hertz, and the return (hz / 1e6).toFixed(4).replace(/(\d)(?=(\d{3})+\.)/g, '$1 ');
// Doppler correction moves the last three digits every second. };
return (hz / 1e6).toFixed(6).replace(/(\d)(?=(\d{3})+\.)/g, '$1 ');
// The Doppler shift, as an operator would say it: hertz while it is small
// enough to say in hertz, kilohertz once it is not. "+9741 Hz" is four digits
// of precision on a number that is only ever read as "about ten kilohertz".
const fmtShift = (hz: number) => {
const sign = hz > 0 ? '+' : '';
const a = Math.abs(hz);
if (a < 1000) return `${sign}${Math.round(a)} Hz`;
return `${sign}${(a / 1000).toFixed(1)} kHz`;
}; };
const fmtDeg = (d: number) => `${d.toFixed(1)}°`; const fmtDeg = (d: number) => `${d.toFixed(1)}°`;
const fmtKm = (km: number) => `${Math.round(km).toLocaleString()} km`; const fmtKm = (km: number) => `${Math.round(km).toLocaleString()} km`;
@@ -619,6 +636,14 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
// The pass, as a countdown and a bar. Both derived here from two timestamps, // The pass, as a countdown and a bar. Both derived here from two timestamps,
// so they move every second without asking Go anything. // so they move every second without asking Go anything.
// Is the antenna still on its way? The rotator is asked where it is every
// three seconds and a mast takes tens of seconds to cross a pass, so a
// difference between where it is and where the satellite is means it is
// moving — which is exactly what a number alone cannot show, and the
// difference between "on its way" and "stuck" is the whole reason to look.
const antennaMoving = !!tracking?.rot_on && !!tracking.rot_live &&
Math.abs(((tracking.az - tracking.rot_az + 540) % 360) - 180) > 3;
const aosMs = pass?.has_pass ? Date.parse(pass.aos) : 0; const aosMs = pass?.has_pass ? Date.parse(pass.aos) : 0;
const losMs = pass?.has_pass ? Date.parse(pass.los) : 0; const losMs = pass?.has_pass ? Date.parse(pass.los) : 0;
const inPass = !!pass?.has_pass && now >= aosMs && now < losMs; const inPass = !!pass?.has_pass && now >= aosMs && now < losMs;
@@ -687,6 +712,40 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
{tracking?.on && tracking.radio === 'downlink-only' && ( {tracking?.on && tracking.radio === 'downlink-only' && (
<span className="text-[11px] text-warning">{t('sat.downlinkOnly')}</span> <span className="text-[11px] text-warning">{t('sat.downlinkOnly')}</span>
)} )}
{/* What the station is actually doing, beside the button that started
it. During a pass an operator watches the radio and the antenna, not
a column on the far side of the window — and that column is the
first thing they hide to get the map full width. */}
{tracking?.on && (
<div className="flex items-center gap-2.5 rounded-md border border-border bg-card/60 px-2 py-0.5 text-xs tabular-nums">
<span className="flex items-center gap-1" title={t('sat.down')}>
<ArrowDown className="size-3 text-muted-foreground" />
<span className="font-medium">{fmtHz(tracking.down_hz)}</span>
</span>
{!!tracking.up_hz && (
<span className="flex items-center gap-1" title={t('sat.up')}>
<ArrowUp className="size-3 text-muted-foreground" />
<span className="font-medium">{fmtHz(tracking.up_hz)}</span>
</span>
)}
{tracking.rot_on && (
<span className={cn('flex items-center gap-1 border-l border-border pl-2.5',
antennaMoving && 'text-caution')} title={t('sat.antenna')}>
{/* The needle spins while the antenna is slewing. A rotator
takes tens of seconds to cross a pass, and the difference
between "on its way" and "stuck" is the whole reason to look
at it — a number alone cannot show movement. */}
<Compass className={cn('size-3', antennaMoving ? 'animate-spin' : 'text-muted-foreground')}
style={antennaMoving ? { animationDuration: '3s' } : undefined} />
<span className="font-medium">
{Math.round(tracking.rot_az)}°
{!tracking.rot_az_only && ` / ${Math.round(tracking.rot_el)}°`}
</span>
</span>
)}
</div>
)}
<div className="flex-1" /> <div className="flex-1" />
{/* Elements are maintenance, so only their AGE is here — and only when {/* Elements are maintenance, so only their AGE is here — and only when
it has become a reason the panel might be wrong. */} it has become a reason the panel might be wrong. */}
@@ -1031,9 +1090,7 @@ function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: n
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span> <span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span> <span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span>
{!!shift && ( {!!shift && (
<span className="text-[10px] tabular-nums text-muted-foreground"> <span className="text-[10px] tabular-nums text-muted-foreground">{fmtShift(shift)}</span>
{shift > 0 ? '+' : ''}{Math.abs(Math.round(shift))} Hz
</span>
)} )}
</div> </div>
); );