package main import ( "fmt" "strings" "time" "hamlog/internal/applog" ) // Live operator status — for multi-operator events on a SHARED MySQL logbook // (e.g. a special-event call like TM74FR with several ops on different bands). // Each OpsLog instance heartbeats its current activity (operator call + station // call + freq/band/mode from CAT) into a `live_status` table every ~15s. A tiny // web script on the operator's own server reads that table and renders a live // page/image that the QRZ.com bio can embed (``). OpsLog only WRITES // to the DB — it is not a web server. Rows older than a couple of minutes are // "stale" (operator went offline); the web side ignores them. const keyLiveStatusEnabled = "livestatus.enabled" // GetLiveStatusEnabled reports whether this operator publishes live status. func (a *App) GetLiveStatusEnabled() bool { if a.settings == nil { return false } v, _ := a.settings.Get(a.ctx, keyLiveStatusEnabled) return strings.TrimSpace(v) == "1" } // SetLiveStatusEnabled turns live-status publishing on or off (off also removes // this operator's row immediately). func (a *App) SetLiveStatusEnabled(on bool) error { if a.settings == nil { return fmt.Errorf("db not initialized") } val := "0" if on { val = "1" } if err := a.settings.Set(a.ctx, keyLiveStatusEnabled, val); err != nil { return err } if on { applog.Printf("livestatus: enabled (logbook backend=%q, mysql conn=%v)", a.dbBackend, a.logDb != nil) go a.publishLiveStatus() // show up right away } else { a.clearLiveStatus() } return nil } // liveStatusLoop heartbeats the current activity while enabled. Started once at // startup; cheap no-op when disabled or not on MySQL. func (a *App) liveStatusLoop() { defer func() { _ = recover() }() // never crash the app from here applog.Printf("livestatus: loop started") a.publishLiveStatus() // attempt immediately, don't wait the first tick t := time.NewTicker(15 * time.Second) defer t.Stop() for range t.C { a.publishLiveStatus() } } // liveStatusActive reports whether publishing should run (MySQL logbook + on). func (a *App) liveStatusActive() bool { return a.logDb != nil && a.dbBackend == "mysql" && a.GetLiveStatusEnabled() } // liveStatusOperator returns this instance's operator id (the operator callsign, // falling back to the station callsign for a single-op setup). The callsign and // operator live on the ACTIVE PROFILE (station_profiles table), NOT in the // settings KV — read them there. func (a *App) liveStatusOperator() (op, station string) { if a.profiles == nil { return "", "" } p, err := a.profiles.Active(a.ctx) if err != nil { return "", "" } station = strings.ToUpper(strings.TrimSpace(p.Callsign)) op = strings.ToUpper(strings.TrimSpace(p.Operator)) if op == "" { op = station } return op, station } // ReportLiveActivity is called by the UI with the current entry-strip freq/band/ // mode, used as a fallback for live status when the CAT isn't connected. func (a *App) ReportLiveActivity(freqHz int64, band, mode string) { a.liveActMu.Lock() a.liveFreqHz = freqHz a.liveBand = strings.ToUpper(strings.TrimSpace(band)) a.liveMode = strings.ToUpper(strings.TrimSpace(mode)) a.liveActMu.Unlock() } // publishLiveStatus upserts this operator's current activity. Best effort, with // explicit logging so a silent no-op is diagnosable. func (a *App) publishLiveStatus() { if a.logDb == nil || a.dbBackend != "mysql" { return // not a MySQL logbook — nothing to do (silent, runs every 15s) } if !a.GetLiveStatusEnabled() { return // disabled (silent) } op, station := a.liveStatusOperator() if op == "" { applog.Printf("livestatus: nothing published — no operator/callsign set (Settings → Station)") return } var freqHz int64 var band, mode string if a.cat != nil { st := a.cat.State() if st.Connected { freqHz, band, mode = st.FreqHz, st.Band, st.Mode } } // Fall back to whatever the entry strip last reported (so band/mode/freq are // published even when the CAT isn't connected). a.liveActMu.Lock() if freqHz == 0 { freqHz = a.liveFreqHz } if band == "" { band = a.liveBand } if mode == "" { mode = a.liveMode } a.liveActMu.Unlock() if err := a.ensureLiveStatusTable(); err != nil { applog.Printf("livestatus: CREATE TABLE failed: %v", err) return } _, err := a.logDb.ExecContext(a.ctx, "INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+ "VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+ "ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+ "band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()", op, station, freqHz, band, mode) if err != nil { applog.Printf("livestatus: INSERT failed: %v", err) return } applog.Printf("livestatus: published op=%s station=%s %dHz %s %s", op, station, freqHz, band, mode) } func (a *App) ensureLiveStatusTable() error { _, err := a.logDb.ExecContext(a.ctx, "CREATE TABLE IF NOT EXISTS live_status ("+ "operator VARCHAR(32) PRIMARY KEY, "+ "station VARCHAR(32), "+ "freq_hz BIGINT, "+ "band VARCHAR(16), "+ "mode VARCHAR(16), "+ "updated_at DATETIME)") return err } // clearLiveStatus removes this operator's row (on disable / shutdown). func (a *App) clearLiveStatus() { if a.logDb == nil || a.dbBackend != "mysql" { return } op, _ := a.liveStatusOperator() if op == "" { return } _, _ = a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op) }