feat: Super Check Partial + N+1 helper; fix Flex binding to SmartSDR CAT; telemetry callsign wait
- SCP/N+1: new internal/scp downloads the community MASTER.SCP master list and a docked two-column widget shows, as you type a call, the known calls containing it (Partial) and the calls one edit away (N+1) — click to fix a busted call. Opt-in in Settings → General; top-bar toggle; queried debounced on callsign input. - Flex: OpsLog's GUI-client detection was too loose and could bind to "SmartSDR CAT" (or DAX) — both carry "smartsdr" in the program name — instead of the real GUI client, making SmartSDR CAT drop and reconnect in a loop while OpsLog was open. Now it binds only to a real SmartSDR/Maestro GUI client (e.g. a FLEX-8600M's integrated screen) and excludes cat/dax; dropped the risky empty-program fallback. - Telemetry: on a fresh install the callsign isn't set at launch, so the once-a-day heartbeat recorded the machine UUID. Now it waits (~10 min) for the operator to enter their callsign before sending, falling back to the UUID only if none appears.
This commit is contained in:
@@ -53,6 +53,7 @@ import (
|
||||
"hamlog/internal/rotator/gs232"
|
||||
"hamlog/internal/rotator/pst"
|
||||
"hamlog/internal/rotgenius"
|
||||
"hamlog/internal/scp"
|
||||
"hamlog/internal/settings"
|
||||
"hamlog/internal/solar"
|
||||
"hamlog/internal/spe"
|
||||
@@ -234,6 +235,8 @@ const (
|
||||
|
||||
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
||||
|
||||
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
|
||||
|
||||
keyBackupEnabled = "backup.enabled"
|
||||
keyBackupFolder = "backup.folder"
|
||||
keyBackupRotation = "backup.rotation"
|
||||
@@ -493,6 +496,7 @@ type App struct {
|
||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
||||
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
|
||||
scp *scp.Manager // Super Check Partial / N+1 callsign master list
|
||||
|
||||
// NET Control: persistent net definitions/rosters (global JSON) + the live
|
||||
// session (in-memory only — active stations currently in QSO).
|
||||
@@ -985,6 +989,25 @@ func (a *App) startup(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Super Check Partial / N+1: load the cached MASTER.SCP; when the feature is
|
||||
// enabled, auto-(re)download it if missing or older than a week.
|
||||
a.scp = scp.NewManager(dataDir)
|
||||
go func() {
|
||||
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
|
||||
return
|
||||
}
|
||||
if a.scp.Count() == 0 || time.Since(a.scp.Updated()) > 7*24*time.Hour {
|
||||
if n, err := a.scp.Download(context.Background()); err == nil {
|
||||
applog.Printf("scp: auto-downloaded %d callsigns", n)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "scp:updated")
|
||||
}
|
||||
} else {
|
||||
applog.Printf("scp: auto-download failed: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
_ = a.clublog.EnsureLoaded()
|
||||
// Auto-refresh a missing/stale country file (ClubLog adds date-ranged
|
||||
@@ -2311,6 +2334,76 @@ func (a *App) DownloadLoTWUsers() (int, error) {
|
||||
return a.lotwUsers.Download(a.ctx)
|
||||
}
|
||||
|
||||
// ── Super Check Partial / N+1 ────────────────────────────────────────────────
|
||||
|
||||
// ScpStatus is the loaded-list summary for Settings + the widget gate.
|
||||
type ScpStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Count int `json:"count"`
|
||||
Updated string `json:"updated,omitempty"` // RFC3339, empty if never
|
||||
}
|
||||
|
||||
// GetScpStatus returns whether SCP is enabled and how many calls are loaded.
|
||||
func (a *App) GetScpStatus() ScpStatus {
|
||||
st := ScpStatus{}
|
||||
if a.settings != nil {
|
||||
v, _ := a.settings.Get(a.ctx, keyScpEnabled)
|
||||
st.Enabled = v == "1"
|
||||
}
|
||||
if a.scp != nil {
|
||||
st.Count = a.scp.Count()
|
||||
if u := a.scp.Updated(); !u.IsZero() {
|
||||
st.Updated = u.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// SetScpEnabled turns Super Check Partial on/off. Enabling triggers a background
|
||||
// download when the list is missing or stale.
|
||||
func (a *App) SetScpEnabled(on bool) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyScpEnabled, boolStr(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
if on && a.scp != nil && (a.scp.Count() == 0 || time.Since(a.scp.Updated()) > 7*24*time.Hour) {
|
||||
go func() {
|
||||
if n, err := a.scp.Download(context.Background()); err == nil {
|
||||
applog.Printf("scp: downloaded %d callsigns", n)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "scp:updated")
|
||||
}
|
||||
} else {
|
||||
applog.Printf("scp: download failed: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadScp fetches the MASTER.SCP master file and caches it. Returns the
|
||||
// number of callsigns loaded.
|
||||
func (a *App) DownloadScp() (int, error) {
|
||||
if a.scp == nil {
|
||||
return 0, fmt.Errorf("not initialized")
|
||||
}
|
||||
return a.scp.Download(a.ctx)
|
||||
}
|
||||
|
||||
// ScpLookup returns the Super Check Partial (substring) and N+1 (one-edit)
|
||||
// suggestions for a typed fragment. Empty when SCP is disabled.
|
||||
func (a *App) ScpLookup(fragment string) scp.Result {
|
||||
if a.scp == nil || a.settings == nil {
|
||||
return scp.Result{}
|
||||
}
|
||||
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
|
||||
return scp.Result{}
|
||||
}
|
||||
return a.scp.Lookup(fragment, 60)
|
||||
}
|
||||
|
||||
// StationInfoComputed bundles the data we resolve live from the
|
||||
// profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone,
|
||||
// lat/lon. Used by the Settings UI to show the "what will be stamped on
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
"version": "0.21.2",
|
||||
"date": "2026-07-25",
|
||||
"en": [
|
||||
"Fixed OpsLog disrupting another Flex client: it could bind to 'SmartSDR CAT' (or DAX) instead of the real GUI client, which made SmartSDR CAT keep disconnecting/reconnecting from the radio while OpsLog was open. OpsLog now only binds to the actual SmartSDR/Maestro GUI client (e.g. a FlexRadio 'M' integrated screen).",
|
||||
"New: Super Check Partial + N+1 callsign helper (like N1MM/DXLog). Enable it in Settings → General to download the community MASTER.SCP list; a docked two-column widget then shows, as you type a call, the known calls that contain it (Partial) and the calls one character away (N+1) — click one to fix a busted call.",
|
||||
"When a callsign isn't found on QRZ/HamQTH (or you don't use a lookup service), the name, QTH, locator and address are recovered from the last time you worked that station — and the precise locator from that QSO is used instead of the coarse cty.dat country centroid. A real QRZ/HamQTH hit still wins."
|
||||
],
|
||||
"fr": [
|
||||
"Correction : OpsLog pouvait perturber un autre client Flex — il pouvait se « binder » sur « SmartSDR CAT » (ou DAX) au lieu du vrai client GUI, ce qui faisait décrocher/reconnecter SmartSDR CAT de la radio en boucle tant qu'OpsLog était ouvert. OpsLog ne se binde désormais qu'au vrai client GUI SmartSDR/Maestro (ex. l'écran intégré d'un FlexRadio « M »).",
|
||||
"Nouveau : assistant indicatifs Super Check Partial + N+1 (façon N1MM/DXLog). Active-le dans Réglages → Général pour télécharger la liste communautaire MASTER.SCP ; un widget ancré en 2 colonnes affiche alors, pendant que tu tapes, les indicatifs connus qui contiennent ta saisie (Partiel) et ceux à une lettre près (N+1) — clique pour corriger un call busté.",
|
||||
"Quand un indicatif est introuvable sur QRZ/HamQTH (ou si tu n'utilises pas de service de lookup), le nom, le QTH, le locator et l'adresse sont récupérés du dernier QSO avec cette station — et c'est le locator précis de ce QSO qui est utilisé, pas le centroïde du pays de cty.dat. Un vrai résultat QRZ/HamQTH reste prioritaire."
|
||||
]
|
||||
},
|
||||
|
||||
+59
-2
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
GetUltrabeamStatus, SetUltrabeamDirection,
|
||||
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
||||
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
|
||||
GetScpStatus, ScpLookup,
|
||||
OpenExternalURL,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||
@@ -71,6 +72,7 @@ import { FlexPanel } from '@/components/FlexPanel';
|
||||
import { IcomPanel } from '@/components/IcomPanel';
|
||||
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
||||
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
|
||||
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
|
||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||
import { StatsPanel } from '@/components/StatsPanel';
|
||||
@@ -473,6 +475,10 @@ export default function App() {
|
||||
const [agEnabled, setAgEnabled] = useState(false);
|
||||
const [tgStatus, setTgStatus] = useState<TGStatus>({ connected: false });
|
||||
const [tgEnabled, setTgEnabled] = useState(false);
|
||||
// Super Check Partial / N+1
|
||||
const [scpEnabled, setScpEnabled] = useState(false);
|
||||
const [scpCount, setScpCount] = useState(0);
|
||||
const [scpResult, setScpResult] = useState<ScpResult>({});
|
||||
// Per-port optimistic selection that the status poll must not revert until the
|
||||
// device confirms it (or it expires) — otherwise a stale poll right after a
|
||||
// click reverts the UI and the click looks like it did nothing.
|
||||
@@ -1555,6 +1561,7 @@ export default function App() {
|
||||
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
||||
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
|
||||
const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0');
|
||||
const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '0');
|
||||
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||
|
||||
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
|
||||
@@ -1786,6 +1793,31 @@ export default function App() {
|
||||
TunerGeniusActivate(ch).catch((e) => setError(String(e?.message ?? e)));
|
||||
};
|
||||
|
||||
// Super Check Partial: poll the enabled flag + list size so the widget shows up
|
||||
// once the operator turns SCP on and the master list has downloaded.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try { const s: any = await GetScpStatus(); if (alive && s) { setScpEnabled(!!s.enabled); setScpCount(s.count || 0); } } catch {}
|
||||
};
|
||||
load();
|
||||
const id = window.setInterval(load, 3000);
|
||||
const off = EventsOn('scp:updated', load);
|
||||
return () => { alive = false; window.clearInterval(id); off(); };
|
||||
}, []);
|
||||
// Query SCP/N+1 as the callsign changes (debounced). Skipped when disabled or
|
||||
// the widget is hidden, so we don't hit the backend for nothing.
|
||||
useEffect(() => {
|
||||
if (!scpEnabled || !showScp) { setScpResult({}); return; }
|
||||
const c = callsign.trim();
|
||||
if (c.length < 2) { setScpResult({}); return; }
|
||||
let alive = true;
|
||||
const id = window.setTimeout(async () => {
|
||||
try { const r: any = await ScpLookup(c); if (alive && r) setScpResult(r as ScpResult); } catch {}
|
||||
}, 120);
|
||||
return () => { alive = false; window.clearTimeout(id); };
|
||||
}, [callsign, scpEnabled, showScp]);
|
||||
|
||||
// RX band auto-follows the TX band (only differs for cross-band work).
|
||||
useEffect(() => { setBandRx(band); }, [band]);
|
||||
|
||||
@@ -4330,6 +4362,20 @@ export default function App() {
|
||||
{showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
|
||||
</button>
|
||||
)}
|
||||
{scpEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { const v = !showScp; setShowScp(v); writeUiPref('opslog.showScp', v ? '1' : '0'); }}
|
||||
title={showScp ? 'Super Check Partial — shown · click to hide' : 'Super Check Partial · click to show'}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||
showScp ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||
: 'border-border text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<SpellCheck className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
{chatAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -4801,7 +4847,7 @@ export default function App() {
|
||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||
otherwise it shows the QRZ profile photo. */}
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
||||
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
||||
// the entry strip and each widget fills that height, scrolling inside.
|
||||
@@ -4900,6 +4946,17 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showScp && scpEnabled && (
|
||||
<div className="w-[240px] shrink-0 min-h-0">
|
||||
<ScpPanel
|
||||
result={scpResult}
|
||||
currentCall={callsign}
|
||||
count={scpCount}
|
||||
onPick={(c) => onCallsignInput(c, { force: true })}
|
||||
onClose={() => { setShowScp(false); writeUiPref('opslog.showScp', '0'); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{dvkEnabled && (
|
||||
<div className="w-[320px] shrink-0 min-h-0">
|
||||
<DvkPanel
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { SpellCheck, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type ScpResult = { partial?: string[]; nplus1?: string[] };
|
||||
|
||||
// ScpPanel — Super Check Partial + N+1 callsign helper, split in two columns.
|
||||
// Left: master calls that CONTAIN what you've typed (spot/correct a call). Right:
|
||||
// calls one edit away (busted-call check). Clicking a suggestion fills the entry.
|
||||
export function ScpPanel({ result, currentCall, count, onPick, onClose }: {
|
||||
result: ScpResult;
|
||||
currentCall: string;
|
||||
count: number; // master-list size (0 = list not downloaded yet)
|
||||
onPick: (call: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const cur = (currentCall || '').trim().toUpperCase();
|
||||
const partial = result.partial ?? [];
|
||||
const nplus1 = result.nplus1 ?? [];
|
||||
|
||||
const Col = ({ title, tone, calls, empty }: { title: string; tone: string; calls: string[]; empty: string }) => (
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<div className={cn('text-[10px] font-bold uppercase tracking-wider px-1.5 py-1 border-b border-border/50', tone)}>{title}</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-1 space-y-0.5">
|
||||
{calls.length === 0 ? (
|
||||
<div className="text-[10px] text-muted-foreground/70 italic px-1 py-1">{empty}</div>
|
||||
) : calls.map((c) => (
|
||||
<button key={c} type="button" onClick={() => onPick(c)}
|
||||
title={t('scp.fill', { call: c })}
|
||||
className={cn('w-full text-left rounded px-1.5 py-0.5 font-mono text-xs transition-colors',
|
||||
c === cur ? 'bg-success/20 text-success font-bold'
|
||||
: 'hover:bg-primary/15 text-foreground/90')}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||
<SpellCheck className={cn('size-4', count > 0 ? 'text-primary' : 'text-muted-foreground')} />
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">{t('scp.title')}</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors" title={t('scp.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{count === 0 ? (
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center text-[11px] text-muted-foreground italic text-center px-3">
|
||||
{t('scp.noList')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex divide-x divide-border/60">
|
||||
<Col title={t('scp.partial')} tone="text-primary" calls={partial} empty={t('scp.typeMore')} />
|
||||
<Col title="N+1" tone="text-warning" calls={nplus1} empty={t('scp.none')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||
GetScpStatus, SetScpEnabled, DownloadScp,
|
||||
DownloadULSCounties, ULSStatus, BackfillUSCounties,
|
||||
ComputeStationInfo,
|
||||
GetUIPref, SetUIPref,
|
||||
@@ -1253,6 +1254,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
|
||||
finally { setLotwUsersBusy(false); }
|
||||
};
|
||||
// Super Check Partial / N+1 callsign helper: enabled flag + master-list status.
|
||||
const [scp, setScp] = useState<{ enabled: boolean; count: number; updated?: string }>({ enabled: false, count: 0 });
|
||||
const [scpBusy, setScpBusy] = useState(false);
|
||||
useEffect(() => { GetScpStatus().then((s) => setScp(s as any)).catch(() => {}); }, []);
|
||||
const toggleScp = async (on: boolean) => {
|
||||
setScp((s) => ({ ...s, enabled: on }));
|
||||
setScpBusy(true);
|
||||
try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
||||
finally { setScpBusy(false); }
|
||||
};
|
||||
const downloadScp = async () => {
|
||||
setScpBusy(true);
|
||||
try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
||||
finally { setScpBusy(false); }
|
||||
};
|
||||
// US Counties (offline FCC ULS) — download progress arrives via events.
|
||||
const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string }>({ count: 0 });
|
||||
const [ulsBusy, setUlsBusy] = useState(false);
|
||||
@@ -4761,6 +4777,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Checkbox checked={showBeamMap} onCheckedChange={(c) => { const v = !!c; setShowBeamMap(v); writeUiPref('opslog.showBeamOnMap', v ? '1' : '0'); }} />
|
||||
{t('gen.showBeam')}
|
||||
</label>
|
||||
|
||||
{/* Super Check Partial / N+1 — downloads the community MASTER.SCP list and
|
||||
shows a two-column callsign helper (partial matches + one-edit calls). */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
|
||||
{t('scp.enable')}
|
||||
</label>
|
||||
<p className="text-[11px] text-muted-foreground">{t('scp.settingsHint')}</p>
|
||||
{scp.enabled && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
|
||||
<ArrowDown className="size-3.5" /> {scpBusy ? t('scp.downloading') : t('scp.download')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{scp.count > 0
|
||||
? t('scp.loaded', { n: scp.count.toLocaleString(), date: scp.updated ? new Date(scp.updated).toLocaleDateString() : '?' })
|
||||
: t('scp.notLoaded')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} />
|
||||
{t('gen.startEqEnd')} <span className="text-xs text-muted-foreground">{t('gen.startEqEndHint')}</span>
|
||||
|
||||
@@ -283,6 +283,7 @@ const en: Dict = {
|
||||
'es.potaHint': 'Update your QSOs with the park reference from your pota.app hunter log. Paste your session token: log in at pota.app, open the browser DevTools → Network tab, click any api.pota.app request, and copy the full Authorization header value. The token expires after a while — re-copy it if the sync fails.', 'es.sessionToken': 'Session token', 'es.saving': 'Saving…', 'es.saveToken': 'Save token', 'es.potaThen': 'Then run the sync from the QSL Manager tab → service POTA hunter log (you can see and fix unmatched QSOs there).',
|
||||
'es.soon': 'soon', 'es.comingSoon': '{name} — coming soon', 'es.notWired': "This external service isn't wired up yet.",
|
||||
'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 1–4 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.',
|
||||
'scp.title': 'Super Check Partial', 'scp.partial': 'Partial', 'scp.close': 'Close', 'scp.fill': 'Fill {call}', 'scp.none': 'No match', 'scp.typeMore': 'Type a call…', 'scp.noList': 'Download the SCP list in Settings → General.', 'scp.enable': 'Super Check Partial / N+1 callsign helper', 'scp.settingsHint': 'Downloads the community MASTER.SCP master list and shows a two-column widget as you type a call: Partial (known calls containing what you typed) and N+1 (calls one character away) — to spot and fix a busted call.', 'scp.download': 'Update SCP list', 'scp.downloading': 'Downloading…', 'scp.loaded': '{n} calls loaded · updated {date}', 'scp.notLoaded': 'Not downloaded yet.',
|
||||
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
|
||||
// Profiles panel
|
||||
'prof.deleteConfirm': 'Delete profile "{name}"? All its settings will be lost.', 'prof.dupPrompt': 'Name for the new profile (copy of "{name}"):', 'prof.dupSuffix': '{name} Copy', 'prof.newPrompt': 'Name for the new profile:', 'prof.newDefault': 'New profile',
|
||||
@@ -648,6 +649,7 @@ const fr: Dict = {
|
||||
'es.potaHint': "Met à jour tes QSO avec la référence du parc depuis ton carnet chasseur pota.app. Colle ton jeton de session : connecte-toi sur pota.app, ouvre les DevTools du navigateur → onglet Network, clique sur une requête api.pota.app, et copie toute la valeur de l'en-tête Authorization. Le jeton expire au bout d'un moment — recopie-le si la synchro échoue.", 'es.sessionToken': 'Jeton de session', 'es.saving': 'Enregistrement…', 'es.saveToken': 'Enregistrer le jeton', 'es.potaThen': "Puis lance la synchro depuis l'onglet Gestionnaire QSL → service POTA hunter log (tu peux y voir et corriger les QSO non appariés).",
|
||||
'es.soon': 'bientôt', 'es.comingSoon': '{name} — bientôt', 'es.notWired': "Ce service externe n'est pas encore branché.",
|
||||
'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 1–4 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce n’est pas fait.',
|
||||
'scp.title': 'Super Check Partial', 'scp.partial': 'Partiel', 'scp.close': 'Fermer', 'scp.fill': 'Remplir {call}', 'scp.none': 'Aucun', 'scp.typeMore': 'Tape un indicatif…', 'scp.noList': 'Télécharge la liste SCP dans Réglages → Général.', 'scp.enable': 'Assistant indicatifs Super Check Partial / N+1', 'scp.settingsHint': "Télécharge la liste communautaire MASTER.SCP et affiche un widget en 2 colonnes pendant que tu tapes un indicatif : Partiel (indicatifs connus contenant ce que tu tapes) et N+1 (indicatifs à une lettre près) — pour repérer et corriger un call busté.", 'scp.download': 'Mettre à jour la liste SCP', 'scp.downloading': 'Téléchargement…', 'scp.loaded': '{n} indicatifs chargés · maj {date}', 'scp.notLoaded': 'Pas encore téléchargée.',
|
||||
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
|
||||
'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil',
|
||||
'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil',
|
||||
|
||||
Vendored
+9
@@ -24,6 +24,7 @@ import {udp} from '../models';
|
||||
import {lotwusers} from '../models';
|
||||
import {lookup} from '../models';
|
||||
import {netctl} from '../models';
|
||||
import {scp} from '../models';
|
||||
|
||||
export function ACOMSetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
@@ -173,6 +174,8 @@ export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Prom
|
||||
|
||||
export function DownloadLoTWUsers():Promise<number>;
|
||||
|
||||
export function DownloadScp():Promise<number>;
|
||||
|
||||
export function DownloadULSCounties():Promise<void>;
|
||||
|
||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||
@@ -459,6 +462,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||
|
||||
export function GetSPEStatus():Promise<spe.Status>;
|
||||
|
||||
export function GetScpStatus():Promise<main.ScpStatus>;
|
||||
|
||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||
|
||||
export function GetSlotStats():Promise<qso.SlotStats>;
|
||||
@@ -855,6 +860,8 @@ export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>
|
||||
|
||||
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
||||
|
||||
export function ScpLookup(arg1:string):Promise<scp.Result>;
|
||||
|
||||
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
||||
|
||||
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
|
||||
@@ -889,6 +896,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function SetPassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
@@ -298,6 +298,10 @@ export function DownloadLoTWUsers() {
|
||||
return window['go']['main']['App']['DownloadLoTWUsers']();
|
||||
}
|
||||
|
||||
export function DownloadScp() {
|
||||
return window['go']['main']['App']['DownloadScp']();
|
||||
}
|
||||
|
||||
export function DownloadULSCounties() {
|
||||
return window['go']['main']['App']['DownloadULSCounties']();
|
||||
}
|
||||
@@ -870,6 +874,10 @@ export function GetSPEStatus() {
|
||||
return window['go']['main']['App']['GetSPEStatus']();
|
||||
}
|
||||
|
||||
export function GetScpStatus() {
|
||||
return window['go']['main']['App']['GetScpStatus']();
|
||||
}
|
||||
|
||||
export function GetSecretStatus() {
|
||||
return window['go']['main']['App']['GetSecretStatus']();
|
||||
}
|
||||
@@ -1662,6 +1670,10 @@ export function SaveWinkeyerSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
|
||||
}
|
||||
|
||||
export function ScpLookup(arg1) {
|
||||
return window['go']['main']['App']['ScpLookup'](arg1);
|
||||
}
|
||||
|
||||
export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
@@ -1730,6 +1742,10 @@ export function SetPassphrase(arg1) {
|
||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||
}
|
||||
|
||||
export function SetScpEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetTelemetryEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
|
||||
}
|
||||
|
||||
@@ -2720,6 +2720,22 @@ export namespace main {
|
||||
this.com_port = source["com_port"];
|
||||
}
|
||||
}
|
||||
export class ScpStatus {
|
||||
enabled: boolean;
|
||||
count: number;
|
||||
updated?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ScpStatus(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.count = source["count"];
|
||||
this.updated = source["updated"];
|
||||
}
|
||||
}
|
||||
export class SecretStatus {
|
||||
has_passphrase: boolean;
|
||||
unlocked: boolean;
|
||||
@@ -4323,6 +4339,25 @@ export namespace qso {
|
||||
|
||||
}
|
||||
|
||||
export namespace scp {
|
||||
|
||||
export class Result {
|
||||
partial: string[];
|
||||
nplus1: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Result(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.partial = source["partial"];
|
||||
this.nplus1 = source["nplus1"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace solar {
|
||||
|
||||
export class Data {
|
||||
|
||||
@@ -518,7 +518,14 @@ func (f *Flex) handleStatus(payload string) {
|
||||
alreadyBound := f.boundClientID != ""
|
||||
f.mu.Unlock()
|
||||
lp := strings.ToLower(program)
|
||||
isGUI := program == "" || strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro")
|
||||
// The real GUI client is SmartSDR (Windows) or Maestro. Its non-GUI
|
||||
// helpers "SmartSDR CAT" and "SmartSDR DAX" also carry "smartsdr" in the
|
||||
// name, so exclude them — and require an explicit GUI name (dropping the
|
||||
// old program=="" fallback that could match CAT before its program field
|
||||
// arrived). Binding to CAT/DAX is invalid and the radio was seen to drop
|
||||
// SmartSDR CAT (connect/disconnect loop) when a logger did this.
|
||||
isGUI := (strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro")) &&
|
||||
!strings.Contains(lp, "cat") && !strings.Contains(lp, "dax")
|
||||
if !disconnected && clientID != "" && !alreadyBound && isGUI {
|
||||
f.mu.Lock()
|
||||
f.boundClientID = clientID
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// Package scp provides Super Check Partial (SCP) and N+1 callsign suggestions
|
||||
// from the community MASTER.SCP master file (supercheckpartial.com) — the same
|
||||
// aid contest loggers (N1MM, DXLog, Log4OM) show to catch/correct a busted call.
|
||||
//
|
||||
// - Partial (SCP): every master call that CONTAINS what you've typed, so a
|
||||
// mistyped or half-copied call surfaces the real ones.
|
||||
// - N+1: master calls exactly one edit away (one char substituted, added or
|
||||
// removed) from the full call you typed — the classic "did I bust it?" check.
|
||||
//
|
||||
// The list is downloaded once and cached on disk so it survives restarts.
|
||||
package scp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// masterURL is the community Super Check Partial master file: one callsign per
|
||||
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
|
||||
const masterURL = "https://www.supercheckpartial.com/MASTER.SCP"
|
||||
|
||||
const cacheFile = "MASTER.SCP"
|
||||
|
||||
// Result is the two suggestion lists for a typed fragment.
|
||||
type Result struct {
|
||||
Partial []string `json:"partial"` // master calls containing the fragment
|
||||
NPlus1 []string `json:"nplus1"` // master calls one edit away from the full call
|
||||
}
|
||||
|
||||
// Manager holds the parsed call list + cache location.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
calls []string // UPPER, de-duplicated, sorted
|
||||
updated time.Time // when the cache was last refreshed
|
||||
dir string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewManager loads any on-disk cache and returns a ready manager.
|
||||
func NewManager(dataDir string) *Manager {
|
||||
m := &Manager{
|
||||
dir: dataDir,
|
||||
client: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
m.loadCache()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
|
||||
|
||||
func (m *Manager) loadCache() {
|
||||
data, err := os.ReadFile(m.path())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.parse(data)
|
||||
if fi, e := os.Stat(m.path()); e == nil {
|
||||
m.mu.Lock()
|
||||
m.updated = fi.ModTime()
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Download fetches the latest MASTER.SCP, caches it and replaces the in-memory
|
||||
// list. Returns the number of callsigns loaded.
|
||||
func (m *Manager) Download(ctx context.Context) (int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, masterURL, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "OpsLog")
|
||||
resp, err := m.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scp: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("scp: http %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("scp: read: %w", err)
|
||||
}
|
||||
n := m.parse(body)
|
||||
if n == 0 {
|
||||
return 0, fmt.Errorf("scp: file parsed to 0 callsigns")
|
||||
}
|
||||
_ = os.WriteFile(m.path(), body, 0o644) // best-effort cache
|
||||
m.mu.Lock()
|
||||
m.updated = time.Now()
|
||||
m.mu.Unlock()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// parse loads the SCP bytes into the sorted call slice and returns the count.
|
||||
func (m *Manager) parse(data []byte) int {
|
||||
seen := make(map[string]struct{}, 1<<17)
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
line := strings.ToUpper(strings.TrimSpace(sc.Text()))
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue // blank / header comment
|
||||
}
|
||||
// A call token only (the master file is one call per line, but guard
|
||||
// against stray trailing fields).
|
||||
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
if !plausibleCall(line) {
|
||||
continue
|
||||
}
|
||||
seen[line] = struct{}{}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
return 0
|
||||
}
|
||||
calls := make([]string, 0, len(seen))
|
||||
for c := range seen {
|
||||
calls = append(calls, c)
|
||||
}
|
||||
sort.Strings(calls)
|
||||
m.mu.Lock()
|
||||
m.calls = calls
|
||||
m.mu.Unlock()
|
||||
return len(calls)
|
||||
}
|
||||
|
||||
// plausibleCall keeps a token that looks like a callsign: length 3–12, at least
|
||||
// one digit and one letter, only A–Z/0–9//.
|
||||
func plausibleCall(s string) bool {
|
||||
if len(s) < 3 || len(s) > 12 {
|
||||
return false
|
||||
}
|
||||
hasDigit, hasAlpha := false, false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
hasDigit = true
|
||||
case c >= 'A' && c <= 'Z':
|
||||
hasAlpha = true
|
||||
case c == '/':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasDigit && hasAlpha
|
||||
}
|
||||
|
||||
// Lookup returns the Partial (substring) and N+1 (one-edit) suggestions for the
|
||||
// typed fragment. limit caps EACH list. Partial needs ≥2 chars; N+1 needs ≥3
|
||||
// (a plausible whole call) and is skipped otherwise.
|
||||
func (m *Manager) Lookup(fragment string, limit int) Result {
|
||||
q := strings.ToUpper(strings.TrimSpace(fragment))
|
||||
if len(q) < 2 {
|
||||
return Result{}
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
m.mu.RLock()
|
||||
calls := m.calls
|
||||
m.mu.RUnlock()
|
||||
|
||||
wantN1 := len(q) >= 3
|
||||
type pm struct {
|
||||
call string
|
||||
prefix bool
|
||||
}
|
||||
var partial []pm
|
||||
var nplus1 []string
|
||||
for _, c := range calls {
|
||||
if c == q {
|
||||
partial = append(partial, pm{c, true}) // exact = strongest match
|
||||
continue
|
||||
}
|
||||
if idx := strings.Index(c, q); idx >= 0 {
|
||||
partial = append(partial, pm{c, idx == 0})
|
||||
}
|
||||
if wantN1 && len(nplus1) < limit*2 && editDistanceOne(q, c) {
|
||||
nplus1 = append(nplus1, c)
|
||||
}
|
||||
}
|
||||
// Prefix matches first, then the rest (both already alphabetical since `calls`
|
||||
// is sorted and we scanned in order).
|
||||
sort.SliceStable(partial, func(i, j int) bool {
|
||||
if partial[i].prefix != partial[j].prefix {
|
||||
return partial[i].prefix
|
||||
}
|
||||
return false
|
||||
})
|
||||
out := Result{Partial: []string{}, NPlus1: []string{}}
|
||||
for _, p := range partial {
|
||||
if len(out.Partial) >= limit {
|
||||
break
|
||||
}
|
||||
out.Partial = append(out.Partial, p.call)
|
||||
}
|
||||
if len(nplus1) > limit {
|
||||
nplus1 = nplus1[:limit]
|
||||
}
|
||||
out.NPlus1 = append(out.NPlus1, nplus1...)
|
||||
return out
|
||||
}
|
||||
|
||||
// editDistanceOne reports whether a and b are exactly one edit apart (one
|
||||
// substitution, insertion or deletion) — never equal (distance 0 returns false).
|
||||
func editDistanceOne(a, b string) bool {
|
||||
la, lb := len(a), len(b)
|
||||
if la == lb {
|
||||
diff := 0
|
||||
for i := 0; i < la; i++ {
|
||||
if a[i] != b[i] {
|
||||
diff++
|
||||
if diff > 1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return diff == 1
|
||||
}
|
||||
// Ensure a is the shorter one; lengths must differ by exactly 1.
|
||||
if la > lb {
|
||||
a, b = b, a
|
||||
la, lb = lb, la
|
||||
}
|
||||
if lb-la != 1 {
|
||||
return false
|
||||
}
|
||||
// b is a with one extra char: walk both, allowing a single skip in b.
|
||||
i, j, skipped := 0, 0, false
|
||||
for i < la && j < lb {
|
||||
if a[i] == b[j] {
|
||||
i++
|
||||
j++
|
||||
continue
|
||||
}
|
||||
if skipped {
|
||||
return false
|
||||
}
|
||||
skipped = true
|
||||
j++ // skip the extra char in the longer string
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Count returns how many callsigns are loaded.
|
||||
func (m *Manager) Count() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.calls)
|
||||
}
|
||||
|
||||
// Updated returns when the list was last refreshed (zero if never).
|
||||
func (m *Manager) Updated() time.Time {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.updated
|
||||
}
|
||||
+22
-4
@@ -88,12 +88,30 @@ func (a *App) sendTelemetryHeartbeat() {
|
||||
// distinct_id identifies the USER. Prefer the station callsign — it's stable
|
||||
// across every machine/reinstall the same operator runs, so one op counts as
|
||||
// one user (a callsign is public in amateur radio). The random per-install ID
|
||||
// is only a fallback until a callsign is configured; without it the same op on
|
||||
// a laptop + desktop + Maestro showed up as three "users". install_id rides
|
||||
// along as a property so multiple machines under one callsign stay visible.
|
||||
// is only a fallback; without it the same op on a laptop + desktop + Maestro
|
||||
// showed up as three "users". install_id rides along as a property so multiple
|
||||
// machines under one callsign stay visible.
|
||||
//
|
||||
// On a FRESH INSTALL the callsign isn't configured yet at launch, so sending
|
||||
// straight away would record the machine's random UUID (and the once-a-day lock
|
||||
// would then keep it that way even after the op enters their call). Instead wait
|
||||
// a while for the callsign to appear (Settings → Station Information) and only
|
||||
// fall back to the UUID if none shows up within the grace window — so a genuine
|
||||
// no-callsign install is still counted, but the normal case gets the callsign.
|
||||
call := a.activeCallsign()
|
||||
for i := 0; call == "" && i < 60; i++ { // up to ~10 min (60 × 10 s)
|
||||
time.Sleep(10 * time.Second)
|
||||
if a.settings == nil || !a.GetTelemetryEnabled() {
|
||||
return // disabled meanwhile
|
||||
}
|
||||
if last, _ := a.settings.GetGlobal(a.ctx, keyTelemetryLastSent); strings.TrimSpace(last) == today {
|
||||
return // sent by another path in the meantime
|
||||
}
|
||||
call = a.activeCallsign()
|
||||
}
|
||||
installID := a.telemetryInstallID()
|
||||
distinctID := installID
|
||||
if call := a.activeCallsign(); call != "" {
|
||||
if call != "" {
|
||||
distinctID = call
|
||||
}
|
||||
payload := map[string]any{
|
||||
|
||||
Reference in New Issue
Block a user