feat: relay auto-control by frequency / band (PstRotator-style)

Automatically switches the Station Control relay boards from the rig's
current frequency / band. Each relay carries one rule: off (manual), a
frequency window (ON inside [lo,hi] kHz, OFF outside), or a set of bands
(ON on those bands, OFF elsewhere). Evaluated on every CAT frequency/band
change; a relay is only switched when its desired state actually changed,
so tuning within a range doesn't hammer the board.

A cached atomic flag keeps the CAT hot path a no-op when the feature is off
(important during FT8 slice churn). Saving re-applies from the live
frequency so a changed rule takes effect immediately.

New Settings → Hardware → Relay auto-control section: master enable plus a
per-relay mode (Off / Frequency / Band) with kHz range inputs or band
chips, per configured relay board. i18n EN + FR. Azimuth/Time modes (the
other two PstRotator tabs) are left for later.
This commit is contained in:
2026-07-19 01:57:56 +02:00
parent 215652570c
commit c825caa7a8
7 changed files with 363 additions and 0 deletions
+117
View File
@@ -43,6 +43,7 @@ import {
GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
GetRelayAuto, SaveRelayAuto, GetStationDevices,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -187,6 +188,7 @@ type SectionId =
| 'antgenius'
| 'pgxl'
| 'flex'
| 'relayauto'
| 'audio';
type TreeNode =
@@ -204,6 +206,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
{ kind: 'item', label: t('sec.audio'), id: 'audio' },
];
return [
@@ -248,6 +251,7 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
uscounties: 'sec.uscounties',
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
relayauto: 'sec.relayauto',
};
// Map section id → friendly name (used in breadcrumb / placeholders).
@@ -274,6 +278,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
antgenius: 'Antenna Genius',
pgxl: 'Power Genius',
flex: 'FlexRadio',
relayauto: 'Relay auto-control',
audio: 'Audio devices',
};
@@ -636,6 +641,117 @@ function ADIFMonitorPanel() {
);
}
// RelayAutoPanel configures automatic control of the Station Control relay boards
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
// off, a frequency window (kHz), or a set of bands.
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] };
type StationDevUI = { id: string; type: string; name: string; labels: string[] };
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
const relayCountUI = (type: string) => (type === 'kmtronic' ? 8 : 5);
function RelayAutoPanel() {
const { t } = useI18n();
const [enabled, setEnabled] = useState(false);
const [rules, setRules] = useState<RelayRuleUI[]>([]);
const [devices, setDevices] = useState<StationDevUI[]>([]);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
Promise.all([GetRelayAuto(), GetStationDevices()])
.then(([cfg, devs]: any[]) => {
setEnabled(!!cfg?.enabled);
setRules((cfg?.rules ?? []) as RelayRuleUI[]);
setDevices((devs ?? []) as StationDevUI[]);
})
.catch(() => {})
.finally(() => setLoaded(true));
}, []);
const save = (en: boolean, rs: RelayRuleUI[]) => { SaveRelayAuto({ enabled: en, rules: rs } as any).catch(() => {}); };
const ruleFor = (dev: string, relay: number): RelayRuleUI =>
rules.find((r) => r.device_id === dev && r.relay === relay) ?? { device_id: dev, relay, mode: 'off', freq_lo_khz: 0, freq_hi_khz: 0, bands: [] };
// Apply a patch and persist (commit=true) or keep local only (commit=false, for
// freq inputs that persist on blur so we don't switch relays on every keystroke).
const patchRule = (dev: string, relay: number, patch: Partial<RelayRuleUI>, commit = true) => {
const next = { ...ruleFor(dev, relay), ...patch };
const others = rules.filter((r) => !(r.device_id === dev && r.relay === relay));
const all = [...others, next];
setRules(all);
if (commit) save(enabled, all);
};
const toggleBand = (dev: string, relay: number, band: string) => {
const cur = ruleFor(dev, relay);
const has = cur.bands.includes(band);
patchRule(dev, relay, { bands: has ? cur.bands.filter((b) => b !== band) : [...cur.bands, band] });
};
return (
<div className="space-y-4 max-w-3xl">
<p className="text-xs text-muted-foreground">{t('relayauto.hint')}</p>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={enabled} disabled={!loaded} onCheckedChange={(c) => { const v = !!c; setEnabled(v); save(v, rules); }} />
{t('relayauto.enable')}
</label>
{devices.length === 0 && loaded && (
<p className="text-xs text-muted-foreground italic">{t('relayauto.noDevices')}</p>
)}
{devices.map((dev) => (
<div key={dev.id} className="rounded-md border border-border">
<div className="px-3 py-1.5 border-b border-border bg-muted/40 text-xs font-semibold">{dev.name || dev.id}</div>
<div className="divide-y divide-border/60">
{Array.from({ length: relayCountUI(dev.type) }, (_, i) => i + 1).map((relay) => {
const r = ruleFor(dev.id, relay);
const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
return (
<div key={relay} className="flex items-start gap-3 px-3 py-2">
<span className="w-28 shrink-0 text-xs font-mono pt-1.5 truncate" title={label}>{label}</span>
<Select value={r.mode || 'off'} onValueChange={(v) => patchRule(dev.id, relay, { mode: v })}>
<SelectTrigger className="h-8 w-32 text-xs shrink-0"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="off">{t('relayauto.modeOff')}</SelectItem>
<SelectItem value="freq">{t('relayauto.modeFreq')}</SelectItem>
<SelectItem value="band">{t('relayauto.modeBand')}</SelectItem>
</SelectContent>
</Select>
<div className="flex-1 min-w-0 pt-0.5">
{r.mode === 'freq' && (
<div className="flex items-center gap-1.5 text-xs">
<Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.from')}
defaultValue={r.freq_lo_khz || ''}
onChange={(e) => patchRule(dev.id, relay, { freq_lo_khz: parseFloat(e.target.value) || 0 }, false)}
onBlur={() => save(enabled, rules)} />
<span className="text-muted-foreground"></span>
<Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.to')}
defaultValue={r.freq_hi_khz || ''}
onChange={(e) => patchRule(dev.id, relay, { freq_hi_khz: parseFloat(e.target.value) || 0 }, false)}
onBlur={() => save(enabled, rules)} />
<span className="text-muted-foreground">kHz</span>
</div>
)}
{r.mode === 'band' && (
<div className="flex flex-wrap gap-1">
{RELAY_BANDS.map((b) => {
const on = r.bands.includes(b);
return (
<button key={b} type="button" onClick={() => toggleBand(dev.id, relay, b)}
className={cn('px-1.5 py-0.5 rounded text-[11px] font-mono border transition-colors',
on ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
{b}
</button>
);
})}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
// MainViewPanes lets the operator choose what the Main tab's left and right
// panes show, independently: the great-circle map, the locator street map, the
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
@@ -4515,6 +4631,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// — useState/useEffect/useI18n — get a proper component context; PANELS[x]()
// is a plain call and hook-holding panels must go through JSX like this.
adifmon: () => <ADIFMonitorPanel />,
relayauto: () => <RelayAutoPanel />,
backup: BackupPanel,
database: DatabasePanel,
uscounties: USCountiesPanel,
+14
View File
@@ -125,6 +125,13 @@ const en: Dict = {
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
'sec.relayauto': 'Relay auto-control',
'relayauto.hint': 'Automatically switch Station Control relays from the rig frequency / band (like PstRotator). Each relay: a frequency window (ON inside, OFF outside) or a set of bands. Relays are set up in the Station Control panel.',
'relayauto.enable': 'Enable relay auto-control',
'relayauto.noDevices': 'No relay board configured. Add one in the Station Control panel first.',
'relayauto.relay': 'Relay',
'relayauto.modeOff': 'Off (manual)', 'relayauto.modeFreq': 'Frequency', 'relayauto.modeBand': 'Band',
'relayauto.from': 'from', 'relayauto.to': 'to',
// General panel
'gen.hint': 'App behaviour (saved instantly).',
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
@@ -423,6 +430,13 @@ const fr: Dict = {
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
'sec.relayauto': 'Relais automatiques',
'relayauto.hint': "Commute automatiquement les relais du Station Control selon la fréquence / bande du poste (comme PstRotator). Par relais : une plage de fréquence (ON dedans, OFF dehors) ou un ensemble de bandes. Les relais se configurent dans le panneau Station Control.",
'relayauto.enable': 'Activer les relais automatiques',
'relayauto.noDevices': "Aucune carte relais configurée. Ajoutes-en une dans le panneau Station Control d'abord.",
'relayauto.relay': 'Relais',
'relayauto.modeOff': 'Off (manuel)', 'relayauto.modeFreq': 'Fréquence', 'relayauto.modeBand': 'Bande',
'relayauto.from': 'de', 'relayauto.to': 'à',
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
+4
View File
@@ -386,6 +386,8 @@ export function GetQSO(arg1:number):Promise<qso.QSO>;
export function GetQSORate():Promise<main.QSORate>;
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotatorSettings():Promise<main.RotatorSettings>;
@@ -740,6 +742,8 @@ export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
+8
View File
@@ -730,6 +730,10 @@ export function GetQSORate() {
return window['go']['main']['App']['GetQSORate']();
}
export function GetRelayAuto() {
return window['go']['main']['App']['GetRelayAuto']();
}
export function GetRotatorHeading() {
return window['go']['main']['App']['GetRotatorHeading']();
}
@@ -1438,6 +1442,10 @@ export function SaveQSLDefaults(arg1) {
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
}
export function SaveRelayAuto(arg1) {
return window['go']['main']['App']['SaveRelayAuto'](arg1);
}
export function SaveRotatorSettings(arg1) {
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
}
+55
View File
@@ -2392,6 +2392,61 @@ export namespace main {
this.last60 = source["last60"];
}
}
export class RelayAutoRule {
device_id: string;
relay: number;
mode: string;
freq_lo_khz: number;
freq_hi_khz: number;
bands: string[];
static createFrom(source: any = {}) {
return new RelayAutoRule(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.device_id = source["device_id"];
this.relay = source["relay"];
this.mode = source["mode"];
this.freq_lo_khz = source["freq_lo_khz"];
this.freq_hi_khz = source["freq_hi_khz"];
this.bands = source["bands"];
}
}
export class RelayAutoConfig {
enabled: boolean;
rules: RelayAutoRule[];
static createFrom(source: any = {}) {
return new RelayAutoConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.rules = this.convertValues(source["rules"], RelayAutoRule);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class RotatorHeading {
enabled: boolean;
ok: boolean;