At launch nothing is in memory yet, so the badge showed offline even with a recent QSO. liveLastQSOTime is now authoritative: it takes the most recent of the in-memory stamp AND the DB (this operator's last QSO — covers a contact from the shared logbook or one logged before launch), used by both the published status and the badge. The badge polls the backend every 5 s (down from 15) and on each qso:logged, so it shows on air within a few seconds and flips online instantly on a new contact.
271 lines
8.8 KiB
Go
271 lines
8.8 KiB
Go
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 (`<img src=…>`). 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)
|
|
}
|