328 lines
11 KiB
Go
328 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"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, 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, online, appVersion, 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|