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" // liveOnlineWindow is how long after the last logged contact an operator still // counts as "on air". Leaving the log open without working anyone flips them // offline once this elapses; logging a new QSO flips them back online. const liveOnlineWindow = 5 * time.Minute // noteLiveQSO records that this operator just logged a new contact and pushes the // live status right away, so they flip back to online the instant they work // someone. Called from the logging paths (manual entry, UDP auto-log). func (a *App) noteLiveQSO() { a.liveActMu.Lock() a.liveLastQSOAt = time.Now() a.liveActMu.Unlock() if a.liveStatusActive() { go a.publishLiveStatus() } } // 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 } // seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who // worked someone shortly before (re)starting OpsLog is shown "on air" right away // instead of offline until their next QSO. func (a *App) seedLiveLastQSO() { if a.qso == nil { return } op, _ := a.liveStatusOperator() if op == "" { return } if t, ok := a.qso.LastQSOTime(a.ctx, op); ok { a.liveActMu.Lock() if a.liveLastQSOAt.IsZero() { a.liveLastQSOAt = t } a.liveActMu.Unlock() } } // liveLastQSOTime is the authoritative "last contact" instant for this operator: // the most recent of the in-memory stamp (this session's local logs, updated // instantly) AND the DB (a contact that arrived via the SHARED logbook from another // station, or one logged before launch). Used by both the published status and the // UI badge so on-air/offline is right in every multi-op case. func (a *App) liveLastQSOTime() time.Time { a.liveActMu.Lock() last := a.liveLastQSOAt a.liveActMu.Unlock() if a.qso != nil { if op, _ := a.liveStatusOperator(); op != "" { if t, ok := a.qso.LastQSOTime(a.ctx, op); ok && t.After(last) { last = t } } } return last } // LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if // none is known — the UI polls it for the "on air" badge. func (a *App) LiveLastQSOAgeSec() int { last := a.liveLastQSOTime() if last.IsZero() { return -1 } return int(time.Since(last).Seconds()) } // 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.seedLiveLastQSO() // so online/offline is right at launch, not only after the next QSO 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() lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB) // Online = a new contact was logged within the window. An operator who leaves // the log open but stops working shows offline after `liveOnlineWindow`; the // next QSO flips them back on. never-logged (zero time) → offline. online := 0 var lastQSOArg any if !lastQSO.IsZero() { lastQSOArg = lastQSO.UTC() if time.Since(lastQSO) < liveOnlineWindow { online = 1 } } 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, online, last_qso_at, updated_at) "+ "VALUES (?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+ "ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+ "band=VALUES(band), mode=VALUES(mode), online=VALUES(online), "+ "last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()", op, station, freqHz, band, mode, online, lastQSOArg) if err != nil { applog.Printf("livestatus: INSERT failed: %v", err) return } applog.Printf("livestatus: published op=%s station=%s %dHz %s %s online=%d", op, station, freqHz, band, mode, online) } func (a *App) ensureLiveStatusTable() error { if _, 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), "+ "online TINYINT DEFAULT 0, "+ "last_qso_at DATETIME NULL, "+ "updated_at DATETIME)"); err != nil { return err } // Add the online/last_qso_at columns to a table created by an older build. // MySQL has no portable "ADD COLUMN IF NOT EXISTS", so just run the ALTERs and // ignore the duplicate-column error when they already exist. for _, ddl := range []string{ "ALTER TABLE live_status ADD COLUMN online TINYINT DEFAULT 0", "ALTER TABLE live_status ADD COLUMN last_qso_at DATETIME NULL", } { if _, err := a.logDb.ExecContext(a.ctx, ddl); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") { applog.Printf("livestatus: %q: %v", ddl, err) } } return nil } // 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) }