diff --git a/app.go b/app.go index a8012f7..e958077 100644 --- a/app.go +++ b/app.go @@ -49,6 +49,7 @@ import ( "hamlog/internal/pota" "hamlog/internal/powergenius" "hamlog/internal/profile" + "hamlog/internal/pskr" "hamlog/internal/qslcard" "hamlog/internal/qso" "hamlog/internal/relaydev" @@ -609,6 +610,10 @@ type App struct { // air now, not a database. decodeGrids map[string]string 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. // Held in memory only — a restart legitimately re-announces the station. selfSpotMu sync.Mutex @@ -1399,6 +1404,9 @@ func (a *App) startup(ctx context.Context) { go a.sendTelemetryHeartbeat() go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL 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) } diff --git a/bandopen_sources.go b/bandopen_sources.go new file mode 100644 index 0000000..3f5feb0 --- /dev/null +++ b/bandopen_sources.go @@ -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 +} diff --git a/bandopening.go b/bandopening.go index 7c86664..5ca00c4 100644 --- a/bandopening.go +++ b/bandopening.go @@ -49,10 +49,15 @@ func (a *App) detectBandOpening(s cluster.Spot) { } } a.bandOpen.mu.Unlock() - if op == nil { - return + if op != nil { + 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)", op.Band, op.Calls, op.MedianKm, op.Sector(), map[bool]string{true: "", false: " — UNUSUAL for the season"}[op.InSeason], diff --git a/changelog.json b/changelog.json index 951e331..711b264 100644 --- a/changelog.json +++ b/changelog.json @@ -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.", "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.", - "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": [ "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 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 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." ] }, { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 0333c70..cb68565 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -52,6 +52,7 @@ import { GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile, GetRelayAuto, SaveRelayAuto, GetStationDevices, GetAwardDefs, GetTrackedAwards, SaveTrackedAwards, + GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, } from '../../wailsjs/go/main/App'; import type { profile as profileModels } from '../../wailsjs/go/models'; 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 // the "1" landed and the field would fight the operator. 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({ enabled: false, bands: [], available: [] }); + const [pskrStatus, setPskrStatus] = useState(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 [selfSpotText, setSelfSpotText] = useState(String(SELF_SPOT_MIN_MIN)); const [clusterStatuses, setClusterStatuses] = useState([]); @@ -4148,6 +4169,44 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan things set up once. A preferences dialog you reopen every ten minutes 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. */} +
+ + {bandOpen.enabled && ( +
+
+ {(bandOpen.available ?? []).map((b: string) => { + const on = (bandOpen.bands ?? []).includes(b); + return ( + + ); + })} +
+ {/* A live count, because a feed that is connected but silent looks + exactly like one that is broken until a number moves. */} +

+ {pskrStatus?.running + ? t('bo.feedUp', { n: pskrStatus.received ?? 0 }) + : t('bo.feedDown')} +

+
+ )} +
+ {/* 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. */}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 03628a3..e3fbc8f 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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.slotHighlight': 'Colour the stations not worked on this band and mode', '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.', // Backup panel 'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-.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.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.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.', 'bk.hintMysql': "À la fermeture (1×/jour) OpsLog sauvegarde le SQLite local (config) ET exporte le log MySQL partagé en ADIF — opslog-log-.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.", diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index c51a7eb..d66ae51 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -14,6 +14,7 @@ import {bandopen} from '../models'; import {cluster} from '../models'; import {extsvc} from '../models'; import {powergenius} from '../models'; +import {pskr} from '../models'; import {spe} from '../models'; import {solar} from '../models'; import {tunergenius} from '../models'; @@ -387,6 +388,8 @@ export function GetAwards():Promise>; export function GetBackupSettings():Promise; +export function GetBandOpenSettings():Promise; + export function GetBandOpenings():Promise>; export function GetCATSettings():Promise; @@ -465,6 +468,8 @@ export function GetPGXLStatus():Promise; export function GetPOTAToken():Promise; +export function GetPSKReporterStatus():Promise; + export function GetPendingQSOs():Promise>; export function GetQSLDefaults():Promise; @@ -873,6 +878,8 @@ export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise; export function SaveBackupSettings(arg1:main.BackupSettings):Promise; +export function SaveBandOpenSettings(arg1:main.BandOpenSettings):Promise; + export function SaveCATSettings(arg1:main.CATSettings):Promise; export function SaveCabrilloFile():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 8b14a15..55215a6 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -718,6 +718,10 @@ export function GetBackupSettings() { return window['go']['main']['App']['GetBackupSettings'](); } +export function GetBandOpenSettings() { + return window['go']['main']['App']['GetBandOpenSettings'](); +} + export function GetBandOpenings() { return window['go']['main']['App']['GetBandOpenings'](); } @@ -874,6 +878,10 @@ export function GetPOTAToken() { return window['go']['main']['App']['GetPOTAToken'](); } +export function GetPSKReporterStatus() { + return window['go']['main']['App']['GetPSKReporterStatus'](); +} + export function GetPendingQSOs() { return window['go']['main']['App']['GetPendingQSOs'](); } @@ -1690,6 +1698,10 @@ export function SaveBackupSettings(arg1) { return window['go']['main']['App']['SaveBackupSettings'](arg1); } +export function SaveBandOpenSettings(arg1) { + return window['go']['main']['App']['SaveBandOpenSettings'](arg1); +} + export function SaveCATSettings(arg1) { return window['go']['main']['App']['SaveCATSettings'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 8fb654a..58f8b0d 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1971,6 +1971,22 @@ export namespace main { 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 { enabled: boolean; 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 class Bevel { diff --git a/go.mod b/go.mod index 273e0aa..1a37adf 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( 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-sql-driver/mysql v1.10.0 github.com/gorilla/websocket v1.5.3 @@ -12,7 +13,7 @@ require ( github.com/wailsapp/wails/v2 v2.11.0 github.com/wneessen/go-mail v0.7.3 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/text v0.37.0 modernc.org/sqlite v1.50.1 @@ -44,7 +45,8 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // 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/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index d98aaf6..6705811 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 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= go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc= 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.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +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/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= 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.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +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/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/pskr/pskr.go b/internal/pskr/pskr.go new file mode 100644 index 0000000..a5bfb4f --- /dev/null +++ b/internal/pskr/pskr.go @@ -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/////... +// 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 +}