package main import ( "database/sql" "fmt" "strings" "time" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" "hamlog/internal/applog" ) // emitLiveStatusChanged nudges the frontend to re-read GetLiveStations right when // the shared table actually changes (a row appeared or was removed) — so the // "stations on air" widget updates the instant a QSO is published, matching the // bottom ON-AIR badge instead of waiting for its poll. func (a *App) emitLiveStatusChanged() { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "livestatus:updated", nil) } } // 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) { nb := strings.ToUpper(strings.TrimSpace(band)) nm := strings.ToUpper(strings.TrimSpace(mode)) a.liveActMu.Lock() changed := a.liveFreqHz != freqHz || a.liveBand != nb || a.liveMode != nm a.liveFreqHz = freqHz a.liveBand = nb a.liveMode = nm // Push a fresh row PROMPTLY when the operator's freq/band/mode changes, instead // of waiting for the next 15 s heartbeat — otherwise "who's on air" showed a // stale band and took up to a minute to catch up. Debounced so spinning the VFO // doesn't hammer the shared MySQL. if changed { if a.livePublishTimer != nil { a.livePublishTimer.Stop() } a.livePublishTimer = time.AfterFunc(1500*time.Millisecond, func() { a.publishLiveStatus() }) } 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) // On air = a new contact was logged within the window. An operator who leaves // the log open but stops working goes offline after `liveOnlineWindow`; the next // QSO puts them back on. never-logged (zero time) → offline. online := !lastQSO.IsZero() && time.Since(lastQSO) < liveOnlineWindow if err := a.ensureLiveStatusTable(); err != nil { applog.Printf("livestatus: CREATE TABLE failed: %v", err) return } // Offline → REMOVE the row entirely, not just flip a flag: a status page that // lists the present rows (the common case, keyed on updated_at) then shows the // operator as gone without having to read the online column. The row reappears // on the next QSO. This is the whole point — no one shows on air when they're not. if !online { if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil { applog.Printf("livestatus: offline DELETE failed: %v", err) } a.emitLiveStatusChanged() return } lastQSOArg := lastQSO.UTC() _, err := a.logDb.ExecContext(a.ctx, "INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, 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), version=VALUES(version), "+ "last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()", op, station, freqHz, band, mode, 1, appVersion, lastQSOArg) if err != nil { applog.Printf("livestatus: INSERT failed: %v", err) return } applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode) a.emitLiveStatusChanged() } // LiveStation is one operator's live status for the multi-op "who's on air" widget. type LiveStation struct { Operator string `json:"operator"` Station string `json:"station"` FreqHz int64 `json:"freq_hz"` Band string `json:"band"` Mode string `json:"mode"` Online bool `json:"online"` // logged a QSO in the last 5 min Version string `json:"version"` // that operator's OpsLog version AgeSec int `json:"age_sec"` // seconds since their last heartbeat (stale = OpsLog closed) } // GetLiveStations returns every operator's live status from the shared MySQL // logbook (empty on a local SQLite logbook). Rows whose heartbeat is very stale // (OpsLog closed without clearing its row) are dropped. Online stations first. func (a *App) GetLiveStations() []LiveStation { if a.logDb == nil || a.dbBackend != "mysql" { return nil } if err := a.ensureLiveStatusTable(); err != nil { return nil } rows, err := a.logDb.QueryContext(a.ctx, "SELECT operator, COALESCE(station,''), COALESCE(freq_hz,0), COALESCE(band,''), "+ "COALESCE(mode,''), COALESCE(online,0), COALESCE(version,''), "+ "TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) "+ "FROM live_status ORDER BY online DESC, operator") if err != nil { applog.Printf("livestatus: list failed: %v", err) return nil } defer rows.Close() out := []LiveStation{} for rows.Next() { var s LiveStation var online int var age sql.NullInt64 if err := rows.Scan(&s.Operator, &s.Station, &s.FreqHz, &s.Band, &s.Mode, &online, &s.Version, &age); err != nil { continue } // Drop rows from an OpsLog that hasn't heartbeated in a while (closed): the // heartbeat is every 15 s, so > 3 min means it's gone. if age.Valid && age.Int64 > 180 { continue } s.Online = online == 1 if age.Valid { s.AgeSec = int(age.Int64) } out = append(out, s) } return out } 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, "+ "version VARCHAR(32), "+ "last_qso_at DATETIME NULL, "+ "updated_at DATETIME)"); err != nil { return err } // Add newer 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 version VARCHAR(32)", "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) }