diff --git a/app.go b/app.go index 091c4b2..d1e4bb8 100644 --- a/app.go +++ b/app.go @@ -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() diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e7ecb29..fb56074 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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> = { 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> = { 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([]); + const [devices, setDevices] = useState([]); + 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, 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 ( +
+

{t('relayauto.hint')}

+ + + {devices.length === 0 && loaded && ( +

{t('relayauto.noDevices')}

+ )} + + {devices.map((dev) => ( +
+
{dev.name || dev.id}
+
+ {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 ( +
+ {label} + +
+ {r.mode === 'freq' && ( +
+ patchRule(dev.id, relay, { freq_lo_khz: parseFloat(e.target.value) || 0 }, false)} + onBlur={() => save(enabled, rules)} /> + + patchRule(dev.id, relay, { freq_hi_khz: parseFloat(e.target.value) || 0 }, false)} + onBlur={() => save(enabled, rules)} /> + kHz +
+ )} + {r.mode === 'band' && ( +
+ {RELAY_BANDS.map((b) => { + const on = r.bands.includes(b); + return ( + + ); + })} +
+ )} +
+
+ ); + })} +
+
+ ))} +
+ ); +} + // 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: () => , + relayauto: () => , backup: BackupPanel, database: DatabasePanel, uscounties: USCountiesPanel, diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 0865ac2..bf9f3a9 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 8621712..dbd08b0 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -386,6 +386,8 @@ export function GetQSO(arg1:number):Promise; export function GetQSORate():Promise; +export function GetRelayAuto():Promise; + export function GetRotatorHeading():Promise; export function GetRotatorSettings():Promise; @@ -740,6 +742,8 @@ export function SaveProfile(arg1:profile.Profile):Promise; export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise; +export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise; + export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise; export function SaveStationDevices(arg1:Array):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index a08fa43..73ead07 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -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); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 87ea8d6..6113f84 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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; diff --git a/relayauto.go b/relayauto.go new file mode 100644 index 0000000..b940b42 --- /dev/null +++ b/relayauto.go @@ -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 + } +}