feat(bandopen): read PSK Reporter, and arrange the sources the watch needs

The detection shipped reading whatever the operator's cluster nodes happened to
carry. On VHF that is a few hundred skimmers, nearly all of them on HF: a 6 m
opening carrying 869 stations reached OpsLog as a handful of spots or none, and
Nexus flagged it on the same PC while OpsLog stayed silent.

internal/pskr subscribes to pskr/filter/v2/<band>/# on PSK Reporter's MQTT
broker. Every ordinary station running WSJT-X reports what it decodes, so the
difference is two orders of magnitude rather than a threshold. The feed's shape
suits us exactly: BOTH grids are in each message, so distance and bearing are
arithmetic — no lookup, and no DXCC-centre approximation, which is what made the
cluster path's bearings coarse. The geometry is injected from app.go so it stays
the same arithmetic the cluster path uses; two answers to one question is how a
bearing quietly becomes wrong.

Volume was the design constraint, not the protocol. Six metres open is thousands
of messages a minute and this runs on some very old PCs, so nothing is kept or
persisted in the watcher: each message is parsed, measured and handed on or
dropped, and the detector's existing window does the deciding.

And the part that was the real bug: enabling the watch now ARRANGES ITS OWN
SOURCES. It adds the two RBN nodes when missing and brings the feed up. A
feature that silently depends on sources nobody can know are needed does not
look unconfigured, it looks broken. Matched on host and port, not name, so an
operator who renamed theirs does not get a duplicate — which the detector would
read as twice as many stations, and announce an opening that is not there.

