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 } // relayAction is one relay's computed desired state for this evaluation. type relayAction struct { dev string relay int want bool } // applyRelayAuto evaluates every rule against the current frequency/band and // switches only the relays that are NOT already in the wanted position. Two things // it deliberately does NOT do, which used to make the relay clunk on every // launch/close: // - Never acts on an UNKNOWN frequency/band. When the CAT disconnects (app close) // the frequency drops to 0; reading that as "out of range" and switching the // relay off — then back on at the next launch — was the whole bug. // - Never commands a relay already in the right position: on the first evaluation // after launch/save it reads the boards' LIVE state, so a relay that's already // correct is left untouched instead of being re-sent. 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 band = strings.TrimSpace(band) // Compute desired states, skipping rules whose input is unknown right now. var acts []relayAction needLive := false for _, r := range cfg.Rules { if r.Relay < 1 { continue } var want bool switch r.Mode { case "freq": if freqHz <= 0 { continue // no known frequency (CAT off/closing) → leave the relay as-is } if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 { continue // unconfigured range } lo, hi := r.FreqLoKHz, r.FreqHiKHz if hi < lo { lo, hi = hi, lo } want = khz >= lo && khz <= hi case "band": if band == "" { continue // no known band → leave the relay as-is } if len(r.Bands) == 0 { continue } want = bandInList(r.Bands, band) default: continue // "off"/empty → not managed } acts = append(acts, relayAction{r.DeviceID, r.Relay, want}) if _, ok := a.relayAutoLast[relayAutoKey(r.DeviceID, r.Relay)]; !ok { needLive = true } } if len(acts) == 0 { return } // First evaluation after launch/save: read the boards' LIVE relay states once // so we don't re-command a relay that's already in the wanted position. var live map[string]bool if needLive { live = map[string]bool{} for _, ds := range a.GetStationStatus() { for _, rl := range ds.Relays { live[relayAutoKey(ds.ID, rl.Number)] = rl.On } } } changed := false for _, ac := range acts { key := relayAutoKey(ac.dev, ac.relay) cur, known := a.relayAutoLast[key] if !known && live != nil { cur, known = live[key] } if known && cur == ac.want { a.relayAutoLast[key] = ac.want // already in position — record it, don't switch continue } if err := a.StationSetRelay(ac.dev, ac.relay, ac.want); err != nil { applog.Printf("relay auto: set %s relay %d = %v failed: %v", ac.dev, ac.relay, ac.want, err) continue // don't cache a failed write — retry next change } a.relayAutoLast[key] = ac.want changed = true } if changed && a.ctx != nil { wruntime.EventsEmit(a.ctx, "station:relay_auto", nil) // nudge the Station Control UI to re-poll } }