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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user