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:
@@ -484,6 +484,9 @@ type App struct {
|
||||
pttMu sync.Mutex
|
||||
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
||||
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
|
||||
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
|
||||
relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change
|
||||
relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off
|
||||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
||||
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
|
||||
@@ -810,6 +813,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.qso = qso.NewRepo(logbookConn)
|
||||
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
||||
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
|
||||
a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag
|
||||
|
||||
// cty.dat for offline DXCC / country resolution. Cached on disk; first
|
||||
// run downloads it from country-files.com in the background so startup
|
||||
@@ -895,6 +899,13 @@ func (a *App) startup(ctx context.Context) {
|
||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||
}
|
||||
a.emitRadioUDP(s)
|
||||
// Drive station relays by the current frequency/band (PstRotator-style
|
||||
// automatic control). Cheap cached-flag check keeps this a no-op when the
|
||||
// feature is off; when on, run off this callback so a slow relay board never
|
||||
// stalls rig-state processing.
|
||||
if a.relayAutoOn.Load() {
|
||||
go a.applyRelayAuto(s.FreqHz, s.Band)
|
||||
}
|
||||
})
|
||||
a.reloadCAT()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Vendored
+4
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
// Relay auto-control: drives the Station Control relay boards automatically from
|
||||
// the rig's current frequency / band — the equivalent of PstRotator's "Automatic
|
||||
// Control". Each relay carries at most one rule:
|
||||
// - "freq": ON while the frequency is inside [lo,hi] kHz, OFF otherwise;
|
||||
// - "band": ON while the current band is one of the listed bands, OFF otherwise;
|
||||
// - "off"/empty: not managed (left to manual control).
|
||||
//
|
||||
// Evaluated on every CAT frequency/band change. A relay is only switched when its
|
||||
// desired state actually changed since the last apply, so a slow relay board isn't
|
||||
// hammered while you tune within the same range.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const keyRelayAuto = "relayauto.config"
|
||||
|
||||
// RelayAutoRule is one relay's automatic-control rule.
|
||||
type RelayAutoRule struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Relay int `json:"relay"` // 1-based
|
||||
Mode string `json:"mode"` // "off" | "freq" | "band"
|
||||
FreqLoKHz float64 `json:"freq_lo_khz"`
|
||||
FreqHiKHz float64 `json:"freq_hi_khz"`
|
||||
Bands []string `json:"bands"`
|
||||
}
|
||||
|
||||
// RelayAutoConfig is the whole auto-control setup: a master switch + the rules.
|
||||
type RelayAutoConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Rules []RelayAutoRule `json:"rules"`
|
||||
}
|
||||
|
||||
// GetRelayAuto returns the relay auto-control configuration for the settings UI.
|
||||
func (a *App) GetRelayAuto() RelayAutoConfig {
|
||||
var cfg RelayAutoConfig
|
||||
if a.settings == nil {
|
||||
return cfg
|
||||
}
|
||||
s, _ := a.settings.GetGlobal(a.ctx, keyRelayAuto)
|
||||
if strings.TrimSpace(s) != "" {
|
||||
_ = json.Unmarshal([]byte(s), &cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// SaveRelayAuto persists the configuration and applies it immediately from the
|
||||
// current rig state, so toggling a rule takes effect without waiting for the next
|
||||
// frequency change.
|
||||
func (a *App) SaveRelayAuto(cfg RelayAutoConfig) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.settings.SetGlobal(a.ctx, keyRelayAuto, string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
a.relayAutoOn.Store(cfg.Enabled) // keep the CAT hot-path flag in sync
|
||||
// Re-apply from the live frequency so a just-changed rule takes hold now. Also
|
||||
// forget the last-applied cache so a rule the user just switched to "off" and
|
||||
// back gets re-sent even if the value is unchanged.
|
||||
a.relayAutoMu.Lock()
|
||||
a.relayAutoLast = map[string]bool{}
|
||||
a.relayAutoMu.Unlock()
|
||||
if a.cat != nil {
|
||||
st := a.cat.State()
|
||||
go a.applyRelayAuto(st.FreqHz, st.Band)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func relayAutoKey(dev string, relay int) string { return dev + "|" + strconv.Itoa(relay) }
|
||||
|
||||
func bandInList(bands []string, band string) bool {
|
||||
band = strings.ToLower(strings.TrimSpace(band))
|
||||
if band == "" {
|
||||
return false
|
||||
}
|
||||
for _, b := range bands {
|
||||
if strings.ToLower(strings.TrimSpace(b)) == band {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// applyRelayAuto evaluates every rule against the current frequency/band and
|
||||
// switches only the relays whose desired state changed since the last apply.
|
||||
func (a *App) applyRelayAuto(freqHz int64, band string) {
|
||||
a.relayAutoMu.Lock()
|
||||
defer a.relayAutoMu.Unlock()
|
||||
|
||||
cfg := a.GetRelayAuto()
|
||||
if !cfg.Enabled || len(cfg.Rules) == 0 {
|
||||
return
|
||||
}
|
||||
if a.relayAutoLast == nil {
|
||||
a.relayAutoLast = map[string]bool{}
|
||||
}
|
||||
khz := float64(freqHz) / 1000.0
|
||||
|
||||
changed := false
|
||||
for _, r := range cfg.Rules {
|
||||
if r.Relay < 1 {
|
||||
continue
|
||||
}
|
||||
var want bool
|
||||
switch r.Mode {
|
||||
case "freq":
|
||||
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
|
||||
continue // unconfigured range → leave the relay alone
|
||||
}
|
||||
lo, hi := r.FreqLoKHz, r.FreqHiKHz
|
||||
if hi < lo {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
want = khz >= lo && khz <= hi
|
||||
case "band":
|
||||
if len(r.Bands) == 0 {
|
||||
continue
|
||||
}
|
||||
want = bandInList(r.Bands, band)
|
||||
default:
|
||||
continue // "off"/empty → not managed
|
||||
}
|
||||
|
||||
key := relayAutoKey(r.DeviceID, r.Relay)
|
||||
if last, ok := a.relayAutoLast[key]; ok && last == want {
|
||||
continue // no change → don't hammer the board
|
||||
}
|
||||
if err := a.StationSetRelay(r.DeviceID, r.Relay, want); err != nil {
|
||||
applog.Printf("relay auto: set %s relay %d = %v failed: %v", r.DeviceID, r.Relay, want, err)
|
||||
continue // don't cache a failed write — retry next change
|
||||
}
|
||||
a.relayAutoLast[key] = want
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed && a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "station:relay_auto", nil) // nudge the Station Control UI to re-poll
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user