Turning it off leaves the nodes alone: they may have been wanted for their own
sake, and removing a node someone is using is worse than leaving one they are not.
This commit is contained in:
2026-08-11 12:48:29 +02:00
parent 2d3bfa704e
commit 8afba2c4e8
12 changed files with 601 additions and 12 deletions
+8
View File
@@ -49,6 +49,7 @@ import (
"hamlog/internal/pota" "hamlog/internal/pota"
"hamlog/internal/powergenius" "hamlog/internal/powergenius"
"hamlog/internal/profile" "hamlog/internal/profile"
"hamlog/internal/pskr"
"hamlog/internal/qslcard" "hamlog/internal/qslcard"
"hamlog/internal/qso" "hamlog/internal/qso"
"hamlog/internal/relaydev" "hamlog/internal/relaydev"
@@ -609,6 +610,10 @@ type App struct {
// air now, not a database. // air now, not a database.
decodeGrids map[string]string decodeGrids map[string]string
decodeGridsMu sync.RWMutex decodeGridsMu sync.RWMutex
// pskr is the PSK Reporter MQTT feed, up only while the opening watch is on.
// It is the source that makes VHF detection work at all: the cluster and RBN
// carry a handful of 6 m spots where PSK Reporter carries hundreds.
pskr *pskr.Watcher
// Self-spot throttle: when and on what frequency we last announced ourselves. // Self-spot throttle: when and on what frequency we last announced ourselves.
// Held in memory only — a restart legitimately re-announces the station. // Held in memory only — a restart legitimately re-announces the station.
selfSpotMu sync.Mutex selfSpotMu sync.Mutex
@@ -1399,6 +1404,9 @@ func (a *App) startup(ctx context.Context) {
go a.sendTelemetryHeartbeat() go a.sendTelemetryHeartbeat()
go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL
go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence
// PSK Reporter, when the opening watch is on. After the operator's grid is
// known: without it there is no distance to measure and the feed stays down.
a.startBandOpenFeed()
fmt.Println("OpsLog: db ready at", a.dbPath) fmt.Println("OpsLog: db ready at", a.dbPath)
} }
+200
View File
@@ -0,0 +1,200 @@
package main
// The data sources band-opening detection depends on, and the settings that
// switch them on.
//
// The detection shipped reading whatever the operator's cluster nodes happened
// to carry. That was a design mistake of the worst kind: the feature depended
// on RBN feeds and a PSK Reporter subscription that nobody could know were
// needed, so it looked broken rather than unconfigured. Nexus showed a 6 m
// opening with 869 stations while OpsLog, on the same PC, showed nothing.
//
// So enabling the watch ARRANGES ITS OWN SOURCES: it adds the two RBN nodes if
// they are missing and brings the PSK Reporter feed up. Turning it off leaves
// the nodes alone — they may have been wanted for their own sake, and silently
// removing a cluster node an operator is using would be worse than leaving one
// they no longer need.
import (
"strings"
"hamlog/internal/applog"
"hamlog/internal/bandopen"
"hamlog/internal/cluster"
"hamlog/internal/pskr"
)
const (
keyBandOpenEnabled = "bandopen.enabled"
keyBandOpenBands = "bandopen.bands" // comma-separated; empty = the default set
)
// rbnNodes are the two Reverse Beacon Network endpoints the watch wants: CW and
// digital are separate ports and carry different skimmers.
var rbnNodes = []cluster.ServerConfig{
{Name: "RBN CW", Host: "telnet.reversebeacon.net", Port: 7000, Enabled: true},
{Name: "RBN FTx", Host: "telnet.reversebeacon.net", Port: 7001, Enabled: true},
}
// BandOpenSettings is the panel's shape.
type BandOpenSettings struct {
Enabled bool `json:"enabled"`
Bands []string `json:"bands"`
// Available is every band that can be watched, so the UI does not carry its
// own copy of a list that belongs to the detector.
Available []string `json:"available"`
}
func (a *App) GetBandOpenSettings() BandOpenSettings {
s := BandOpenSettings{
Enabled: a.settingOr(keyBandOpenEnabled, "") == "1",
Bands: splitCSV(a.settingOr(keyBandOpenBands, "")),
Available: pskr.Bands,
}
if len(s.Bands) == 0 {
s.Bands = append(s.Bands, pskr.Bands...)
}
return s
}
func (a *App) SaveBandOpenSettings(s BandOpenSettings) error {
a.setSetting(keyBandOpenEnabled, map[bool]string{true: "1", false: "0"}[s.Enabled])
a.setSetting(keyBandOpenBands, strings.Join(s.Bands, ","))
if s.Enabled {
a.ensureRBNNodes()
}
a.startBandOpenFeed()
return nil
}
// ensureRBNNodes adds the RBN endpoints when they are absent.
//
// Matched on host AND port rather than on name: an operator who renamed theirs
// "Skimmers CW" has the node, and adding a second one pointed at the same
// server would give them every spot twice — which the detector would read as
// twice as many stations, i.e. an opening that is not there.
func (a *App) ensureRBNNodes() {
have, err := a.ListClusterServers()
if err != nil {
applog.Printf("bandopen: cannot read the cluster nodes (%v) — not adding RBN", err)
return
}
for _, want := range rbnNodes {
found := false
for _, h := range have {
if strings.EqualFold(strings.TrimSpace(h.Host), want.Host) && h.Port == want.Port {
found = true
break
}
}
if found {
continue
}
if _, err := a.SaveClusterServer(want); err != nil {
applog.Printf("bandopen: could not add %s: %v", want.Name, err)
continue
}
applog.Printf("bandopen: added cluster node %s (%s:%d) — the watch needs it",
want.Name, want.Host, want.Port)
}
}
// startBandOpenFeed brings the PSK Reporter subscription up or down to match
// the setting. Called at startup and whenever the setting is saved.
func (a *App) startBandOpenFeed() {
if a.pskr != nil {
a.pskr.Stop()
a.pskr = nil
}
s := a.GetBandOpenSettings()
if !s.Enabled {
return
}
// Every spot is measured from the operator's position. Without one there is
// nothing to measure, and a detector fed unmeasurable spots reports nothing
// while looking like it is working.
if !a.opSet {
applog.Printf("bandopen: no station grid set — the opening watch needs one to measure a path")
return
}
a.pskr = pskr.New(pskr.Config{
Bands: s.Bands,
OpLat: a.opLat, OpLon: a.opLon,
Geo: func(grid string) (int, int, bool) {
lat, lon, ok := gridToLatLon(grid)
if !ok {
return 0, 0, false
}
// The same arithmetic the cluster path uses, so one spot cannot be
// 2000 km away down one road and 2100 km down the other.
d := int(haversineKm(a.opLat, a.opLon, lat, lon) + 0.5)
b := int(initialBearingDeg(a.opLat, a.opLon, lat, lon) + 0.5)
return d, b, true
},
OnSpot: a.feedBandOpen,
Logf: applog.Printf,
})
if err := a.pskr.Start(); err != nil {
applog.Printf("bandopen: PSK Reporter feed did not start: %v", err)
}
}
// feedBandOpen hands one PSK Reporter decode to the detector.
//
// Called from the MQTT goroutine at up to thousands a minute when 6 m is open,
// so it does the least possible: the detector's own window and de-duplication
// by callsign are what turn that flood into one announcement.
func (a *App) feedBandOpen(s pskr.Spot) {
if !bandopen.Watched(s.Band) {
return
}
a.bandOpen.mu.Lock()
if a.bandOpen.det == nil {
a.bandOpen.det = bandopen.New(bandopen.DefaultConfig())
}
op := a.bandOpen.det.Add(bandopen.Spot{
Call: s.Call, Band: s.Band, DistKm: s.DistKm, Bearing: s.Bearing, At: s.At,
}, a.opLat)
if op != nil {
a.bandOpen.last = append([]bandopen.Opening{*op}, a.bandOpen.last...)
if len(a.bandOpen.last) > maxRememberedOpenings {
a.bandOpen.last = a.bandOpen.last[:maxRememberedOpenings]
}
}
a.bandOpen.mu.Unlock()
if op != nil {
a.announceOpening(*op)
}
}
// GetPSKReporterStatus is what the settings panel polls.
func (a *App) GetPSKReporterStatus() pskr.Status {
if a.pskr == nil {
return pskr.Status{Bands: pskr.Bands}
}
return a.pskr.Status()
}
// settingOr reads one key, falling back when the store is not up yet or the
// value is blank. The settings store is a plain string key/value and every
// caller does this by hand; two of them here earn the helper.
func (a *App) settingOr(key, def string) string {
if a.settings == nil {
return def
}
v, _ := a.settings.Get(a.ctx, key)
if strings.TrimSpace(v) == "" {
return def
}
return v
}
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+7 -2
View File
@@ -49,10 +49,15 @@ func (a *App) detectBandOpening(s cluster.Spot) {
} }
} }
a.bandOpen.mu.Unlock() a.bandOpen.mu.Unlock()
if op == nil { if op != nil {
return a.announceOpening(*op)
} }
}
// announceOpening logs and pushes one detection. Shared by both feeds — the
// cluster path here and the PSK Reporter path in bandopen_sources.go — so an
// opening reads the same however it was noticed.
func (a *App) announceOpening(op bandopen.Opening) {
applog.Printf("bandopen: %s opening — %d stations, ~%d km, %s%s (%s)", applog.Printf("bandopen: %s opening — %d stations, ~%d km, %s%s (%s)",
op.Band, op.Calls, op.MedianKm, op.Sector(), op.Band, op.Calls, op.MedianKm, op.Sector(),
map[bool]string{true: "", false: " — UNUSUAL for the season"}[op.InSeason], map[bool]string{true: "", false: " — UNUSUAL for the season"}[op.InSeason],
+4 -2
View File
@@ -7,14 +7,16 @@
"CW over CAT now works on a Kenwood. The KY command was sent in Elecraft's variable-length form; a Kenwood needs exactly 24 characters, so every message was refused.", "CW over CAT now works on a Kenwood. The KY command was sent in Elecraft's variable-length form; a Kenwood needs exactly 24 characters, so every message was refused.",
"Shared CAT is steadier and now diagnoses itself. It survives a rig answering \"busy\" just after transmit instead of dropping the link, stops repeating a PTT state the client never changed, and logs a plain explanation when another program has taken its port or when a client is set to a rig model instead of Hamlib NET rigctl. It also answers the lock-mode and stop-morse commands some clients send around every transmit, instead of refusing them.", "Shared CAT is steadier and now diagnoses itself. It survives a rig answering \"busy\" just after transmit instead of dropping the link, stops repeating a PTT state the client never changed, and logs a plain explanation when another program has taken its port or when a client is set to a rig model instead of Hamlib NET rigctl. It also answers the lock-mode and stop-morse commands some clients send around every transmit, instead of refusing them.",
"Web publishing now offers every field a QSO carries, awards included — 123 instead of 23 — from a searchable dropdown, with the chosen columns listed above it in publication order. Choose carefully: the page is public and the list includes addresses and e-mail.", "Web publishing now offers every field a QSO carries, awards included — 123 instead of 23 — from a searchable dropdown, with the chosen columns listed above it in publication order. Choose carefully: the page is public and the list includes addresses and e-mail.",
"Band-opening detection no longer ignores long paths. It capped them at 2400 km on the assumption that anything further was not a single hop; multi-hop sporadic E is ordinary on 6 m, so the openings most worth hearing about were the ones being discarded. Direction still decides — a second hop leaves the sector the first one entered." "Band-opening detection no longer ignores long paths. It capped them at 2400 km on the assumption that anything further was not a single hop; multi-hop sporadic E is ordinary on 6 m, so the openings most worth hearing about were the ones being discarded. Direction still decides — a second hop leaves the sector the first one entered.",
"Band-opening detection now has the data it needs. Switching on \"Watch for band openings\" (Settings DX Cluster) subscribes to the PSK Reporter feed — every station on the air reporting what it decodes, rather than the handful of VHF spots a cluster carries — and adds the two RBN nodes if they are missing. Pick the bands to watch: 12, 10, 6, 4 and 2 m."
], ],
"fr": [ "fr": [
"Décodes digitaux : le carré locator d une station pouvait être enregistré comme son indicatif quand le texte du message sortait de l ordinaire. Un grid à la place de l indicatif est maintenant refusé.", "Décodes digitaux : le carré locator d une station pouvait être enregistré comme son indicatif quand le texte du message sortait de l ordinaire. Un grid à la place de l indicatif est maintenant refusé.",
"Le CW par CAT fonctionne sur Kenwood. La commande KY partait sous la forme Elecraft à longueur libre ; un Kenwood exige exactement 24 caractères, donc chaque message était refusé.", "Le CW par CAT fonctionne sur Kenwood. La commande KY partait sous la forme Elecraft à longueur libre ; un Kenwood exige exactement 24 caractères, donc chaque message était refusé.",
"Le CAT partagé est plus solide et se diagnostique tout seul. Il survit à un rig qui répond « occupé » juste après une émission au lieu de lâcher le lien, cesse de répéter un état PTT que le client n a pas changé, et écrit une explication claire quand un autre programme lui a pris son port ou qu un client est réglé sur un modèle de rig au lieu de Hamlib NET rigctl. Il répond aussi aux commandes de verrouillage et d arrêt du morse que certains logiciels envoient à chaque émission, au lieu de les refuser.", "Le CAT partagé est plus solide et se diagnostique tout seul. Il survit à un rig qui répond « occupé » juste après une émission au lieu de lâcher le lien, cesse de répéter un état PTT que le client n a pas changé, et écrit une explication claire quand un autre programme lui a pris son port ou qu un client est réglé sur un modèle de rig au lieu de Hamlib NET rigctl. Il répond aussi aux commandes de verrouillage et d arrêt du morse que certains logiciels envoient à chaque émission, au lieu de les refuser.",
"La publication web propose désormais tous les champs d un QSO, awards compris — 123 au lieu de 23 — depuis une liste déroulante cherchable, les colonnes choisies étant listées au-dessus dans l ordre de publication. À choisir avec soin : la page est publique et la liste contient adresses et e-mails.", "La publication web propose désormais tous les champs d un QSO, awards compris — 123 au lieu de 23 — depuis une liste déroulante cherchable, les colonnes choisies étant listées au-dessus dans l ordre de publication. À choisir avec soin : la page est publique et la liste contient adresses et e-mails.",
"La détection d ouverture n ignore plus les longues distances. Elle plafonnait à 2400 km en supposant qu au-delà ce n était plus un saut simple ; l Es à sauts multiples est ordinaire sur 6 m, donc les ouvertures les plus intéressantes étaient précisément celles qu on jetait. C est toujours la direction qui tranche — un second saut repart dans le secteur où le premier est arrivé." "La détection d ouverture n ignore plus les longues distances. Elle plafonnait à 2400 km en supposant qu au-delà ce n était plus un saut simple ; l Es à sauts multiples est ordinaire sur 6 m, donc les ouvertures les plus intéressantes étaient précisément celles qu on jetait. C est toujours la direction qui tranche — un second saut repart dans le secteur où le premier est arrivé.",
"La détection d ouverture dispose enfin des données qu il lui faut. Activer « Surveiller les ouvertures de bande » (Paramètres Cluster DX) souscrit au flux PSK Reporter — toutes les stations en l air qui rapportent ce qu elles décodent, au lieu des quelques spots VHF que porte un cluster — et ajoute les deux nœuds RBN s ils manquent. Les bandes surveillées se choisissent : 12, 10, 6, 4 et 2 m."
] ]
}, },
{ {
+59
View File
@@ -52,6 +52,7 @@ import {
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile, GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
GetRelayAuto, SaveRelayAuto, GetStationDevices, GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards, GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models'; import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -1540,6 +1541,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// blur, not per keystroke, or typing "10" would be rewritten to "5" the moment // blur, not per keystroke, or typing "10" would be rewritten to "5" the moment
// the "1" landed and the field would fight the operator. // the "1" landed and the field would fight the operator.
const SELF_SPOT_MIN_MIN = 5; const SELF_SPOT_MIN_MIN = 5;
// Band-opening watch. Saved through the backend rather than as a UI pref: it
// has side effects there — adding the RBN nodes, bringing the PSK Reporter
// feed up or down — so the write has to go where those live.
const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
const [pskrStatus, setPskrStatus] = useState<any>(null);
const saveBandOpen = async (next: any) => {
setBandOpen(next);
try { await SaveBandOpenSettings(next); } catch { /* the status line shows the result */ }
};
useEffect(() => {
(async () => {
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
})();
// Poll the feed while the panel is open: a live count is the only thing that
// distinguishes "connected" from "connected and receiving nothing".
const t = window.setInterval(async () => {
try { setPskrStatus(await GetPSKReporterStatus()); } catch { /* ignore */ }
}, 3000);
return () => window.clearInterval(t);
}, []);
const [selfSpot, setSelfSpot] = useState({ enabled: false, minutes: SELF_SPOT_MIN_MIN }); const [selfSpot, setSelfSpot] = useState({ enabled: false, minutes: SELF_SPOT_MIN_MIN });
const [selfSpotText, setSelfSpotText] = useState(String(SELF_SPOT_MIN_MIN)); const [selfSpotText, setSelfSpotText] = useState(String(SELF_SPOT_MIN_MIN));
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]); const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
@@ -4148,6 +4169,44 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
things set up once. A preferences dialog you reopen every ten minutes things set up once. A preferences dialog you reopen every ten minutes
is a filter in the wrong place. */} is a filter in the wrong place. */}
{/* Band-opening watch. It lives HERE, with the cluster nodes, because
switching it on adds two of them the operator should see that
happen where it happens rather than find nodes they did not add. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={bandOpen.enabled} className="mt-0.5"
onCheckedChange={(c) => saveBandOpen({ ...bandOpen, enabled: !!c })} />
<span>{t('bo.enable')} <span className="text-xs text-muted-foreground">{t('bo.enableHint')}</span></span>
</label>
{bandOpen.enabled && (
<div className="pl-6 space-y-2">
<div className="flex flex-wrap gap-1.5">
{(bandOpen.available ?? []).map((b: string) => {
const on = (bandOpen.bands ?? []).includes(b);
return (
<button key={b} type="button"
onClick={() => saveBandOpen({
...bandOpen,
bands: on ? bandOpen.bands.filter((x: string) => x !== b) : [...(bandOpen.bands ?? []), b],
})}
className={cn('px-2 py-0.5 rounded-md border text-[11px] font-bold tracking-wider font-mono',
on ? 'bg-primary text-primary-foreground border-primary' : 'text-muted-foreground border-border hover:bg-muted')}>
{b}
</button>
);
})}
</div>
{/* A live count, because a feed that is connected but silent looks
exactly like one that is broken until a number moves. */}
<p className="text-xs text-muted-foreground">
{pskrStatus?.running
? t('bo.feedUp', { n: pskrStatus.received ?? 0 })
: t('bo.feedDown')}
</p>
</div>
)}
</div>
{/* Self-spot. The interval only shows once it's on an interval for {/* Self-spot. The interval only shows once it's on an interval for
something switched off is just a question the operator can't act on. */} something switched off is just a question the operator can't act on. */}
<div className="border-t border-border/60 pt-3 space-y-2"> <div className="border-t border-border/60 pt-3 space-y-2">
+2 -2
View File
@@ -270,7 +270,7 @@ const en: Dict = {
'clu.muteWorkedHint': '(they stay in the list, just quiet — leaves the colour for what is left to do)', 'clu.muteWorkedHint': '(they stay in the list, just quiet — leaves the colour for what is left to do)',
'clu.slotHighlight': 'Colour the stations not worked on this band and mode', 'clu.slotHighlight': 'Colour the stations not worked on this band and mode',
'clu.slotHighlightHint': '(by callsign, whatever the entity status says)', 'clu.slotHighlightHint': '(by callsign, whatever the entity status says)',
'clu.workedSameSlot': 'Already worked only on the same slot', 'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.workedSameSlot': 'Already worked only on the same slot',
'clu.workedSameSlotHint': '— a spot shows "worked" only if you worked that call on the SAME band and mode, not just anywhere. Combines with digital-mode grouping (Settings → General): with it on, a call worked on 20m FT8 also counts as worked for a 20m FT4 spot; with it off, FT8 and FT4 are separate slots.', 'clu.workedSameSlotHint': '— a spot shows "worked" only if you worked that call on the SAME band and mode, not just anywhere. Combines with digital-mode grouping (Settings → General): with it on, a call worked on 20m FT8 also counts as worked for a 20m FT4 spot; with it off, FT8 and FT4 are separate slots.',
// Backup panel // Backup panel
'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each.', 'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each.',
@@ -690,7 +690,7 @@ const fr: Dict = {
'clu.muteWorkedHint': '(elles restent dans la liste, simplement discrètes — la couleur reste pour ce qui est à faire)', 'clu.muteWorkedHint': '(elles restent dans la liste, simplement discrètes — la couleur reste pour ce qui est à faire)',
'clu.slotHighlight': 'Colorer les stations non contactées sur cette bande et ce mode', 'clu.slotHighlight': 'Colorer les stations non contactées sur cette bande et ce mode',
'clu.slotHighlightHint': "(par indicatif, quel que soit le statut de l'entité)", 'clu.slotHighlightHint': "(par indicatif, quel que soit le statut de l'entité)",
'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot',
'clu.workedSameSlotHint': '— un spot n\'affiche « contacté » que si vous avez contacté cet indicatif sur la MÊME bande et le MÊME mode, pas juste n\'importe où. Se combine avec le groupage des modes numériques (Réglages → Général) : activé, un indicatif contacté en 20m FT8 compte aussi comme contacté pour un spot 20m FT4 ; désactivé, FT8 et FT4 sont des slots distincts.', 'clu.workedSameSlotHint': '— un spot n\'affiche « contacté » que si vous avez contacté cet indicatif sur la MÊME bande et le MÊME mode, pas juste n\'importe où. Se combine avec le groupage des modes numériques (Réglages → Général) : activé, un indicatif contacté en 20m FT8 compte aussi comme contacté pour un spot 20m FT4 ; désactivé, FT8 et FT4 sont des slots distincts.',
'bk.hintMysql': "À la fermeture (1×/jour) OpsLog sauvegarde le SQLite local (config) ET exporte le log MySQL partagé en ADIF — opslog-log-<date>.adi — pour protéger tes contacts même s'ils sont sur le serveur. La rotation garde les N derniers de chaque.", 'bk.hintMysql': "À la fermeture (1×/jour) OpsLog sauvegarde le SQLite local (config) ET exporte le log MySQL partagé en ADIF — opslog-log-<date>.adi — pour protéger tes contacts même s'ils sont sur le serveur. La rotation garde les N derniers de chaque.",
'bk.hint': "OpsLog peut copier la base SQLite dans un dossier de ton choix à la fermeture, une fois par jour. La rotation garde les N dernières copies et supprime les plus anciennes.", 'bk.hint': "OpsLog peut copier la base SQLite dans un dossier de ton choix à la fermeture, une fois par jour. La rotation garde les N dernières copies et supprime les plus anciennes.",
+7
View File
@@ -14,6 +14,7 @@ import {bandopen} from '../models';
import {cluster} from '../models'; import {cluster} from '../models';
import {extsvc} from '../models'; import {extsvc} from '../models';
import {powergenius} from '../models'; import {powergenius} from '../models';
import {pskr} from '../models';
import {spe} from '../models'; import {spe} from '../models';
import {solar} from '../models'; import {solar} from '../models';
import {tunergenius} from '../models'; import {tunergenius} from '../models';
@@ -387,6 +388,8 @@ export function GetAwards():Promise<Array<award.Result>>;
export function GetBackupSettings():Promise<main.BackupSettings>; export function GetBackupSettings():Promise<main.BackupSettings>;
export function GetBandOpenSettings():Promise<main.BandOpenSettings>;
export function GetBandOpenings():Promise<Array<bandopen.Opening>>; export function GetBandOpenings():Promise<Array<bandopen.Opening>>;
export function GetCATSettings():Promise<main.CATSettings>; export function GetCATSettings():Promise<main.CATSettings>;
@@ -465,6 +468,8 @@ export function GetPGXLStatus():Promise<powergenius.Status>;
export function GetPOTAToken():Promise<string>; export function GetPOTAToken():Promise<string>;
export function GetPSKReporterStatus():Promise<pskr.Status>;
export function GetPendingQSOs():Promise<Array<qso.QSO>>; export function GetPendingQSOs():Promise<Array<qso.QSO>>;
export function GetQSLDefaults():Promise<main.QSLDefaults>; export function GetQSLDefaults():Promise<main.QSLDefaults>;
@@ -873,6 +878,8 @@ export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>; export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
export function SaveBandOpenSettings(arg1:main.BandOpenSettings):Promise<void>;
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>; export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveCabrilloFile():Promise<string>; export function SaveCabrilloFile():Promise<string>;
+12
View File
@@ -718,6 +718,10 @@ export function GetBackupSettings() {
return window['go']['main']['App']['GetBackupSettings'](); return window['go']['main']['App']['GetBackupSettings']();
} }
export function GetBandOpenSettings() {
return window['go']['main']['App']['GetBandOpenSettings']();
}
export function GetBandOpenings() { export function GetBandOpenings() {
return window['go']['main']['App']['GetBandOpenings'](); return window['go']['main']['App']['GetBandOpenings']();
} }
@@ -874,6 +878,10 @@ export function GetPOTAToken() {
return window['go']['main']['App']['GetPOTAToken'](); return window['go']['main']['App']['GetPOTAToken']();
} }
export function GetPSKReporterStatus() {
return window['go']['main']['App']['GetPSKReporterStatus']();
}
export function GetPendingQSOs() { export function GetPendingQSOs() {
return window['go']['main']['App']['GetPendingQSOs'](); return window['go']['main']['App']['GetPendingQSOs']();
} }
@@ -1690,6 +1698,10 @@ export function SaveBackupSettings(arg1) {
return window['go']['main']['App']['SaveBackupSettings'](arg1); return window['go']['main']['App']['SaveBackupSettings'](arg1);
} }
export function SaveBandOpenSettings(arg1) {
return window['go']['main']['App']['SaveBandOpenSettings'](arg1);
}
export function SaveCATSettings(arg1) { export function SaveCATSettings(arg1) {
return window['go']['main']['App']['SaveCATSettings'](arg1); return window['go']['main']['App']['SaveCATSettings'](arg1);
} }
+62
View File
@@ -1971,6 +1971,22 @@ export namespace main {
this.default_folder = source["default_folder"]; this.default_folder = source["default_folder"];
} }
} }
export class BandOpenSettings {
enabled: boolean;
bands: string[];
available: string[];
static createFrom(source: any = {}) {
return new BandOpenSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.bands = source["bands"];
this.available = source["available"];
}
}
export class CATSettings { export class CATSettings {
enabled: boolean; enabled: boolean;
backend: string; backend: string;
@@ -3791,6 +3807,52 @@ export namespace profile {
} }
export namespace pskr {
export class Status {
running: boolean;
received: number;
// Go type: time
last_at: any;
last_err?: string;
broker: string;
bands: string[];
static createFrom(source: any = {}) {
return new Status(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.running = source["running"];
this.received = source["received"];
this.last_at = this.convertValues(source["last_at"], null);
this.last_err = source["last_err"];
this.broker = source["broker"];
this.bands = source["bands"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace qslcard { export namespace qslcard {
export class Bevel { export class Bevel {
+4 -2
View File
@@ -4,6 +4,7 @@ go 1.25.0
require ( require (
github.com/braheezy/shine-mp3 v0.1.0 github.com/braheezy/shine-mp3 v0.1.0
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/go-ole/go-ole v1.3.0 github.com/go-ole/go-ole v1.3.0
github.com/go-sql-driver/mysql v1.10.0 github.com/go-sql-driver/mysql v1.10.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
@@ -12,7 +13,7 @@ require (
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.11.0
github.com/wneessen/go-mail v0.7.3 github.com/wneessen/go-mail v0.7.3
go.bug.st/serial v1.7.1 go.bug.st/serial v1.7.1
golang.org/x/net v0.35.0 golang.org/x/net v0.44.0
golang.org/x/sys v0.45.0 golang.org/x/sys v0.45.0
golang.org/x/text v0.37.0 golang.org/x/text v0.37.0
modernc.org/sqlite v1.50.1 modernc.org/sqlite v1.50.1
@@ -44,7 +45,8 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect golang.org/x/crypto v0.42.0 // indirect
golang.org/x/sync v0.20.0 // indirect
modernc.org/libc v1.72.3 // indirect modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
+6 -4
View File
@@ -8,6 +8,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
@@ -84,13 +86,13 @@ github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk= github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc= go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc=
go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ= go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+230
View File
@@ -0,0 +1,230 @@
// Package pskr subscribes to PSK Reporter's MQTT feed and turns it into the
// spots the band-opening detector already eats.
//
// Why this exists at all: the detector was fed from the DX cluster and the RBN,
// and on VHF that is a few hundred skimmers, nearly all of them on HF. A 6 m
// opening carrying 869 stations reached OpsLog as a handful of spots, or none.
// PSK Reporter is every ordinary station running WSJT-X and reporting what it
// decodes — the difference is two orders of magnitude, not a threshold.
//
// The feed's shape happens to suit us exactly:
//
// topic pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/...
// payload {"f":50313000,"md":"FT8","rp":-12,"sc":"F4BPO","sl":"JN36",
// "rc":"OH5CX","rl":"KP30","b":"6m"}
//
// BOTH grids are in the message, so distance and bearing are arithmetic. No
// lookup, no DXCC-centre approximation, no extra network call — which is what
// made the cluster path's bearings coarse.
//
// Volume is the real design constraint. Six metres open is thousands of
// messages a minute, and OpsLog runs on some very old PCs. So: no history is
// kept here, nothing is persisted, each message is parsed and handed on or
// dropped, and the callback does the deciding.
package pskr
import (
"encoding/json"
"fmt"
"strings"
"sync"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// DefaultBroker is PSK Reporter's public MQTT endpoint, TLS.
const DefaultBroker = "tls://mqtt.pskreporter.info:1884"
// Bands offered. HF below 12 m is deliberately absent: an "opening" on 20 m is
// the normal state of the band and announcing it says nothing. These are the
// bands where an opening is an event worth interrupting an operator for.
var Bands = []string{"12m", "10m", "6m", "4m", "2m"}
// Spot is one decode, already reduced to what a detector needs.
type Spot struct {
Call string // the transmitting station
Band string
Mode string
Grid string // transmitter's grid, 4 characters
DistKm int // from the operator
Bearing int // degrees from the operator, short path
At time.Time
}
// Config is what the watcher needs to run.
type Config struct {
Broker string
Bands []string
// OpLat/OpLon are the operator's position: every spot is measured from it,
// so with no position there is nothing to measure and the watcher stays down.
OpLat, OpLon float64
// Geo turns two grids into distance and bearing. Injected rather than
// implemented here so it stays the SAME arithmetic the cluster path uses —
// two answers for one question is how a bearing quietly becomes wrong.
Geo func(grid string) (distKm int, bearing int, ok bool)
// OnSpot receives every accepted decode. Called from the MQTT goroutine, so
// it must not block: the broker's buffer is what pays for it if it does.
OnSpot func(Spot)
Logf func(string, ...any)
}
// Watcher owns the MQTT connection and its subscriptions.
type Watcher struct {
mu sync.Mutex
cfg Config
client mqtt.Client
running bool
// received counts accepted spots since start, for the status panel: a
// connection that is up but silent looks identical to one that is working
// until you can see a number moving.
received uint64
lastAt time.Time
lastErr string
}
func New(cfg Config) *Watcher {
if cfg.Broker == "" {
cfg.Broker = DefaultBroker
}
if len(cfg.Bands) == 0 {
cfg.Bands = Bands
}
if cfg.Logf == nil {
cfg.Logf = func(string, ...any) {}
}
return &Watcher{cfg: cfg}
}
// Start connects and subscribes. Safe to call when already running.
func (w *Watcher) Start() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.running {
return nil
}
if w.cfg.Geo == nil || w.cfg.OnSpot == nil {
return fmt.Errorf("pskr: Geo and OnSpot are required")
}
opts := mqtt.NewClientOptions().
AddBroker(w.cfg.Broker).
// A stable client id would collide with another OpsLog on the same
// account; the broker is anonymous, so uniqueness is ours to provide.
SetClientID(fmt.Sprintf("opslog-%d", time.Now().UnixNano())).
SetCleanSession(true).
SetAutoReconnect(true).
SetConnectRetry(true).
SetConnectRetryInterval(30 * time.Second).
SetConnectTimeout(15 * time.Second).
// No message is worth keeping if we cannot handle it now: an opening is
// a thing happening at this moment, and a queue of stale decodes would
// announce one that finished twenty minutes ago.
SetOrderMatters(false)
opts.OnConnect = func(c mqtt.Client) {
w.cfg.Logf("pskr: connected to %s", w.cfg.Broker)
for _, b := range w.cfg.Bands {
// Every mode, every pair of stations, on this band. That firehose IS
// the point: the detector's job is to find the shape in it.
topic := "pskr/filter/v2/" + b + "/#"
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil {
w.cfg.Logf("pskr: subscribe %s failed: %v", topic, tok.Error())
continue
}
w.cfg.Logf("pskr: watching %s", topic)
}
}
opts.OnConnectionLost = func(_ mqtt.Client, err error) {
w.mu.Lock()
w.lastErr = err.Error()
w.mu.Unlock()
w.cfg.Logf("pskr: connection lost: %v (will retry)", err)
}
c := mqtt.NewClient(opts)
// Deliberately NOT waiting on the connect token: the broker may be slow or
// unreachable and startup must not hang on a feature that is decoration.
// ConnectRetry brings it up in the background when it can.
c.Connect()
w.client = c
w.running = true
return nil
}
// Stop disconnects. Safe to call when already stopped.
func (w *Watcher) Stop() {
w.mu.Lock()
c, running := w.client, w.running
w.client, w.running = nil, false
w.mu.Unlock()
if running && c != nil {
c.Disconnect(250)
}
}
// wire is the payload as PSK Reporter sends it — short keys, no nesting.
type wire struct {
Freq int64 `json:"f"`
Mode string `json:"md"`
SNR int `json:"rp"`
TxCall string `json:"sc"`
TxGrid string `json:"sl"`
RxCall string `json:"rc"`
RxGrid string `json:"rl"`
Band string `json:"b"`
}
func (w *Watcher) handle(_ mqtt.Client, m mqtt.Message) {
var p wire
if err := json.Unmarshal(m.Payload(), &p); err != nil {
return // malformed payloads are not worth a log line at this rate
}
call := strings.ToUpper(strings.TrimSpace(p.TxCall))
grid := strings.ToUpper(strings.TrimSpace(p.TxGrid))
if call == "" || len(grid) < 4 {
return
}
// The TRANSMITTER is the station on the air; the receiver is whoever
// happened to be listening. An opening is described by where the signals are
// coming from, so it is the transmitter's grid that is measured.
dist, brg, ok := w.cfg.Geo(grid[:4])
if !ok {
return
}
s := Spot{
Call: call, Band: strings.ToLower(strings.TrimSpace(p.Band)),
Mode: strings.ToUpper(strings.TrimSpace(p.Mode)), Grid: grid[:4],
DistKm: dist, Bearing: brg,
// Stamped on receipt: the broker's own timestamps vary between payload
// versions, and the window this feeds is measured in minutes.
At: time.Now(),
}
w.mu.Lock()
w.received++
w.lastAt = s.At
w.mu.Unlock()
w.cfg.OnSpot(s)
}
// Status is the snapshot the settings panel shows.
type Status struct {
Running bool `json:"running"`
Received uint64 `json:"received"`
LastAt time.Time `json:"last_at"`
LastErr string `json:"last_err,omitempty"`
Broker string `json:"broker"`
Bands []string `json:"bands"`
}
func (w *Watcher) Status() Status {
w.mu.Lock()
defer w.mu.Unlock()
st := Status{
Running: w.running, Received: w.received, LastAt: w.lastAt,
LastErr: w.lastErr, Broker: w.cfg.Broker,
}
st.Bands = append(st.Bands, w.cfg.Bands...)
return st
}