fix(sat,decodes): the left column holds position too, and drift stops lying

Two things.

The sky plot and the position share the left column now, and the button
that opens it says so the way the right one does — it was a radar icon,
left over from when it toggled a plot inside the readout column, and the
gesture is the same one on both sides. The plot and the numbers are the
same answer at two precisions: azimuth and elevation drawn, then written
out to the digit. Having them at opposite ends of the window meant
reading a bearing off one side and finding it on the other.

And the band-drift warning, reported by W4TE. It compared the decoder's
announced band against RigState.Band, which is the TRANSMIT band — so
with slice A on 20 m running its own WSJT-X, slice B on 40 m, and
transmit focus on B, the 20 m decoder was told the rig was on 40 m while
the slice it listens to had been on 20 m throughout. The panel's own
comment had accepted this as a line that setup could read past; it is
worse than that, because the warning names a band and asserts something
false about the radio.

RigState now carries RxBands: every band the rig has a receiver on. One
entry on a single-VFO rig, one per slice on a Flex, the transmit band
always included so it cannot come back empty while the rig is on a
frequency. The warning fires only when the decoder announces a band
NOTHING on the radio is on, which is what it was always for and what a
lost CAT link actually looks like.
This commit is contained in:
2026-09-10 15:25:54 +02:00
parent 3fe14c2c77
commit 7e6e1335e3
8 changed files with 240 additions and 94 deletions
+6 -2
View File
@@ -6,14 +6,18 @@
"Every block of the readout column now has a heading and shuts on its own: the pass, the position, what to tune, the pass list. Each one remembers whether it is open, because an operator working FM birds never looks at the linear passband and one watching a schedule never looks at the range rate. A shut block keeps the one thing worth knowing in its heading — the countdown, the elevation, the mode.", "Every block of the readout column now has a heading and shuts on its own: the pass, the position, what to tune, the pass list. Each one remembers whether it is open, because an operator working FM birds never looks at the linear passband and one watching a schedule never looks at the range rate. A shut block keeps the one thing worth knowing in its heading — the countdown, the elevation, the mode.",
"The frequency to tune to is the CENTRE of the passband, not the Doppler-corrected figure. The corrected one moves every second, differs for every operator, and is not what the frequency plan or anybody on the air calls the satellite's frequency; it belongs beside Tracking, where the radio is, and that is where it now lives. The correction stays here as the offset that explains the difference between the two.", "The frequency to tune to is the CENTRE of the passband, not the Doppler-corrected figure. The corrected one moves every second, differs for every operator, and is not what the frequency plan or anybody on the air calls the satellite's frequency; it belongs beside Tracking, where the radio is, and that is where it now lives. The correction stays here as the offset that explains the difference between the two.",
"More colour where it decides something: the band of each frequency, the Doppler offset, an inverting transponder, the passband width and the peak elevation of a pass are badges in the app's own status colours rather than rows of grey words.", "More colour where it decides something: the band of each frequency, the Doppler offset, an inverting transponder, the passband width and the peak elevation of a pass are badges in the app's own status colours rather than rows of grey words.",
"The ground track is visible on every basemap. It was drawn in a CSS variable, which is not a colour a canvas can use, so the browser kept whatever it had and the track came out a pale near-white that disappeared over the imagery and the deserts alike. It is now a proper colour, drawn over a dark casing the way a road is on a map — one line cannot hold up over both a pale sea and a dark continent, but a line with an outline can." "The ground track is visible on every basemap. It was drawn in a CSS variable, which is not a colour a canvas can use, so the browser kept whatever it had and the track came out a pale near-white that disappeared over the imagery and the deserts alike. It is now a proper colour, drawn over a dark casing the way a road is on a map — one line cannot hold up over both a pale sea and a dark continent, but a line with an outline can.",
"The sky plot and the position now share the left column, and it opens and shuts with the same kind of control as the readout column on the right — it used to be a radar icon, from when it toggled a plot inside that column. The plot and the numbers are the same answer at different precisions, azimuth and elevation drawn and then written out, and having them on opposite sides of the window meant reading a bearing off one and finding it on the other.",
"The decode panel's band-drift warning stops firing on a second slice. It compared the decoder's band against the radio's TRANSMIT band, so a station with slice A on 20 m, slice B on 40 m and transmit focus on B was told its 20 m decoder disagreed with a radio that had been on 20 m all along — on the slice that decoder listens to. The comparison is now against every band the radio has a receiver on, which is what the warning was always about: a decoder announcing a band nothing on the radio is on, the way a lost CAT link looks."
], ],
"fr": [ "fr": [
"Longlet Satellites est réagencé. Le tracé du ciel passe dans sa propre colonne à GAUCHE de la carte, où un graphique qui veut être carré ne dispute plus la largeur aux chiffres — il occupait la colonne de droite et repoussait le tableau des passes hors de l’écran, alors que la marge de lautre côté de la carte restait vide. Sa largeur se règle à la souris, et un double-clic sur la poignée la réinitialise.", "Longlet Satellites est réagencé. Le tracé du ciel passe dans sa propre colonne à GAUCHE de la carte, où un graphique qui veut être carré ne dispute plus la largeur aux chiffres — il occupait la colonne de droite et repoussait le tableau des passes hors de l’écran, alors que la marge de lautre côté de la carte restait vide. Sa largeur se règle à la souris, et un double-clic sur la poignée la réinitialise.",
"Chaque bloc de la colonne de droite a désormais un titre et se replie indépendamment : la passe, la position, ce quil faut accorder, la liste des passes. Chacun retient sil est ouvert, car qui travaille les satellites FM ne regarde jamais la bande passante linéaire, et qui surveille un calendrier ne regarde jamais la vitesse radiale. Un bloc replié conserve dans son titre la seule chose utile : le décompte, l’élévation, le mode.", "Chaque bloc de la colonne de droite a désormais un titre et se replie indépendamment : la passe, la position, ce quil faut accorder, la liste des passes. Chacun retient sil est ouvert, car qui travaille les satellites FM ne regarde jamais la bande passante linéaire, et qui surveille un calendrier ne regarde jamais la vitesse radiale. Un bloc replié conserve dans son titre la seule chose utile : le décompte, l’élévation, le mode.",
"La fréquence à accorder est le CENTRE de la bande passante, pas la valeur corrigée du Doppler. Celle-ci bouge chaque seconde, diffère pour chaque opérateur, et nest pas ce que le plan de fréquences ou nimporte qui sur lair appelle la fréquence du satellite ; elle a sa place à côté de Tracking, là où se trouve la radio, et cest là quelle est désormais. La correction reste ici, comme l’écart qui explique la différence entre les deux.", "La fréquence à accorder est le CENTRE de la bande passante, pas la valeur corrigée du Doppler. Celle-ci bouge chaque seconde, diffère pour chaque opérateur, et nest pas ce que le plan de fréquences ou nimporte qui sur lair appelle la fréquence du satellite ; elle a sa place à côté de Tracking, là où se trouve la radio, et cest là quelle est désormais. La correction reste ici, comme l’écart qui explique la différence entre les deux.",
"Plus de couleur là où elle décide de quelque chose : la bande de chaque fréquence, l’écart Doppler, un transpondeur inverseur, la largeur de bande passante et le pic d’élévation dune passe sont des pastilles aux couleurs d’état de lapplication, plutôt que des rangées de mots gris.", "Plus de couleur là où elle décide de quelque chose : la bande de chaque fréquence, l’écart Doppler, un transpondeur inverseur, la largeur de bande passante et le pic d’élévation dune passe sont des pastilles aux couleurs d’état de lapplication, plutôt que des rangées de mots gris.",
"Le tracé au sol est visible sur tous les fonds de carte. Il était dessiné avec une variable CSS, qui nest pas une couleur exploitable par un canvas : le navigateur conservait ce quil avait et le tracé sortait dans un blanc pâle qui disparaissait aussi bien sur limagerie que sur les déserts. Cest maintenant une vraie couleur, tracée sur un liseré sombre comme on dessine une route sur une carte — une seule ligne ne peut pas tenir à la fois sur une mer claire et un continent sombre, une ligne avec un contour si." "Le tracé au sol est visible sur tous les fonds de carte. Il était dessiné avec une variable CSS, qui nest pas une couleur exploitable par un canvas : le navigateur conservait ce quil avait et le tracé sortait dans un blanc pâle qui disparaissait aussi bien sur limagerie que sur les déserts. Cest maintenant une vraie couleur, tracée sur un liseré sombre comme on dessine une route sur une carte — une seule ligne ne peut pas tenir à la fois sur une mer claire et un continent sombre, une ligne avec un contour si.",
"Le tracé du ciel et la position partagent désormais la colonne de gauche, qui souvre et se ferme avec le même type de bouton que le panneau de droite — c’était une icône radar, héritée de l’époque où elle basculait un graphique à lintérieur de ce panneau. Le tracé et les chiffres sont la même réponse à deux précisions près, lazimut et l’élévation dessinés puis écrits, et les avoir aux deux extrémités de la fenêtre obligeait à lire un relèvement dun côté pour le retrouver de lautre.",
"Lavertissement de dérive de bande du panneau de décodages ne se déclenche plus sur une seconde tranche. Il comparait la bande du décodeur à la bande d’ÉMISSION de la radio : une station avec la tranche A sur 20 m, la B sur 40 m et le focus d’émission sur B se voyait annoncer que son décodeur 20 m contredisait une radio qui était sur 20 m depuis le début — sur la tranche que ce décodeur écoute. La comparaison porte désormais sur toutes les bandes où la radio a un récepteur, ce qui a toujours été le sujet de cet avertissement : un décodeur qui annonce une bande sur laquelle rien de la radio ne se trouve, comme le fait une liaison CAT perdue."
] ]
}, },
{ {
+3
View File
@@ -6644,6 +6644,9 @@ export default function App() {
// Only while CAT is actually connected: an empty band means "nothing to // Only while CAT is actually connected: an empty band means "nothing to
// compare with", never "the rig is on no band". // compare with", never "the rig is on no band".
rigBand={catState.connected ? (catState.band || '') : ''} rigBand={catState.connected ? (catState.band || '') : ''}
// Every band the radio is listening on, so a second slice on a second
// band does not read as a decoder that has lost CAT.
rigBands={catState.connected ? (catState.rx_bands ?? []) : []}
myCall={station.callsign} myCall={station.callsign}
myGrid={station.my_grid} myGrid={station.my_grid}
// A DOUBLE click answers the station: it hands the decode back to // A DOUBLE click answers the station: it hands the decode back to
+16 -3
View File
@@ -102,6 +102,9 @@ interface Props {
// The band the RIG is on, when CAT is connected. Only ever compared with what // The band the RIG is on, when CAT is connected. Only ever compared with what
// the decoder announces — see the drift warning. // the decoder announces — see the drift warning.
rigBand?: string; rigBand?: string;
// Every band the radio has a receiver on. A Flex running two slices has
// two, and a decoder on either of them is not drifting.
rigBands?: string[];
onCall: (d: Decode) => void; onCall: (d: Decode) => void;
// A single click: take the station without transmitting — fill the entry, and // A single click: take the station without transmitting — fill the entry, and
// point the panels at it. Absent, a click falls back to onCall. // point the panels at it. Absent, a click falls back to onCall.
@@ -641,7 +644,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
})); }));
} }
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) { export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, rigBands, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
const { t } = useI18n(); const { t } = useI18n();
// Column widths, dragged in the header and shared by every row. Persisted // Column widths, dragged in the header and shared by every row. Persisted
// through writeUiPref (not raw localStorage) so the layout travels with data/ // through writeUiPref (not raw localStorage) so the layout travels with data/
@@ -778,9 +781,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
// Said, not decided. Using the rig's band instead would be wrong for anyone // Said, not decided. Using the rig's band instead would be wrong for anyone
// decoding a second receiver on another band, and a warning costs that setup // decoding a second receiver on another band, and a warning costs that setup
// nothing but a line it can read past. // nothing but a line it can read past.
//
// Compared against every band the radio is RECEIVING on, not the transmit
// band. Two slices on two bands with a decoder on each is a normal setup,
// and it made this warning lie: with slice A on 20 m, slice B on 40 m and
// transmit focus on B, the 20 m decoder was told the rig was on 40 m while
// the slice it listens to was on 20 m all along. The warning is for a
// decoder announcing a band NOTHING on the radio is on, which is what a
// lost CAT link actually looks like.
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : ''; const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
const bandDrift = !!rigBand && !!decoderBand const onAir = (rigBands && rigBands.length ? rigBands : (rigBand ? [rigBand] : []))
&& rigBand.toLowerCase() !== decoderBand.toLowerCase(); .map((b) => b.toLowerCase());
const bandDrift = onAir.length > 0 && !!decoderBand
&& !onAir.includes(decoderBand.toLowerCase());
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : ''; const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode; const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
+87 -75
View File
@@ -1,7 +1,8 @@
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, Compass, import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen,
PanelLeftClose, PanelLeftOpen, Radar, Compass,
ChevronDown, Clock, Crosshair, SlidersHorizontal, ListOrdered } from 'lucide-react'; ChevronDown, Clock, Crosshair, SlidersHorizontal, ListOrdered } from 'lucide-react';
import { import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning, GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
@@ -857,13 +858,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
> >
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)} {Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select> </select>
{/* The left column, with the same kind of control as the right one.
It was a Radar icon, from when this toggled a plot inside the
readout column. It now opens and shuts a column of the window, so
it says so the way the other one does — the two are the same
gesture and an operator should not have to learn them twice. */}
<Button <Button
variant="ghost" size="sm" variant="ghost" size="sm" className="h-7 px-1.5"
className={cn('h-7 px-1.5', skyShown && 'text-success')}
onClick={() => setSkyShown((v) => !v)} onClick={() => setSkyShown((v) => !v)}
title={skyShown ? t('sat.hideSky') : t('sat.showSky')} title={skyShown ? t('sat.hideSky') : t('sat.showSky')}
> >
<Radar className="size-3.5" /> {skyShown ? <PanelLeftClose className="size-3.5" /> : <PanelLeftOpen className="size-3.5" />}
</Button> </Button>
{/* Put the whole window on the map. On a laptop the readout takes a {/* Put the whole window on the map. On a laptop the readout takes a
third of the screen, and there are moments — watching a footprint third of the screen, and there are moments — watching a footprint
@@ -880,17 +886,23 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
{err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>} {err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}
<div className="flex gap-1 flex-1 min-h-0"> <div className="flex gap-1 flex-1 min-h-0">
{/* The sky, seen from underneath it: the centre is straight up, the {/* WHERE IT IS: the sky plot and the position, in a column of their
rim is the horizon, north is at the top. One glance says whether own to the left of the map.
the pass comes over the roof or along the treeline.
To the LEFT of the map, in a column of its own. It was stacked in The plot is the sky seen from underneath — the centre is
the readout column, where a plot that wants to be square competed straight up, the rim is the horizon, north is at the top — and
for width with the numbers and pushed the pass table off the one glance says whether the pass comes over the roof or along
bottom of the screen — while the margin on this side of the map the treeline. The numbers below it are the same answer to the
sat empty the whole time. */} digit: azimuth, elevation, distance, height. They belong
together, and having them apart meant reading a bearing off one
side of the window and finding it on the other.
Both were in the readout column, where a plot that wants to be
square competed for width with everything else and pushed the
pass table off the bottom of the screen — while the margin on
this side of the map sat empty the whole time. */}
{skyShown && ( {skyShown && (
<div className="shrink-0 flex flex-col gap-1 min-h-0" style={{ width: skyW }}> <div className="shrink-0 flex flex-col gap-1 min-h-0 overflow-y-auto" style={{ width: skyW }}>
<div className="rounded-lg border border-border bg-card p-2"> <div className="rounded-lg border border-border bg-card p-2">
<SkyPlot <SkyPlot
track={sky} track={sky}
@@ -900,6 +912,68 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
visible={!!tuning?.visible} visible={!!tuning?.visible}
/> />
</div> </div>
{/* Where it is, right now. Its elevation goes in the heading: above
or below the horizon is the one thing worth knowing with the
block shut. */}
<Section
title={t('sat.secWhere')}
icon={Crosshair}
open={secOpen.where}
onToggle={() => toggleSec('where')}
right={tuning && !secOpen.where ? (
<Pill tone={tuning.visible ? 'success' : 'muted'}>
{fmtDeg(tuning.el)} {tuning.visible ? t('sat.up') : t('sat.below')}
</Pill>
) : undefined}
>
<div className="grid grid-cols-2 gap-2">
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
sub={tuning ? compass(tuning.az) : ''} />
<Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
colour={tuning?.visible ? 'var(--success)' : 'var(--muted-foreground)'}
sub={tuning ? (tuning.visible ? t('sat.up') : t('sat.below')) : ''} />
</div>
<div className="mt-2 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
<PassBit label={t('sat.range')} value={tuning?.range_km ? fmtKm(tuning.range_km) : '—'} />
<PassBit label={t('sat.altitude')} value={tuning?.alt_km ? fmtKm(tuning.alt_km) : '—'} />
<PassBit label={t('sat.footprint')} value={tuning?.footprint_km ? fmtKm(tuning.footprint_km) : '—'} />
</div>
{/* Approaching or receding, which is the sign of the whole Doppler
correction and the one number that explains why the frequencies
are moving the way they are. */}
{!!tuning && !bird?.geostationary && (
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
{tuning.range_rate < 0
? <ArrowUp className="size-3 text-success" />
: <ArrowDown className="size-3 text-warning" />}
<span>{tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')}</span>
<span>{Math.abs(tuning.range_rate).toFixed(2)} km/s</span>
</div>
)}
{/* Where the antenna is, beside where the satellite is. The two
differing is a rotator still slewing; the two differing for a
long time is a rotator that is stuck, and that is worth being
able to see without walking outside. */}
{tracking?.rot_on && (
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
{/* No elevation when none is being driven: an undriven zero
draws an antenna lying on the horizon, which is a bearing
and not the absence of one. */}
<span className="font-medium">
{tracking.rot_az_only
? fmtDeg(tracking.rot_az)
: `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`}
</span>
{tracking.rot_az_only && <span className="text-muted-foreground">{t('sat.rotAzOnly')}</span>}
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
</div>
)}
</Section>
</div> </div>
)} )}
{skyShown && ( {skyShown && (
@@ -1004,68 +1078,6 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
)} )}
</Section> </Section>
{/* Where it is, right now. Its elevation goes in the heading: above
or below the horizon is the one thing worth knowing with the
block shut. */}
<Section
title={t('sat.secWhere')}
icon={Crosshair}
open={secOpen.where}
onToggle={() => toggleSec('where')}
right={tuning && !secOpen.where ? (
<Pill tone={tuning.visible ? 'success' : 'muted'}>
{fmtDeg(tuning.el)} {tuning.visible ? t('sat.up') : t('sat.below')}
</Pill>
) : undefined}
>
<div className="grid grid-cols-2 gap-2">
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
sub={tuning ? compass(tuning.az) : ''} />
<Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
colour={tuning?.visible ? 'var(--success)' : 'var(--muted-foreground)'}
sub={tuning ? (tuning.visible ? t('sat.up') : t('sat.below')) : ''} />
</div>
<div className="mt-2 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
<PassBit label={t('sat.range')} value={tuning?.range_km ? fmtKm(tuning.range_km) : '—'} />
<PassBit label={t('sat.altitude')} value={tuning?.alt_km ? fmtKm(tuning.alt_km) : '—'} />
<PassBit label={t('sat.footprint')} value={tuning?.footprint_km ? fmtKm(tuning.footprint_km) : '—'} />
</div>
{/* Approaching or receding, which is the sign of the whole Doppler
correction and the one number that explains why the frequencies
are moving the way they are. */}
{!!tuning && !bird?.geostationary && (
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
{tuning.range_rate < 0
? <ArrowUp className="size-3 text-success" />
: <ArrowDown className="size-3 text-warning" />}
<span>{tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')}</span>
<span>{Math.abs(tuning.range_rate).toFixed(2)} km/s</span>
</div>
)}
{/* Where the antenna is, beside where the satellite is. The two
differing is a rotator still slewing; the two differing for a
long time is a rotator that is stuck, and that is worth being
able to see without walking outside. */}
{tracking?.rot_on && (
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
{/* No elevation when none is being driven: an undriven zero
draws an antenna lying on the horizon, which is a bearing
and not the absence of one. */}
<span className="font-medium">
{tracking.rot_az_only
? fmtDeg(tracking.rot_az)
: `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`}
</span>
{tracking.rot_az_only && <span className="text-muted-foreground">{t('sat.rotAzOnly')}</span>}
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
</div>
)}
</Section>
{/* What to tune. */} {/* What to tune. */}
<Section <Section
title={t('sat.secTune')} title={t('sat.secTune')}
+2 -2
View File
@@ -625,7 +625,7 @@ const en: Dict = {
'sat.sideWidthTip': 'Drag to resize the readout. Double-click to reset it.', 'sat.sideWidthTip': 'Drag to resize the readout. Double-click to reset it.',
'sat.thSat': 'Satellite', 'sat.thAos': 'Rise', 'sat.thLos': 'Set', 'sat.thMaxEl': 'Max', 'sat.thIn': 'In', 'sat.thSat': 'Satellite', 'sat.thAos': 'Rise', 'sat.thLos': 'Set', 'sat.thMaxEl': 'Max', 'sat.thIn': 'In',
'sat.skyPlot': 'The pass across the sky — centre is straight up, the rim is the horizon, north is at the top', 'sat.skyPlot': 'The pass across the sky — centre is straight up, the rim is the horizon, north is at the top',
'sat.showSky': 'Show the sky plot', 'sat.hideSky': 'Hide the sky plot', 'sat.showSky': 'Show the sky and position', 'sat.hideSky': 'Hide the sky and position',
'sec.satellite': 'Satellites', 'sec.satellite': 'Satellites',
'satset.hint': 'Where the antenna is, and the machine that points it. The satellites you follow and the frequency plan are in the Satellites tab.', 'satset.hint': 'Where the antenna is, and the machine that points it. The satellites you follow and the frequency plan are in the Satellites tab.',
'satset.gridPlaceholder': 'e.g. JN18cx', 'satset.gridPlaceholder': 'e.g. JN18cx',
@@ -1251,7 +1251,7 @@ const fr: Dict = {
'sat.sideWidthTip': 'Glisser pour redimensionner le panneau. Double-clic pour le remettre par défaut.', 'sat.sideWidthTip': 'Glisser pour redimensionner le panneau. Double-clic pour le remettre par défaut.',
'sat.thSat': 'Satellite', 'sat.thAos': 'Lever', 'sat.thLos': 'Coucher', 'sat.thMaxEl': 'Max', 'sat.thIn': 'Dans', 'sat.thSat': 'Satellite', 'sat.thAos': 'Lever', 'sat.thLos': 'Coucher', 'sat.thMaxEl': 'Max', 'sat.thIn': 'Dans',
'sat.skyPlot': 'Le passage dans le ciel — le centre est à la verticale, le bord est lhorizon, le nord en haut', 'sat.skyPlot': 'Le passage dans le ciel — le centre est à la verticale, le bord est lhorizon, le nord en haut',
'sat.showSky': 'Afficher la vue du ciel', 'sat.hideSky': 'Masquer la vue du ciel', 'sat.showSky': 'Afficher le ciel et la position', 'sat.hideSky': 'Masquer le ciel et la position',
'sec.satellite': 'Satellites', 'sec.satellite': 'Satellites',
'satset.hint': 'Où se trouve lantenne, et la machine qui la pointe. Les satellites suivis et le plan de fréquences sont dans longlet Satellites.', 'satset.hint': 'Où se trouve lantenne, et la machine qui la pointe. Les satellites suivis et le plan de fréquences sont dans longlet Satellites.',
'satset.gridPlaceholder': 'ex. JN18cx', 'satset.gridPlaceholder': 'ex. JN18cx',
+2
View File
@@ -1154,6 +1154,7 @@ export namespace cat {
split?: boolean; split?: boolean;
mode?: string; mode?: string;
band?: string; band?: string;
rx_bands?: string[];
vfo?: string; vfo?: string;
error?: string; error?: string;
// Go type: time // Go type: time
@@ -1175,6 +1176,7 @@ export namespace cat {
this.split = source["split"]; this.split = source["split"];
this.mode = source["mode"]; this.mode = source["mode"];
this.band = source["band"]; this.band = source["band"];
this.rx_bands = source["rx_bands"];
this.vfo = source["vfo"]; this.vfo = source["vfo"];
this.error = source["error"]; this.error = source["error"];
this.updated_at = this.convertValues(source["updated_at"], null); this.updated_at = this.convertValues(source["updated_at"], null);
+46
View File
@@ -60,6 +60,16 @@ type RigState struct {
Split bool `json:"split,omitempty"` // rig is in split mode Split bool `json:"split,omitempty"` // rig is in split mode
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY) Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
Band string `json:"band,omitempty"` // computed from FreqHz Band string `json:"band,omitempty"` // computed from FreqHz
// RxBands is every band the radio currently has a receiver on, Band
// included. One entry on a rig with one VFO; one per slice on a Flex.
//
// It exists because Band alone is the TRANSMIT band, and a station running
// two slices on two bands with a decoder on each is not drifting when one
// of them announces the band it is legitimately on. Reported for W4TE:
// slice A on 20 m with its own WSJT-X, slice B on 40 m holding transmit
// focus, and OpsLog telling him his 20 m decoder disagreed with a radio
// that was on 20 m — on that slice.
RxBands []string `json:"rx_bands,omitempty"`
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB" Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
Error string `json:"error,omitempty"` // last connect/poll error if any Error string `json:"error,omitempty"` // last connect/poll error if any
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
@@ -1002,11 +1012,47 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
if ns.FreqHz != 0 && ns.Band == "" { if ns.FreqHz != 0 && ns.Band == "" {
ns.Band = BandFromHz(ns.FreqHz) ns.Band = BandFromHz(ns.FreqHz)
} }
ns.RxBands = m.rxBands(ns)
m.update(ns) m.update(ns)
} }
} }
} }
// rxBands lists the bands the radio has a receiver on.
//
// Asked of the backend rather than derived from the state, because only the
// backend knows: a Flex reports its slices, and everything else has exactly
// one receiver whose band is already in the state. The transmit band is
// always included, even on a Flex, so this can never come back empty while
// the rig is on a frequency.
func (m *Manager) rxBands(st RigState) []string {
seen := map[string]bool{}
var out []string
add := func(b string) {
if b = strings.ToLower(strings.TrimSpace(b)); b != "" && !seen[b] {
seen[b] = true
out = append(out, b)
}
}
add(st.Band)
if st.RxFreqHz != 0 {
add(BandFromHz(st.RxFreqHz))
}
if fx, ok := m.FlexState(); ok {
for _, sl := range fx.Slices {
if sl.Band != "" {
add(sl.Band)
continue
}
// A slice the radio has reported a frequency but no band for.
if sl.FreqHz != 0 {
add(BandFromHz(sl.FreqHz))
}
}
}
return out
}
func (m *Manager) applyCommandDelay() { func (m *Manager) applyCommandDelay() {
m.mu.RLock() m.mu.RLock()
d := m.cmdDelay d := m.cmdDelay
+66
View File
@@ -0,0 +1,66 @@
package cat
import (
"strings"
"testing"
)
// RxBands is what stops the decode panel's band-drift warning from lying to a
// station running two slices.
//
// Reported for W4TE: slice A on 20 m with its own WSJT-X, slice B on 40 m
// holding transmit focus. RigState.Band is the TRANSMIT band, so the 20 m
// decoder was told "the rig is on 40M" while the slice it listens to had been
// on 20 m the whole time.
func TestRxBandsAlwaysCarriesTheTransmitBand(t *testing.T) {
m := &Manager{}
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20m"})
if len(got) != 1 || got[0] != "20m" {
t.Fatalf("got %v, want [20m]", got)
}
}
// A split rig receives on one band and transmits on another — cross-band split
// is unusual but legal, and the receive side is where a decoder listens.
func TestRxBandsIncludesTheSplitReceiveBand(t *testing.T) {
m := &Manager{}
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20m", RxFreqHz: 7074000, Split: true})
if !has(got, "20m") || !has(got, "40m") {
t.Fatalf("got %v, want both 20m and 40m", got)
}
}
// Nothing to report when the rig is on no frequency: an empty list means "there
// is nothing to compare with", and the panel treats it as such rather than as
// "the radio is on no band", which would warn about every decode.
func TestRxBandsIsEmptyWithNoFrequency(t *testing.T) {
m := &Manager{}
if got := m.rxBands(RigState{}); len(got) != 0 {
t.Fatalf("got %v, want nothing", got)
}
}
// Deduplicated and lower-cased, because the panel compares strings: two slices
// on the same band are one band, and "20M" from a backend must match "20m" from
// BandFromHz.
func TestRxBandsIsNormalised(t *testing.T) {
m := &Manager{}
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20M", RxFreqHz: 14080000})
if len(got) != 1 || got[0] != "20m" {
t.Fatalf("got %v, want [20m]", got)
}
for _, b := range got {
if b != strings.ToLower(b) {
t.Errorf("%q is not lower-cased", b)
}
}
}
func has(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}