Compare commits

...
4 Commits
Author SHA1 Message Date
rouggy 59e6570f17 chore: release v0.20.2 2026-07-19 19:11:06 +02:00
rouggy 82a2c6cb7f fix: relay auto-control no longer toggles on every launch/close
Closing OpsLog dropped the CAT, whose frequency went to 0; the auto-control
read that as out of range and switched the relay OFF, then ON again at the next
launch. Now a rule is skipped entirely when the frequency/band is unknown (CAT
off), so relays keep their state across a close. And on the first evaluation
after launch/save it reads the boards' LIVE state and only switches a relay
that isn't already in the wanted position — no more clunk when it's already
correct.
2026-07-19 18:35:18 +02:00
rouggy 24eaf597fd fix: ON AIR badge correct at launch + faster to appear
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.
2026-07-19 18:35:18 +02:00
rouggy 14a22ddb66 fix: SetExtra used the wrong column name (extras -> extras_json)
The targeted extras write used SET extras, but the column is extras_json, so
sending an OpsLog QSL card (and the recording stamps) failed with an unknown-
column error. Use the real column name.
2026-07-19 18:35:18 +02:00
10 changed files with 283 additions and 43 deletions
+99 -18
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock, Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { import {
@@ -13,7 +13,7 @@ import {
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO, GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail, UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings, LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations,
WorkedBefore, WorkedBefore,
SetCompactMode, SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
@@ -210,6 +210,16 @@ function bandForMHz(mhz: number): string {
return ''; return '';
} }
// modeAccent maps a mode to a theme-aware colour for the live-stations widget:
// CW gold, phone green, digital blue, unknown muted.
function modeAccent(mode?: string): string {
const m = (mode || '').toUpperCase();
if (/CW/.test(m)) return 'var(--chart-3)';
if (/SSB|USB|LSB|AM|FM|PHONE|DV/.test(m)) return 'var(--chart-2)';
if (/FT8|FT4|RTTY|PSK|JT|JS8|Q65|MSK|FST|MFSK|OLIVIA|DIG|DATA|WSPR/.test(m)) return 'var(--chart-1)';
return 'var(--muted-foreground)';
}
// rstCategory buckets a mode into the report family used for its RST list. // rstCategory buckets a mode into the report family used for its RST list.
type RSTLists = { phone: string[]; cw: string[]; digital: string[] }; type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
function rstCategory(mode: string): keyof RSTLists { function rstCategory(mode: string): keyof RSTLists {
@@ -410,6 +420,18 @@ export default function App() {
// click reverts the UI and the click looks like it did nothing. // click reverts the UI and the click looks like it did nothing.
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({}); const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null); const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
// Multi-op "who's on air" widget: every operator's live status from the shared
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
const [liveStations, setLiveStations] = useState<LiveStation[]>([]);
const [showLiveStations, setShowLiveStations] = useState(() => localStorage.getItem('opslog.showLiveStations') === '1');
useEffect(() => {
if (dbConn?.backend !== 'mysql') { setLiveStations([]); return; }
const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
load();
const id = window.setInterval(load, 15 * 1000);
return () => window.clearInterval(id);
}, [dbConn]);
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig // Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
// in Preferences > Hardware > CAT interface. // in Preferences > Hardware > CAT interface.
@@ -1096,23 +1118,17 @@ export default function App() {
// offline. Only shown when live-status publishing is enabled (Settings→General). // offline. Only shown when live-status publishing is enabled (Settings→General).
const [liveStatusOn, setLiveStatusOn] = useState(false); const [liveStatusOn, setLiveStatusOn] = useState(false);
const [onAir, setOnAir] = useState(false); const [onAir, setOnAir] = useState(false);
const lastQsoAtRef = useRef(0);
useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]); useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]);
useEffect(() => { useEffect(() => {
const LIVE_WINDOW = 5 * 60 * 1000; // 5 min, matches the backend // Read the ON-AIR state straight from the backend (single source of truth:
const evalOnAir = () => setOnAir(liveStatusOn && lastQsoAtRef.current > 0 && (Date.now() - lastQsoAtRef.current) < LIVE_WINDOW); // liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll
const off = EventsOn('qso:logged', () => { lastQsoAtRef.current = Date.now(); evalOnAir(); }); // it + refresh on each logged QSO — no fragile frontend timestamp to drift.
// Seed from the DB at launch so a QSO logged just before starting OpsLog still const refresh = () => LiveLastQSOAgeSec()
// counts (otherwise the badge showed offline until the next contact). .then((sec: number) => setOnAir(liveStatusOn && typeof sec === 'number' && sec >= 0 && sec < 300))
LiveLastQSOAgeSec().then((sec: number) => { .catch(() => {});
if (typeof sec === 'number' && sec >= 0) { refresh();
const at = Date.now() - sec * 1000; const off = EventsOn('qso:logged', refresh);
if (at > lastQsoAtRef.current) lastQsoAtRef.current = at; const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (cheap 400-row scan)
evalOnAir();
}
}).catch(() => {});
evalOnAir();
const id = window.setInterval(evalOnAir, 10 * 1000); // flip to offline within ~10s of the window elapsing
return () => { off(); window.clearInterval(id); }; return () => { off(); window.clearInterval(id); };
}, [liveStatusOn]); }, [liveStatusOn]);
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General. // QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
@@ -3846,6 +3862,24 @@ export default function App() {
)} )}
</button> </button>
)} )}
{/* Multi-op "who's on air": a dockable widget (toggle), not a popover. */}
{dbConn?.backend === 'mysql' && (
<button
type="button"
onClick={() => { const v = !showLiveStations; setShowLiveStations(v); writeUiPref('opslog.showLiveStations', v ? '1' : '0'); }}
title={showLiveStations ? `${t('live.stationsTitle')} — shown · click to hide` : `${t('live.stationsTitle')} · click to show`}
className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showLiveStations ? 'border-info-border bg-info-muted text-info-muted-foreground hover:bg-info-muted'
: 'border-border text-muted-foreground hover:bg-muted')}
>
<Radio className="size-4" />
{liveStations.filter((s) => s.online).length > 0 && (
<span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-danger text-danger-foreground text-[9px] font-bold leading-[14px] text-center">
{(() => { const n = liveStations.filter((s) => s.online).length; return n > 9 ? '9+' : n; })()}
</span>
)}
</button>
)}
</div> </div>
{/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header {/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header
@@ -4214,8 +4248,55 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the {/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style); Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */} otherwise it shows the QRZ profile photo. */}
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled)) && ( {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch"> <div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
{/* Multi-op "who's on air" widget: every operator on the shared logbook,
their freq/mode (colour-coded) and OpsLog version. */}
{showLiveStations && dbConn?.backend === 'mysql' && (
<div className="w-[248px] shrink-0 min-h-0 relative">
<div className="absolute inset-0 flex flex-col min-h-0 rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-1.5 px-3 h-8 border-b border-border shrink-0">
<Radio className="size-3.5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider truncate">{t('live.stationsTitle')}</span>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground tabular-nums">{liveStations.filter((s) => s.online).length}/{liveStations.length}</span>
<button type="button" className="text-muted-foreground hover:text-foreground shrink-0"
onClick={() => { setShowLiveStations(false); writeUiPref('opslog.showLiveStations', '0'); }} title={t('live.stationsHide')}>
<X className="size-3.5" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-auto p-1.5 flex flex-col gap-1">
{liveStations.length === 0 ? (
<p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p>
) : liveStations.map((s, i) => {
const mc = modeAccent(s.mode);
return (
<div key={i} className={cn('flex items-center gap-2 rounded-md px-2 py-1.5 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
<span className={cn('size-2 rounded-full shrink-0', s.online ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')}
title={s.online ? t('live.onAir') : t('live.offline')} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5 min-w-0">
<span className="text-xs font-bold font-mono truncate">{s.operator}</span>
{s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums ml-auto">v{s.version}</span>}
</div>
<div className="flex items-center gap-1.5 mt-0.5 min-w-0">
<span className="font-mono text-[11px] font-semibold tabular-nums" style={{ color: mc }}>
{s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
</span>
{s.mode && (
<span className="text-[9px] font-bold uppercase px-1.5 rounded-full leading-[15px] shrink-0"
style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
)}
{s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
{chatShown && ( {chatShown && (
// relative + absolute inner: the chat takes the row height (set by the // relative + absolute inner: the chat takes the row height (set by the
// entry strip) WITHOUT its message list growing the row, like the // entry strip) WITHOUT its message list growing the row, like the
+2
View File
@@ -17,6 +17,7 @@ const en: Dict = {
'live.onAir': 'On air', 'live.offline': 'Offline', 'live.onAir': 'On air', 'live.offline': 'Offline',
'live.onAirTip': 'On air — a QSO was logged in the last 5 minutes (published to the live status)', 'live.onAirTip': 'On air — a QSO was logged in the last 5 minutes (published to the live status)',
'live.offlineTip': 'Offline — no QSO logged in the last 5 minutes', 'live.offlineTip': 'Offline — no QSO logged in the last 5 minutes',
'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'No station reporting yet.', 'live.stationsHide': 'Hide',
'upd.available': 'OpsLog v{v} available', 'upd.current': "You're on v{v}.", 'upd.available': 'OpsLog v{v} available', 'upd.current': "You're on v{v}.",
'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later', 'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later',
'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…', 'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…',
@@ -335,6 +336,7 @@ const fr: Dict = {
'live.onAir': 'On air', 'live.offline': 'Hors ligne', 'live.onAir': 'On air', 'live.offline': 'Hors ligne',
'live.onAirTip': "On air — un QSO a été loggé dans les 5 dernières minutes (publié dans le statut live)", 'live.onAirTip': "On air — un QSO a été loggé dans les 5 dernières minutes (publié dans le statut live)",
'live.offlineTip': 'Hors ligne — aucun QSO loggé depuis 5 minutes', 'live.offlineTip': 'Hors ligne — aucun QSO loggé depuis 5 minutes',
'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'Aucune station ne reporte pour le moment.', 'live.stationsHide': 'Masquer',
'rate.title': 'Rythme QSO (QSO/heure) — projeté sur les 10 / 60 dernières minutes', 'rate.title': 'Rythme QSO (QSO/heure) — projeté sur les 10 / 60 dernières minutes',
'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)', 'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils', 'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.20.1'; export const APP_VERSION = '0.20.2';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -354,6 +354,8 @@ export function GetIcomState():Promise<cat.IcomTXState>;
export function GetListsSettings():Promise<main.ListsSettings>; export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLiveStations():Promise<Array<main.LiveStation>>;
export function GetLiveStatusEnabled():Promise<boolean>; export function GetLiveStatusEnabled():Promise<boolean>;
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>; export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
+4
View File
@@ -666,6 +666,10 @@ export function GetListsSettings() {
return window['go']['main']['App']['GetListsSettings'](); return window['go']['main']['App']['GetListsSettings']();
} }
export function GetLiveStations() {
return window['go']['main']['App']['GetLiveStations']();
}
export function GetLiveStatusEnabled() { export function GetLiveStatusEnabled() {
return window['go']['main']['App']['GetLiveStatusEnabled'](); return window['go']['main']['App']['GetLiveStatusEnabled']();
} }
+26
View File
@@ -2054,6 +2054,32 @@ export namespace main {
return a; return a;
} }
} }
export class LiveStation {
operator: string;
station: string;
freq_hz: number;
band: string;
mode: string;
online: boolean;
version: string;
age_sec: number;
static createFrom(source: any = {}) {
return new LiveStation(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.operator = source["operator"];
this.station = source["station"];
this.freq_hz = source["freq_hz"];
this.band = source["band"];
this.mode = source["mode"];
this.online = source["online"];
this.version = source["version"];
this.age_sec = source["age_sec"];
}
}
export class LoTWUsersStatus { export class LoTWUsersStatus {
count: number; count: number;
updated?: string; updated?: string;
+2 -2
View File
@@ -793,7 +793,7 @@ func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error
return fmt.Errorf("missing id or key") return fmt.Errorf("missing id or key")
} }
var extrasJSON sql.NullString var extrasJSON sql.NullString
if err := r.db.QueryRowContext(ctx, `SELECT extras FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil { if err := r.db.QueryRowContext(ctx, `SELECT extras_json FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil {
return fmt.Errorf("load extras: %w", err) return fmt.Errorf("load extras: %w", err)
} }
m := decodeExtras(extrasJSON.String) m := decodeExtras(extrasJSON.String)
@@ -806,7 +806,7 @@ func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error
m[key] = value m[key] = value
} }
if _, err := r.db.ExecContext(ctx, if _, err := r.db.ExecContext(ctx,
`UPDATE qso SET extras = ?, updated_at = ? WHERE id = ?`, `UPDATE qso SET extras_json = ?, updated_at = ? WHERE id = ?`,
encodeExtras(m), db.NowISO(), id); err != nil { encodeExtras(m), db.NowISO(), id); err != nil {
return fmt.Errorf("set extra %s: %w", key, err) return fmt.Errorf("set extra %s: %w", key, err)
} }
+85 -11
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"database/sql"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -87,12 +88,29 @@ func (a *App) seedLiveLastQSO() {
} }
} }
// LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if // liveLastQSOTime is the authoritative "last contact" instant for this operator:
// none is known — the UI uses it to seed the "on air" badge at launch. // the most recent of the in-memory stamp (this session's local logs, updated
func (a *App) LiveLastQSOAgeSec() int { // 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() a.liveActMu.Lock()
last := a.liveLastQSOAt last := a.liveLastQSOAt
a.liveActMu.Unlock() 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() { if last.IsZero() {
return -1 return -1
} }
@@ -182,8 +200,8 @@ func (a *App) publishLiveStatus() {
if mode == "" { if mode == "" {
mode = a.liveMode mode = a.liveMode
} }
lastQSO := a.liveLastQSOAt
a.liveActMu.Unlock() 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 // Online = a new contact was logged within the window. An operator who leaves
// the log open but stops working shows offline after `liveOnlineWindow`; the // the log open but stops working shows offline after `liveOnlineWindow`; the
// next QSO flips them back on. never-logged (zero time) → offline. // next QSO flips them back on. never-logged (zero time) → offline.
@@ -200,12 +218,12 @@ func (a *App) publishLiveStatus() {
return return
} }
_, err := a.logDb.ExecContext(a.ctx, _, err := a.logDb.ExecContext(a.ctx,
"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, last_qso_at, updated_at) "+ "INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, last_qso_at, updated_at) "+
"VALUES (?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+ "ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), "+ "band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()", "last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
op, station, freqHz, band, mode, online, lastQSOArg) op, station, freqHz, band, mode, online, appVersion, lastQSOArg)
if err != nil { if err != nil {
applog.Printf("livestatus: INSERT failed: %v", err) applog.Printf("livestatus: INSERT failed: %v", err)
return return
@@ -213,6 +231,60 @@ func (a *App) publishLiveStatus() {
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s online=%d", op, station, freqHz, band, mode, online) 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 { func (a *App) ensureLiveStatusTable() error {
if _, err := a.logDb.ExecContext(a.ctx, if _, err := a.logDb.ExecContext(a.ctx,
"CREATE TABLE IF NOT EXISTS live_status ("+ "CREATE TABLE IF NOT EXISTS live_status ("+
@@ -222,15 +294,17 @@ func (a *App) ensureLiveStatusTable() error {
"band VARCHAR(16), "+ "band VARCHAR(16), "+
"mode VARCHAR(16), "+ "mode VARCHAR(16), "+
"online TINYINT DEFAULT 0, "+ "online TINYINT DEFAULT 0, "+
"version VARCHAR(32), "+
"last_qso_at DATETIME NULL, "+ "last_qso_at DATETIME NULL, "+
"updated_at DATETIME)"); err != nil { "updated_at DATETIME)"); err != nil {
return err return err
} }
// Add the online/last_qso_at columns to a table created by an older build. // Add newer columns to a table created by an older build. MySQL has no portable
// MySQL has no portable "ADD COLUMN IF NOT EXISTS", so just run the ALTERs and // "ADD COLUMN IF NOT EXISTS", so just run the ALTERs and ignore the duplicate-
// ignore the duplicate-column error when they already exist. // column error when they already exist.
for _, ddl := range []string{ for _, ddl := range []string{
"ALTER TABLE live_status ADD COLUMN online TINYINT DEFAULT 0", "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", "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") { if _, err := a.logDb.ExecContext(a.ctx, ddl); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
+61 -10
View File
@@ -96,8 +96,23 @@ func bandInList(bands []string, band string) bool {
return false return false
} }
// relayAction is one relay's computed desired state for this evaluation.
type relayAction struct {
dev string
relay int
want bool
}
// applyRelayAuto evaluates every rule against the current frequency/band and // applyRelayAuto evaluates every rule against the current frequency/band and
// switches only the relays whose desired state changed since the last apply. // switches only the relays that are NOT already in the wanted position. Two things
// it deliberately does NOT do, which used to make the relay clunk on every
// launch/close:
// - Never acts on an UNKNOWN frequency/band. When the CAT disconnects (app close)
// the frequency drops to 0; reading that as "out of range" and switching the
// relay off — then back on at the next launch — was the whole bug.
// - Never commands a relay already in the right position: on the first evaluation
// after launch/save it reads the boards' LIVE state, so a relay that's already
// correct is left untouched instead of being re-sent.
func (a *App) applyRelayAuto(freqHz int64, band string) { func (a *App) applyRelayAuto(freqHz int64, band string) {
a.relayAutoMu.Lock() a.relayAutoMu.Lock()
defer a.relayAutoMu.Unlock() defer a.relayAutoMu.Unlock()
@@ -110,8 +125,11 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
a.relayAutoLast = map[string]bool{} a.relayAutoLast = map[string]bool{}
} }
khz := float64(freqHz) / 1000.0 khz := float64(freqHz) / 1000.0
band = strings.TrimSpace(band)
changed := false // Compute desired states, skipping rules whose input is unknown right now.
var acts []relayAction
needLive := false
for _, r := range cfg.Rules { for _, r := range cfg.Rules {
if r.Relay < 1 { if r.Relay < 1 {
continue continue
@@ -119,8 +137,11 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
var want bool var want bool
switch r.Mode { switch r.Mode {
case "freq": case "freq":
if freqHz <= 0 {
continue // no known frequency (CAT off/closing) → leave the relay as-is
}
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 { if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
continue // unconfigured range → leave the relay alone continue // unconfigured range
} }
lo, hi := r.FreqLoKHz, r.FreqHiKHz lo, hi := r.FreqLoKHz, r.FreqHiKHz
if hi < lo { if hi < lo {
@@ -128,6 +149,9 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
} }
want = khz >= lo && khz <= hi want = khz >= lo && khz <= hi
case "band": case "band":
if band == "" {
continue // no known band → leave the relay as-is
}
if len(r.Bands) == 0 { if len(r.Bands) == 0 {
continue continue
} }
@@ -135,16 +159,43 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
default: default:
continue // "off"/empty → not managed continue // "off"/empty → not managed
} }
acts = append(acts, relayAction{r.DeviceID, r.Relay, want})
key := relayAutoKey(r.DeviceID, r.Relay) if _, ok := a.relayAutoLast[relayAutoKey(r.DeviceID, r.Relay)]; !ok {
if last, ok := a.relayAutoLast[key]; ok && last == want { needLive = true
continue // no change → don't hammer the board
} }
if err := a.StationSetRelay(r.DeviceID, r.Relay, want); err != nil { }
applog.Printf("relay auto: set %s relay %d = %v failed: %v", r.DeviceID, r.Relay, want, err) if len(acts) == 0 {
return
}
// First evaluation after launch/save: read the boards' LIVE relay states once
// so we don't re-command a relay that's already in the wanted position.
var live map[string]bool
if needLive {
live = map[string]bool{}
for _, ds := range a.GetStationStatus() {
for _, rl := range ds.Relays {
live[relayAutoKey(ds.ID, rl.Number)] = rl.On
}
}
}
changed := false
for _, ac := range acts {
key := relayAutoKey(ac.dev, ac.relay)
cur, known := a.relayAutoLast[key]
if !known && live != nil {
cur, known = live[key]
}
if known && cur == ac.want {
a.relayAutoLast[key] = ac.want // already in position — record it, don't switch
continue
}
if err := a.StationSetRelay(ac.dev, ac.relay, ac.want); err != nil {
applog.Printf("relay auto: set %s relay %d = %v failed: %v", ac.dev, ac.relay, ac.want, err)
continue // don't cache a failed write — retry next change continue // don't cache a failed write — retry next change
} }
a.relayAutoLast[key] = want a.relayAutoLast[key] = ac.want
changed = true changed = true
} }
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.20.1" appVersion = "0.20.2"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.