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
|
pttMu sync.Mutex
|
||||||
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
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)
|
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
|
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
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)
|
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)
|
a.qso = qso.NewRepo(logbookConn)
|
||||||
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
||||||
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
|
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
|
// cty.dat for offline DXCC / country resolution. Cached on disk; first
|
||||||
// run downloads it from country-files.com in the background so startup
|
// 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)
|
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||||
}
|
}
|
||||||
a.emitRadioUDP(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()
|
a.reloadCAT()
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
GetUIPref, SetUIPref,
|
GetUIPref, SetUIPref,
|
||||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||||
|
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -187,6 +188,7 @@ type SectionId =
|
|||||||
| 'antgenius'
|
| 'antgenius'
|
||||||
| 'pgxl'
|
| 'pgxl'
|
||||||
| 'flex'
|
| 'flex'
|
||||||
|
| 'relayauto'
|
||||||
| 'audio';
|
| 'audio';
|
||||||
|
|
||||||
type TreeNode =
|
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.antgenius'), id: 'antgenius' },
|
||||||
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
||||||
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
...(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' },
|
{ kind: 'item', label: t('sec.audio'), id: 'audio' },
|
||||||
];
|
];
|
||||||
return [
|
return [
|
||||||
@@ -248,6 +251,7 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
|||||||
uscounties: 'sec.uscounties',
|
uscounties: 'sec.uscounties',
|
||||||
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
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',
|
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).
|
// Map section id → friendly name (used in breadcrumb / placeholders).
|
||||||
@@ -274,6 +278,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
antgenius: 'Antenna Genius',
|
antgenius: 'Antenna Genius',
|
||||||
pgxl: 'Power Genius',
|
pgxl: 'Power Genius',
|
||||||
flex: 'FlexRadio',
|
flex: 'FlexRadio',
|
||||||
|
relayauto: 'Relay auto-control',
|
||||||
audio: 'Audio devices',
|
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
|
// 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
|
// panes show, independently: the great-circle map, the locator street map, the
|
||||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
// 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]()
|
// — useState/useEffect/useI18n — get a proper component context; PANELS[x]()
|
||||||
// is a plain call and hook-holding panels must go through JSX like this.
|
// is a plain call and hook-holding panels must go through JSX like this.
|
||||||
adifmon: () => <ADIFMonitorPanel />,
|
adifmon: () => <ADIFMonitorPanel />,
|
||||||
|
relayauto: () => <RelayAutoPanel />,
|
||||||
backup: BackupPanel,
|
backup: BackupPanel,
|
||||||
database: DatabasePanel,
|
database: DatabasePanel,
|
||||||
uscounties: USCountiesPanel,
|
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',
|
'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.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.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
|
// General panel
|
||||||
'gen.hint': 'App behaviour (saved instantly).',
|
'gen.hint': 'App behaviour (saved instantly).',
|
||||||
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
|
'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',
|
'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.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.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.hint': 'Comportement de l\'application (enregistré immédiatement).',
|
||||||
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
||||||
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
'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 GetQSORate():Promise<main.QSORate>;
|
||||||
|
|
||||||
|
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||||
|
|
||||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||||
|
|
||||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
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 SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||||
|
|||||||
@@ -730,6 +730,10 @@ export function GetQSORate() {
|
|||||||
return window['go']['main']['App']['GetQSORate']();
|
return window['go']['main']['App']['GetQSORate']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetRelayAuto() {
|
||||||
|
return window['go']['main']['App']['GetRelayAuto']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetRotatorHeading() {
|
export function GetRotatorHeading() {
|
||||||
return window['go']['main']['App']['GetRotatorHeading']();
|
return window['go']['main']['App']['GetRotatorHeading']();
|
||||||
}
|
}
|
||||||
@@ -1438,6 +1442,10 @@ export function SaveQSLDefaults(arg1) {
|
|||||||
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveRelayAuto(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1) {
|
export function SaveRotatorSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2392,6 +2392,61 @@ export namespace main {
|
|||||||
this.last60 = source["last60"];
|
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 {
|
export class RotatorHeading {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
ok: 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