feat(sat): the uplink keeps the correction the operator makes

A transponder does not translate by exactly the published difference —
the oscillator on board is decades old on some birds and a kilohertz or
two out. So an operator who sounds right to themselves comes back off
frequency, corrects it on the transmit VFO, and the tracker put it back
one second later, every second, for the rest of the pass. Reported on an
IC-9700 against HRD, which keeps the shift the operator sets.

The tracker already worked this way for the RECEIVER: it reads the dial
back and treats a move as the operator choosing a new station. The
transmitter had no equivalent — its comment even said so, "derived from
the nominal and never argued with". Now it is read back too, and the
difference becomes a standing trim on the nominal uplink.

Applied to the nominal rather than the corrected frequency, because a
translation error is a fixed offset in the uplink band and not something
that scales with the Doppler. Read only while not transmitting: mid-over
nobody is turning the knob, and on an Icom this read switches to the SUB
band and back, which is the same path TuneSatellite already uses to
write the uplink and not something to do under a carrier.

Kept per satellite AND per transponder, because that is what it belongs
to: the error is a property of the hardware in orbit, stable from one
pass to the next. Capped at 20 kHz so a bad stored value cannot put the
station outside the passband for ever, and shown in the tune panel with
a reset — an offset taken silently from the VFO has to be visible, and
the VFO alone cannot bring it back to zero once the operator has drifted
somewhere wrong.

SatTuner gains SatTransmitHz, implemented for the native Icom satellite
mode and for the Flex uplink slice; anything else reports nothing and
the uplink is left to the arithmetic, as before.
This commit is contained in:
2026-09-10 18:53:03 +02:00
parent d0d29659cb
commit 72696a5c0c
11 changed files with 320 additions and 7 deletions
@@ -8,6 +8,7 @@ import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, RetargetSatelliteTracking,
SetSatUplinkTrim,
} from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { Button } from '@/components/ui/button';
@@ -60,6 +61,7 @@ type Track = {
on: boolean; name: string; transponder: string; mode: string;
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
az: number; el: number; visible: boolean; range_km: number; alt_km: number;
up_trim_hz: number;
radio: string; // "sat" | "downlink-only" | ""
error: string;
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean;
@@ -1102,6 +1104,30 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
<FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
</div>
{/* The uplink correction, when there is one.
It is taken from the transmit VFO without being asked for, so it
has to be visible: an offset nobody can see is a trap, and the
VFO alone cannot bring it back to zero once the operator has
drifted somewhere wrong. Hence the reset. */}
{!!tracking?.on && !!tracking.up_trim_hz && (
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-center gap-2">
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">
{t('sat.upTrim')}
</span>
<Pill tone="caution" title={t('sat.upTrimHint')}>
{tracking.up_trim_hz > 0 ? '+' : ''}{(tracking.up_trim_hz / 1000).toFixed(2)} kHz
</Pill>
<div className="flex-1" />
<button type="button"
className="text-[11px] text-muted-foreground hover:text-foreground"
title={t('sat.upTrimReset')}
onClick={() => { if (bird) void SetSatUplinkTrim(bird.name, tpIdx, 0).catch(() => {}); }}>
</button>
</div>
)}
{/* The tone, on the FM birds, with the same weight as a frequency.
It IS one, as far as the outcome goes: a repeater called without
its tone does not answer, and the operator hears an empty
+4
View File
@@ -607,6 +607,8 @@ const en: Dict = {
'sat.antenna': 'Antenna', 'sat.rotCommanded': '(commanded — this controller does not report back)',
'sat.aos': 'Rises in', 'sat.los': 'Sets in', 'sat.rise': 'Rise', 'sat.peak': 'Peak', 'sat.set': 'Set',
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Footprint',
'sat.upTrim': 'Trim', 'sat.upTrimReset': 'Back to the computed uplink',
'sat.upTrimHint': 'Your correction to the uplink, taken from the transmit VFO and kept for this transponder. Transponders do not translate by exactly the published difference.',
'sat.secWhere': 'Position', 'sat.secTune': 'Tune to', 'sat.skyWidthTip': 'Drag to resize the sky plot, double-click to reset it',
'sat.shiftHint': 'Doppler correction — the radio is on the centre frequency plus this. The corrected figure is beside Tracking.',
'sat.invertingHint': 'The passband is turned over: transmit on lower sideband to come back on upper.',
@@ -1233,6 +1235,8 @@ const fr: Dict = {
'sat.antenna': 'Antenne', 'sat.rotCommanded': '(commandé — ce contrôleur ne répond pas)',
'sat.aos': 'Lever dans', 'sat.los': 'Coucher dans', 'sat.rise': 'Lever', 'sat.peak': 'Culmination', 'sat.set': 'Coucher',
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Empreinte',
'sat.upTrim': 'Écart', 'sat.upTrimReset': 'Revenir à la montée calculée',
'sat.upTrimHint': 'Votre correction de la montée, relevée sur le VFO d’émission et conservée pour ce transpondeur. Les transpondeurs ne translatent pas exactement de l’écart publié.',
'sat.secWhere': 'Position', 'sat.secTune': 'À accorder', 'sat.skyWidthTip': 'Glisser pour redimensionner le ciel, double-clic pour réinitialiser',
'sat.shiftHint': 'Correction Doppler — la radio est sur la fréquence centrale plus cette valeur. Le chiffre corrigé est à côté de Tracking.',
'sat.invertingHint': 'La bande passante est inversée : émettre en bande latérale inférieure pour revenir en supérieure.',
+4
View File
@@ -600,6 +600,8 @@ export function GetSPEStatus():Promise<spe.Status>;
export function GetSatSettings():Promise<main.SatSettings>;
export function GetSatUplinkTrim(arg1:string,arg2:number):Promise<number>;
export function GetSatelliteBirds():Promise<Array<main.SatBird>>;
export function GetSatelliteGroundTrack(arg1:string,arg2:number):Promise<Array<sat.Position>>;
@@ -1296,6 +1298,8 @@ export function SetPSUOutput(arg1:boolean):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>;
export function SetSatUplinkTrim(arg1:string,arg2:number,arg3:number):Promise<void>;
export function SetScpClublogEnabled(arg1:boolean):Promise<void>;
export function SetScpEnabled(arg1:boolean):Promise<void>;
+8
View File
@@ -1130,6 +1130,10 @@ export function GetSatSettings() {
return window['go']['main']['App']['GetSatSettings']();
}
export function GetSatUplinkTrim(arg1, arg2) {
return window['go']['main']['App']['GetSatUplinkTrim'](arg1, arg2);
}
export function GetSatelliteBirds() {
return window['go']['main']['App']['GetSatelliteBirds']();
}
@@ -2522,6 +2526,10 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1);
}
export function SetSatUplinkTrim(arg1, arg2, arg3) {
return window['go']['main']['App']['SetSatUplinkTrim'](arg1, arg2, arg3);
}
export function SetScpClublogEnabled(arg1) {
return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
}
+2
View File
@@ -4302,6 +4302,7 @@ export namespace main {
visible: boolean;
range_km: number;
alt_km: number;
up_trim_hz: number;
radio: string;
error: string;
rot_on: boolean;
@@ -4329,6 +4330,7 @@ export namespace main {
this.visible = source["visible"];
this.range_km = source["range_km"];
this.alt_km = source["alt_km"];
this.up_trim_hz = source["up_trim_hz"];
this.radio = source["radio"];
this.error = source["error"];
this.rot_on = source["rot_on"];