10 Commits
19 changed files with 1388 additions and 30 deletions
+176 -3
View File
@@ -18,6 +18,7 @@ import (
"time"
"hamlog/internal/adif"
"hamlog/internal/alerts"
"hamlog/internal/antgenius"
"hamlog/internal/applog"
"hamlog/internal/audio"
@@ -89,6 +90,9 @@ const (
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
// global (not per-profile) like CAT/rotator. Device fields store the
@@ -265,6 +269,9 @@ type CATSettings struct {
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
@@ -409,6 +416,8 @@ type App struct {
netActive []*qso.QSO // on-air QSO drafts (transient negative ids), check-in order
netSeq int64 // transient-id counter for on-air drafts (decrements: -1, -2, …)
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
cwMu sync.Mutex // guards the CW decoder lifecycle
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
@@ -608,7 +617,8 @@ func (a *App) startup(ctx context.Context) {
// Route CAT/OmniRig debug lines into the unified app log (they used to go
// to a separate cat.log in the old HamLog folder, which users couldn't find).
cat.LogSink = applog.Printf
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis
applog.Printf("startup: data dir = %s", dataDir)
// The local SQLite file ALWAYS holds per-operator configuration — settings,
// station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award
@@ -757,6 +767,8 @@ func (a *App) startup(ctx context.Context) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cluster:spot", s)
}
// Fire any matching alert rules (sound / visual / e-mail).
a.evaluateAlerts(s)
// Mirror the spot onto the FlexRadio panadapter when enabled. The
// Color is left to the backend default for now — status-based
// colouring can be filled in here later (new entity / worked / …).
@@ -844,6 +856,13 @@ func (a *App) startup(ctx context.Context) {
a.netStore = ns
}
// DX-cluster spot alert rules (global JSON).
if as, err := alerts.Open(filepath.Join(a.dataDir, "alerts.json")); err != nil {
applog.Printf("alerts: open failed: %v", err)
} else {
a.alertStore = as
}
// Ultrabeam antenna: connect in the background if enabled.
a.startUltrabeam()
// Antenna Genius switch: connect in the background if enabled.
@@ -3851,7 +3870,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
if err != nil {
return CATSettings{}, err
}
@@ -3865,6 +3884,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
IcomPort: m[keyCATIcomPort],
IcomBaud: 115200,
IcomAddr: 0x98, // IC-7610 default
TCIHost: m[keyCATTCIHost],
TCIPort: 40001,
TCISpots: m[keyCATTCISpots] == "1",
PollMs: 250,
DelayMs: 0,
DigitalDefault: m[keyCATDigitalDefault],
@@ -3872,6 +3894,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
out.FlexPort = n
}
if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 {
out.TCIPort = n
}
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
out.IcomBaud = n
}
@@ -3930,6 +3955,10 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.FlexSpots {
flexSpots = "1"
}
tciSpots := "0"
if s.TCISpots {
tciSpots = "1"
}
if s.DigitalDefault == "" {
s.DigitalDefault = "FT8"
}
@@ -3943,6 +3972,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
keyCATTCIPort: strconv.Itoa(s.TCIPort),
keyCATTCISpots: tciSpots,
keyCATPollMs: strconv.Itoa(s.PollMs),
keyCATDelayMs: strconv.Itoa(s.DelayMs),
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
@@ -4242,6 +4274,136 @@ func (a *App) toast(msg string) {
}
}
// ── DX-cluster spot alerts ─────────────────────────────────────────────────
const keyAlertEmailTo = "alerts.email_to" // where alert e-mails are sent (default: SMTP from)
// ListAlertRules returns every alert rule.
func (a *App) ListAlertRules() []alerts.Rule {
if a.alertStore == nil {
return nil
}
return a.alertStore.List()
}
// SaveAlertRule upserts a rule (empty id → create) and returns it.
func (a *App) SaveAlertRule(r alerts.Rule) (alerts.Rule, error) {
if a.alertStore == nil {
return alerts.Rule{}, fmt.Errorf("alert store unavailable")
}
return a.alertStore.Save(r)
}
// DeleteAlertRule removes a rule by id.
func (a *App) DeleteAlertRule(id string) error {
if a.alertStore == nil {
return fmt.Errorf("alert store unavailable")
}
return a.alertStore.Delete(id)
}
// GetAlertEmailTo returns the address alert e-mails go to (empty = use SMTP from).
func (a *App) GetAlertEmailTo() string {
if a.settings == nil {
return ""
}
v, _ := a.settings.GetGlobal(a.ctx, keyAlertEmailTo)
return v
}
// SetAlertEmailTo stores the alert e-mail recipient.
func (a *App) SetAlertEmailTo(addr string) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
return a.settings.SetGlobal(a.ctx, keyAlertEmailTo, strings.TrimSpace(addr))
}
// evaluateAlerts runs a spot through the rules and fires notifications for every
// match (visual/sound via a frontend event, e-mail via SMTP). Cheap when no
// rules exist; the worked-before check only runs for rules that opt into it and
// already matched, so it's off the hot path.
func (a *App) evaluateAlerts(s cluster.Spot) {
if a.alertStore == nil {
return
}
sp := alerts.Spot{
DXCall: s.DXCall,
Band: s.Band,
Mode: alerts.InferMode(s.Comment, s.FreqHz),
Country: s.Country,
Continent: s.Continent,
Spotter: s.Spotter,
}
// The spotter's country/continent (cty.dat) — resolved lazily for Origin rules.
if a.dxcc != nil && s.Spotter != "" {
if m, ok := a.dxcc.Lookup(s.Spotter); ok && m.Entity != nil {
sp.SpotterCountry = m.Entity.Name
sp.SpotterContinent = m.Continent
}
}
matches := a.alertStore.Evaluate(sp, time.Now(), a.isWorkedBandMode)
for _, mt := range matches {
if a.ctx != nil && (mt.Rule.Visual || mt.Rule.Sound) {
wruntime.EventsEmit(a.ctx, "alert:fired", map[string]any{
"rule": mt.Rule.Name,
"call": s.DXCall,
"band": s.Band,
"mode": sp.Mode,
"freq_hz": s.FreqHz,
"country": s.Country,
"comment": s.Comment,
"sound": mt.Rule.Sound,
"visual": mt.Rule.Visual,
})
}
if mt.Rule.Email {
go a.sendAlertEmail(mt.Rule, s, sp.Mode)
}
}
}
// isWorkedBandMode reports whether the exact callsign is already logged on the
// given band+mode (used by rules with "skip worked before").
func (a *App) isWorkedBandMode(call, band, mode string) bool {
if a.qso == nil || strings.TrimSpace(call) == "" {
return false
}
rows, err := a.qso.List(a.ctx, qso.ListFilter{Callsign: call, Band: band, Mode: mode, Limit: 5})
if err != nil {
return false
}
call = strings.ToUpper(strings.TrimSpace(call))
for _, q := range rows {
if strings.ToUpper(strings.TrimSpace(q.Callsign)) == call {
return true
}
}
return false
}
// sendAlertEmail notifies the operator by e-mail that a wanted station was
// spotted. Best effort; requires SMTP configured (E-mail settings).
func (a *App) sendAlertEmail(r alerts.Rule, s cluster.Spot, mode string) {
es, _ := a.GetEmailSettings()
to := strings.TrimSpace(a.GetAlertEmailTo())
if to == "" {
to = strings.TrimSpace(es.From)
}
if to == "" {
applog.Printf("alert: e-mail skipped (no recipient / SMTP from) for rule %q", r.Name)
return
}
subject := fmt.Sprintf("OpsLog alert: %s spotted on %s", s.DXCall, s.Band)
body := fmt.Sprintf("Rule: %s\n\nCall: %s\nBand: %s\nMode: %s\nFreq: %.1f kHz\nCountry: %s\nSpotter: %s\nComment: %s\n",
r.Name, s.DXCall, s.Band, mode, float64(s.FreqHz)/1000, s.Country, s.Spotter, s.Comment)
if err := email.Send(a.emailConfig(es), to, subject, body, ""); err != nil {
applog.Printf("alert: e-mail to %s failed: %v", to, err)
} else {
applog.Printf("alert: e-mailed %s (rule %q) to %s", s.DXCall, r.Name, to)
}
}
// sanitizeFilename makes a callsign safe for a filename (slashes etc.).
func sanitizeFilename(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
@@ -7440,6 +7602,13 @@ func (a *App) FlexSetTXAntenna(ant string) error {
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXAntenna(ant) })
}
func (a *App) FlexSetSplit(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSplit(on) })
}
// keyFlexBandAnt stores the per-band RX/TX antenna map (global, machine-local).
const keyFlexBandAnt = "flex.band_antennas"
@@ -7629,7 +7798,7 @@ func (a *App) reloadCAT() {
}
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
a.catFlexSpots = s.Enabled && s.Backend == "flex" && s.FlexSpots
a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
if !s.Enabled {
a.cat.Stop()
return
@@ -7656,6 +7825,10 @@ func (a *App) reloadCAT() {
// Native Icom CI-V over the radio's USB serial port (local control).
// Same civ protocol a future network backend will reuse for remote.
a.cat.Start(cat.NewIcomSerial(s.IcomPort, s.IcomBaud, s.IcomAddr, s.DigitalDefault))
case "tci":
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
// TCI-compatible server.
a.cat.Start(cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault, s.TCISpots))
default:
// Unknown backend → stop and emit a dummy state so the UI shows it.
a.cat.Stop()
+50 -2
View File
@@ -67,6 +67,7 @@ import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { NetControlPanel } from '@/components/NetControlPanel';
import { AlertsModal } from '@/components/AlertsModal';
import { BulkEditModal } from '@/components/BulkEditModal';
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
@@ -730,6 +731,8 @@ export default function App() {
}
const chatShown = chatOpen && chatAvailable;
const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
@@ -818,6 +821,7 @@ export default function App() {
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
// so it's loaded async on mount and re-read on profile:changed below.
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent';
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
const loadMainPanes = useCallback(async () => {
@@ -1099,6 +1103,35 @@ export default function App() {
return () => { off(); };
}, [showToast]);
// DX-cluster spot alerts: a matched rule fires here. Play a beep (WebAudio, no
// asset needed — CSP-safe) and/or show a toast, per the rule's chosen actions.
useEffect(() => {
const off = EventsOn('alert:fired', (p: any) => {
if (p?.sound) {
try {
const AC = (window as any).AudioContext || (window as any).webkitAudioContext;
const ctx = new AC();
const beep = (freq: number, at: number, dur: number) => {
const o = ctx.createOscillator(); const g = ctx.createGain();
o.type = 'sine'; o.frequency.value = freq;
o.connect(g); g.connect(ctx.destination);
g.gain.setValueAtTime(0.0001, ctx.currentTime + at);
g.gain.exponentialRampToValueAtTime(0.25, ctx.currentTime + at + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + at + dur);
o.start(ctx.currentTime + at); o.stop(ctx.currentTime + at + dur + 0.02);
};
beep(880, 0, 0.14); beep(1320, 0.16, 0.18); // two-tone chirp
window.setTimeout(() => ctx.close().catch(() => {}), 600);
} catch { /* audio blocked — ignore */ }
}
if (p?.visual) {
const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert');
showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? `${p.country}` : ''}`);
}
});
return () => { off(); };
}, [showToast]);
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
// rotator is disabled (the backend just reads settings and returns).
useEffect(() => {
@@ -1991,6 +2024,11 @@ export default function App() {
setLookupBusy(true);
try {
const r = await LookupCallsign(call);
// Discard a STALE result: the operator already moved to another call
// (clicked a new spot / typed) while this lookup was in flight. Applying it
// would clobber the current call's fields and zoom the map to the wrong
// station — the bug where replacing a call didn't re-zoom the map.
if (call !== callsignValRef.current.trim().toUpperCase()) return;
lastLookedUpRef.current = call;
// cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
// A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
@@ -2028,6 +2066,9 @@ export default function App() {
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
}));
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
// The DX location is now known (grid set above) — force the world map to
// auto-zoom right away, so it doesn't lag behind the resolved QSO.
setMapZoomSignal((n) => n + 1);
// Recording: tie it to the resolved callsign. Start once a real (≥3-char)
// call resolves — covers the fast CW workflow (type → Enter, no blur). If
// we're already recording a DIFFERENT call (the operator edited the
@@ -2200,6 +2241,7 @@ export default function App() {
{ type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' },
{ type: 'separator' },
{ type: 'item', label: netEnabled ? '✓ NET Control' : 'NET Control', action: 'tools.net' },
{ type: 'item', label: 'Alert management…', action: 'tools.alerts' },
{ type: 'separator' },
// Maintenance — bumped here while we only have one entry. Will move
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
@@ -2228,6 +2270,7 @@ export default function App() {
case 'tools.dvk': setDvkEnabled((v) => !v); break;
case 'tools.cwdecoder': toggleCwDecoder(); break;
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
case 'tools.alerts': setAlertsOpen(true); break;
case 'tools.refreshCty': refreshCtyDat(); break;
case 'tools.downloadRefs': downloadRefs(); break;
case 'help.about': setShowAbout(true); break;
@@ -2823,6 +2866,7 @@ export default function App() {
toLabel={callsign}
beamAzimuths={showBeamOnMap ? beamHeadings : []}
boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null}
zoomSignal={mapZoomSignal}
/>
);
case 'map2':
@@ -2853,7 +2897,7 @@ export default function App() {
case 'flex':
return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
<FlexPanel />
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} />
</div>
);
case 'recent':
@@ -3908,7 +3952,7 @@ export default function App() {
backend is a FlexRadio. */}
{catState.backend === 'flex' && (
<TabsContent value="flex" className="flex-1 min-h-0 p-0">
<FlexPanel />
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} />
</TabsContent>
)}
@@ -4091,6 +4135,10 @@ export default function App() {
/>
)}
{alertsOpen && (
<AlertsModal onClose={() => setAlertsOpen(false)} bands={bands} modes={modes} countries={countries} />
)}
<AutoEQSL
onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)}
onError={(msg) => showToast(msg)}
+256
View File
@@ -0,0 +1,256 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Bell, Plus, Trash2, Volume2, Mail, Eye, X, Search } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import {
ListAlertRules, SaveAlertRule, DeleteAlertRule, GetAlertEmailTo, SetAlertEmailTo,
} from '@/../wailsjs/go/main/App';
import { alerts } from '@/../wailsjs/go/models';
type Rule = alerts.Rule;
const CONTINENTS = ['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'];
function emptyRule(): Rule {
return alerts.Rule.createFrom({
id: '', name: 'New alert', enabled: true,
calls: [], countries: [], continents: [], bands: [], modes: [],
spotter_call: '', spotter_continents: [], spotter_countries: [],
sound: true, visual: true, email: false, again_after_min: 0, skip_worked: false,
});
}
// MultiCheck — a scrollable checkbox list for a set of options with an optional
// search box (used for the long country list). Empty selection = ALL.
function MultiCheck({ options, selected, onToggle, searchable, height = 'h-52' }: {
options: string[]; selected: string[]; onToggle: (v: string) => void; searchable?: boolean; height?: string;
}) {
const [q, setQ] = useState('');
const shown = useMemo(
() => (q ? options.filter((o) => o.toLowerCase().includes(q.toLowerCase())) : options),
[options, q],
);
const sel = new Set((selected ?? []).map((s) => s.toLowerCase()));
return (
<div className="rounded-md border border-border">
{searchable && (
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/60">
<Search className="size-3 text-muted-foreground" />
<input className="flex-1 bg-transparent text-xs outline-none" placeholder="Filter…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
)}
<div className={cn('overflow-y-auto p-1', height)}>
{shown.map((o) => (
<label key={o} className="flex items-center gap-2 text-xs px-1.5 py-0.5 rounded hover:bg-accent/40 cursor-pointer">
<Checkbox checked={sel.has(o.toLowerCase())} onCheckedChange={() => onToggle(o)} />
<span className="truncate">{o}</span>
</label>
))}
{shown.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-3 text-center">no match</div>}
</div>
<div className="px-2 py-1 border-t border-border/60 text-[10px] text-muted-foreground">
{(selected?.length ?? 0) === 0 ? 'none selected = ALL' : `${selected!.length} selected`}
</div>
</div>
);
}
export function AlertsModal({ onClose, bands, modes, countries }: {
onClose: () => void; bands: string[]; modes: string[]; countries: string[];
}) {
const [rules, setRules] = useState<Rule[]>([]);
const [draft, setDraft] = useState<Rule | null>(null);
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
const [emailTo, setEmailTo] = useState('');
const [err, setErr] = useState('');
const refresh = useCallback(async () => {
try { setRules(((await ListAlertRules()) ?? []) as Rule[]); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}, []);
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
if (!d) return d;
const cur = ((d as any)[key] as string[]) ?? [];
const has = cur.some((x) => x.toLowerCase() === v.toLowerCase());
const next = has ? cur.filter((x) => x.toLowerCase() !== v.toLowerCase()) : [...cur, v];
return alerts.Rule.createFrom({ ...d, [key]: next });
});
async function save() {
if (!draft) return;
if (!draft.name.trim()) { setErr('Give the rule a name'); return; }
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function del() {
if (!draft) return;
if (!draft.id) { setDraft(null); return; }
if (!window.confirm(`Delete alert "${draft.name}"?`)) return;
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> Alert management</DialogTitle>
<DialogDescription>Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).</DialogDescription>
</DialogHeader>
<div className="flex gap-3 min-h-0 px-5" style={{ height: '60vh' }}>
{/* Rule list */}
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">Rules</span>
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
</div>
<div className="flex-1 overflow-y-auto p-1">
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">No rules yet click +</div>}
{rules.map((r) => (
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')} />
<span className="truncate flex-1">{r.name}</span>
{r.sound && <Volume2 className="size-3 text-muted-foreground shrink-0" />}
{r.email && <Mail className="size-3 text-muted-foreground shrink-0" />}
</button>
))}
</div>
<div className="px-2 py-1.5 border-t border-border/60">
<Label className="text-[10px] text-muted-foreground">Alert e-mail to</Label>
<Input className="h-7 text-xs mt-0.5" placeholder="[email protected]" value={emailTo}
onChange={(e) => setEmailTo(e.target.value)}
onBlur={() => SetAlertEmailTo(emailTo).catch(() => {})} />
</div>
</div>
{/* Editor */}
<div className="flex-1 min-w-0 border border-border rounded-md flex flex-col">
{!draft ? (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">Select or create a rule.</div>
) : (
<Tabs value={tab} onValueChange={setTab} className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-2 px-2 pt-2">
<TabsList>
<TabsTrigger value="def">Definition</TabsTrigger>
<TabsTrigger value="call">Call / DXCC</TabsTrigger>
<TabsTrigger value="bm">Band / Mode</TabsTrigger>
<TabsTrigger value="orig">Origin</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-3">
<TabsContent value="def" className="mt-0 space-y-3">
<div>
<Label className="text-xs">Rule name</Label>
<Input value={draft.name} onChange={(e) => patch({ name: e.target.value })} />
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={draft.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} /> Alert enabled
</label>
<div className="flex items-center gap-2">
<Label className="text-xs w-40 shrink-0">Alert again after (min)</Label>
<Input type="number" className="h-8 w-24" value={draft.again_after_min}
onChange={(e) => patch({ again_after_min: parseInt(e.target.value) || 0 })} />
<span className="text-[11px] text-muted-foreground">0 = once/session · -1 = always</span>
</div>
<div className="border-t border-border/60 pt-3 space-y-2">
<Label className="text-xs font-semibold">Actions</Label>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.visual} onCheckedChange={(c) => patch({ visual: !!c })} /><Eye className="size-3.5" /> Visual</label>
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.sound} onCheckedChange={(c) => patch({ sound: !!c })} /><Volume2 className="size-3.5" /> Sound</label>
<label className="flex items-center gap-1.5 text-sm cursor-pointer"><Checkbox checked={draft.email} onCheckedChange={(c) => patch({ email: !!c })} /><Mail className="size-3.5" /> E-mail</label>
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
<Checkbox checked={draft.skip_worked} onCheckedChange={(c) => patch({ skip_worked: !!c })} /> Skip calls already worked (same band + mode)
</label>
</TabsContent>
<TabsContent value="call" className="mt-0 grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Callsigns (one per line, wildcards: IW3*, */P)</Label>
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
placeholder={'DL1ABC\nIW3*\n*/P'}
value={(draft.calls ?? []).join('\n')}
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
</div>
<div className="space-y-1">
<Label className="text-xs">Countries (DXCC)</Label>
<MultiCheck options={countries} selected={draft.countries ?? []} onToggle={(v) => toggleIn('countries', v)} searchable />
</div>
<div className="space-y-1 col-span-2">
<Label className="text-xs">Continents</Label>
<div className="flex gap-3 flex-wrap">
{CONTINENTS.map((c) => (
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={(draft.continents ?? []).includes(c)} onCheckedChange={() => toggleIn('continents', c)} /> {c}
</label>
))}
</div>
</div>
</TabsContent>
<TabsContent value="bm" className="mt-0 grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Bands</Label>
<MultiCheck options={bands} selected={draft.bands ?? []} onToggle={(v) => toggleIn('bands', v)} />
</div>
<div className="space-y-1">
<Label className="text-xs">Modes</Label>
<MultiCheck options={modes} selected={draft.modes ?? []} onToggle={(v) => toggleIn('modes', v)} />
</div>
</TabsContent>
<TabsContent value="orig" className="mt-0 grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Spotter callsign (wildcard)</Label>
<Input className="font-mono" placeholder="e.g. F* or DL1ABC" value={draft.spotter_call ?? ''}
onChange={(e) => patch({ spotter_call: e.target.value })} />
<Label className="text-xs mt-2 block">Spotter continents</Label>
<div className="flex gap-3 flex-wrap">
{CONTINENTS.map((c) => (
<label key={c} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={(draft.spotter_continents ?? []).includes(c)} onCheckedChange={() => toggleIn('spotter_continents', c)} /> {c}
</label>
))}
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Spotter countries</Label>
<MultiCheck options={countries} selected={draft.spotter_countries ?? []} onToggle={(v) => toggleIn('spotter_countries', v)} searchable />
</div>
</TabsContent>
</div>
{/* Editor actions */}
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/60">
{err && <span className="text-[11px] text-rose-600 flex-1 truncate">{err}</span>}
<div className="flex-1" />
<Button variant="ghost" size="sm" className="text-rose-700" onClick={del}><Trash2 className="size-3.5" /> Delete</Button>
<Button size="sm" onClick={save}>Save rule</Button>
</div>
</Tabs>
)}
</div>
</div>
<div className="flex justify-end px-5 pb-4">
<Button variant="outline" size="sm" onClick={onClose}><X className="size-3.5" /> Close</Button>
</div>
</DialogContent>
</Dialog>
);
}
+22 -4
View File
@@ -5,7 +5,7 @@ import {
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
@@ -21,6 +21,7 @@ type FlexState = {
atu_status?: string; atu_memories: boolean;
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
mode?: string;
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
@@ -35,7 +36,7 @@ const ZERO: FlexState = {
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false,
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
@@ -186,7 +187,9 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
);
}
export function FlexPanel() {
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
// host can keep the WinKeyer (which actually sends the macros) in sync.
export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } = {}) {
const [st, setSt] = useState<FlexState>(ZERO);
const hold = useRef<Record<string, number>>({});
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
@@ -306,6 +309,21 @@ export function FlexPanel() {
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
</button>
</div>
<div className="flex items-center gap-2">
<button type="button" disabled={off}
title="Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR."
onClick={() => change('split', !st.split, () => FlexSetSplit(!st.split))}
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.split ? 'bg-sky-600 text-white border-sky-600 shadow-[0_0_12px] shadow-sky-600/50' : 'bg-card text-sky-700 border-sky-400 hover:bg-sky-50')}>
SPLIT
</button>
{st.split && !!st.tx_freq_hz && (
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
{!!st.rx_freq_hz && ` (${(st.tx_freq_hz - st.rx_freq_hz) >= 0 ? '+' : ''}${((st.tx_freq_hz - st.rx_freq_hz) / 1000).toFixed(1)} kHz)`}
</span>
)}
</div>
{!isCW ? (
<div className="border-t border-border/60 pt-3 space-y-3">
@@ -339,7 +357,7 @@ export function FlexPanel() {
<div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span>
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => change('cw_speed', v, () => FlexSetCWSpeed(v))} />
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => { change('cw_speed', v, () => FlexSetCWSpeed(v)); onCWSpeed?.(v); }} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
</div>
<div className="flex items-center gap-2">
+3 -2
View File
@@ -81,10 +81,11 @@ interface WorldProps {
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
beamWidth?: number; // beamwidth (deg), default 30
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
zoomSignal?: number; // bump to force an auto-zoom now (e.g. QRZ lookup finished)
}
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth }: WorldProps) {
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth, zoomSignal }: WorldProps) {
const worldRef = useRef<HTMLDivElement>(null);
const worldMap = useRef<L.Map | null>(null);
const worldOverlay = useRef<L.LayerGroup | null>(null);
@@ -225,7 +226,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
}
setTimeout(() => { wm.invalidateSize(); }, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]);
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom, zoomSignal]);
const path = pathBetween(fromGrid, toGrid);
+12 -2
View File
@@ -254,7 +254,14 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
// Compute initial column defs: all columns defined, but those not marked
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
// overrides this so a previously toggled column wins.
// While AG Grid rebuilds columns (award columns load async), it fires column
// events that would otherwise trigger a save of the DEFAULT visibility before
// we re-apply the user's saved state — clobbering it. This flag suppresses the
// auto-save during that window. Set in the memo (runs at render, before the
// column events) and cleared by the re-apply effect below.
const restoringRef = useRef(true);
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
restoringRef.current = true;
const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
@@ -290,6 +297,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
});
}
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state);
}, []);
@@ -301,9 +309,11 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
// every rebuild so the user's choices win. No-op before the grid is ready.
useEffect(() => {
const api = gridRef.current?.api;
if (!api) return;
const local = loadLocal(COL_STATE_KEY);
if (local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
// Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
+27 -3
View File
@@ -133,8 +133,10 @@ const emptyProfile = (): Profile => ({
is_active: false,
sort_order: 0,
db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' },
created_at: '' as any,
updated_at: '' as any,
// Server-managed timestamps — send null (NOT "") so Go's time.Time unmarshal
// leaves the zero value instead of failing to parse an empty RFC3339 string.
created_at: null as any,
updated_at: null as any,
});
interface Props {
@@ -715,7 +717,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, poll_ms: 250, delay_ms: 0,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8',
});
const [rotator, setRotator] = useState<RotatorSettings>({
@@ -1892,6 +1894,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
<SelectItem value="icom">Icom CI-V (USB serial)</SelectItem>
<SelectItem value="tci">TCI (Expert Electronics / SunSDR)</SelectItem>
</SelectContent>
</Select>
</div>
@@ -1961,6 +1964,27 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</>
)}
{catCfg.backend === 'tci' && (
<>
<div className="space-y-1">
<Label>TCI host</Label>
<Input placeholder="127.0.0.1" value={catCfg.tci_host ?? ''}
onChange={(e) => setCatCfg((s) => ({ ...s, tci_host: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Port</Label>
<Input type="number" value={catCfg.tci_port || 40001}
onChange={(e) => setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 40001 }))} />
</div>
<p className="col-span-2 text-xs text-muted-foreground">
Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.
</p>
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={!!catCfg.tci_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, tci_spots: !!c }))} />
Show cluster spots on the panorama <span className="text-xs text-muted-foreground">(spots from OpsLog's DX cluster appear on the SDR panadapter)</span>
</label>
</>
)}
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
<>
<div className="space-y-1">
+17 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
@@ -96,7 +96,12 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
const count = wb?.count ?? 0;
const entries = wb?.entries ?? [];
// Suppress auto-save while AG Grid rebuilds columns (award columns load async),
// so the default visibility doesn't clobber the user's saved state. See the
// matching note in RecentQSOsGrid.
const restoringRef = useRef(true);
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
restoringRef.current = true;
const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
@@ -127,10 +132,21 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
});
}
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state);
}, []);
// Re-apply the saved column state after the award columns load (they rebuild
// the column set), so the user's visibility choices win over the defaults.
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY);
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols]);
function isColVisible(colId: string): boolean {
const col = gridRef.current?.api?.getColumn(colId);
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.15';
export const APP_VERSION = '0.16.2';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+13
View File
@@ -12,6 +12,7 @@ import {cluster} from '../models';
import {extsvc} from '../models';
import {powergenius} from '../models';
import {winkeyer} from '../models';
import {alerts} from '../models';
import {audio} from '../models';
import {operating} from '../models';
import {udp} from '../models';
@@ -88,6 +89,8 @@ export function DXCCForCountry(arg1:string):Promise<number>;
export function DXCCName(arg1:number):Promise<string>;
export function DeleteAlertRule(arg1:string):Promise<void>;
export function DeleteAllQSO():Promise<number>;
export function DeleteAwardReference(arg1:string,arg2:string):Promise<void>;
@@ -196,6 +199,8 @@ export function FlexSetRXAntenna(arg1:string):Promise<void>;
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
export function FlexSetSplit(arg1:boolean):Promise<void>;
export function FlexSetTXAntenna(arg1:string):Promise<void>;
export function FlexSetTunePower(arg1:number):Promise<void>;
@@ -210,6 +215,8 @@ export function FlexTune(arg1:boolean):Promise<void>;
export function GetActiveProfile():Promise<profile.Profile>;
export function GetAlertEmailTo():Promise<string>;
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
export function GetAntGeniusStatus():Promise<antgenius.Status>;
@@ -352,6 +359,8 @@ export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunch
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
export function ListAlertRules():Promise<Array<alerts.Rule>>;
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
@@ -510,6 +519,8 @@ export function RunBackupNow():Promise<string>;
export function SaveADIFFile():Promise<string>;
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
@@ -572,6 +583,8 @@ export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
export function SendQSORecordingEmail(arg1:number):Promise<void>;
export function SetAlertEmailTo(arg1:string):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>;
+24
View File
@@ -142,6 +142,10 @@ export function DXCCName(arg1) {
return window['go']['main']['App']['DXCCName'](arg1);
}
export function DeleteAlertRule(arg1) {
return window['go']['main']['App']['DeleteAlertRule'](arg1);
}
export function DeleteAllQSO() {
return window['go']['main']['App']['DeleteAllQSO']();
}
@@ -358,6 +362,10 @@ export function FlexSetSidetoneLevel(arg1) {
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
}
export function FlexSetSplit(arg1) {
return window['go']['main']['App']['FlexSetSplit'](arg1);
}
export function FlexSetTXAntenna(arg1) {
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
}
@@ -386,6 +394,10 @@ export function GetActiveProfile() {
return window['go']['main']['App']['GetActiveProfile']();
}
export function GetAlertEmailTo() {
return window['go']['main']['App']['GetAlertEmailTo']();
}
export function GetAntGeniusSettings() {
return window['go']['main']['App']['GetAntGeniusSettings']();
}
@@ -670,6 +682,10 @@ export function LaunchAutostartPrograms() {
return window['go']['main']['App']['LaunchAutostartPrograms']();
}
export function ListAlertRules() {
return window['go']['main']['App']['ListAlertRules']();
}
export function ListAudioInputDevices() {
return window['go']['main']['App']['ListAudioInputDevices']();
}
@@ -986,6 +1002,10 @@ export function SaveADIFFile() {
return window['go']['main']['App']['SaveADIFFile']();
}
export function SaveAlertRule(arg1) {
return window['go']['main']['App']['SaveAlertRule'](arg1);
}
export function SaveAntGeniusSettings(arg1) {
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
}
@@ -1110,6 +1130,10 @@ export function SendQSORecordingEmail(arg1) {
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
}
export function SetAlertEmailTo(arg1) {
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
}
export function SetCATFrequency(arg1) {
return window['go']['main']['App']['SetCATFrequency'](arg1);
}
+59
View File
@@ -65,6 +65,53 @@ export namespace adif {
}
export namespace alerts {
export class Rule {
id: string;
name: string;
enabled: boolean;
calls?: string[];
countries?: string[];
continents?: string[];
bands?: string[];
modes?: string[];
spotter_call?: string;
spotter_continents?: string[];
spotter_countries?: string[];
sound: boolean;
visual: boolean;
email: boolean;
again_after_min: number;
skip_worked: boolean;
static createFrom(source: any = {}) {
return new Rule(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.enabled = source["enabled"];
this.calls = source["calls"];
this.countries = source["countries"];
this.continents = source["continents"];
this.bands = source["bands"];
this.modes = source["modes"];
this.spotter_call = source["spotter_call"];
this.spotter_continents = source["spotter_continents"];
this.spotter_countries = source["spotter_countries"];
this.sound = source["sound"];
this.visual = source["visual"];
this.email = source["email"];
this.again_after_min = source["again_after_min"];
this.skip_worked = source["skip_worked"];
}
}
}
export namespace antgenius {
export class Antenna {
@@ -512,6 +559,9 @@ export namespace cat {
atu_status?: string;
atu_memories: boolean;
rx_avail: boolean;
split: boolean;
rx_freq_hz?: number;
tx_freq_hz?: number;
agc_mode?: string;
agc_threshold: number;
audio_level: number;
@@ -565,6 +615,9 @@ export namespace cat {
this.atu_status = source["atu_status"];
this.atu_memories = source["atu_memories"];
this.rx_avail = source["rx_avail"];
this.split = source["split"];
this.rx_freq_hz = source["rx_freq_hz"];
this.tx_freq_hz = source["tx_freq_hz"];
this.agc_mode = source["agc_mode"];
this.agc_threshold = source["agc_threshold"];
this.audio_level = source["audio_level"];
@@ -1170,6 +1223,9 @@ export namespace main {
icom_port: string;
icom_baud: number;
icom_addr: number;
tci_host: string;
tci_port: number;
tci_spots: boolean;
poll_ms: number;
delay_ms: number;
digital_default: string;
@@ -1189,6 +1245,9 @@ export namespace main {
this.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"];
this.icom_addr = source["icom_addr"];
this.tci_host = source["tci_host"];
this.tci_port = source["tci_port"];
this.tci_spots = source["tci_spots"];
this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"];
this.digital_default = source["digital_default"];
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/braheezy/shine-mp3 v0.1.0
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
github.com/moutend/go-wca v0.3.0
github.com/wailsapp/wails/v2 v2.11.0
github.com/wneessen/go-mail v0.7.3
@@ -22,7 +23,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
+309
View File
@@ -0,0 +1,309 @@
// Package alerts evaluates incoming DX-cluster spots against user-defined rules
// and reports which rules fire, so the app can notify the operator (sound /
// visual / e-mail) when a wanted station is spotted — like Log4OM's Alert
// Management. Rules are persisted as JSON (global, machine-local).
package alerts
import (
"encoding/json"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// Rule is one alert definition. Every filter dimension is optional: an empty
// list/string matches ANY value, and the dimensions are ANDed together, so a
// rule with Countries=[France] and Bands=[20m] fires only for French stations
// on 20m. Calls / SpotterCall accept wildcards (IW3*, */P).
type Rule struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
// DX filters.
Calls []string `json:"calls,omitempty"` // wildcard patterns on the DX call
Countries []string `json:"countries,omitempty"` // DXCC entity names
Continents []string `json:"continents,omitempty"` // AF/AN/AS/EU/NA/OC/SA
// Band / mode filters.
Bands []string `json:"bands,omitempty"`
Modes []string `json:"modes,omitempty"`
// Spotter (origin) filters.
SpotterCall string `json:"spotter_call,omitempty"` // wildcard
SpotterContinents []string `json:"spotter_continents,omitempty"`
SpotterCountries []string `json:"spotter_countries,omitempty"`
// Actions.
Sound bool `json:"sound"`
Visual bool `json:"visual"`
Email bool `json:"email"`
// Throttling: minutes to wait before re-alerting the same callsign. 0 = only
// once per session, -1 = always, >0 = that many minutes.
AgainAfterMin int `json:"again_after_min"`
// Skip a spot whose DX call is already worked on this band+mode.
SkipWorked bool `json:"skip_worked"`
}
// Spot is the subset of a cluster spot the engine matches against.
type Spot struct {
DXCall string
Band string
Mode string // inferred (see InferMode)
Country string // DXCC entity name
Continent string
Spotter string
SpotterCountry string
SpotterContinent string
}
// Store persists the rule set as a JSON file.
type Store struct {
mu sync.Mutex
path string
rules []Rule
// last fired time per (ruleID|call), for the AgainAfter throttle.
lastFired map[string]time.Time
}
// Open loads the store (empty when the file is absent).
func Open(path string) (*Store, error) {
s := &Store{path: path, lastFired: map[string]time.Time{}}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return s, nil
}
return nil, err
}
if len(b) > 0 {
_ = json.Unmarshal(b, &s.rules) // corrupt file → start empty
}
return s, nil
}
func (s *Store) save() error {
b, err := json.MarshalIndent(s.rules, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// List returns a copy of all rules.
func (s *Store) List() []Rule {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Rule, len(s.rules))
copy(out, s.rules)
return out
}
func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) }
// Save upserts a rule (creating an id when empty) and returns it.
func (s *Store) Save(r Rule) (Rule, error) {
s.mu.Lock()
defer s.mu.Unlock()
if strings.TrimSpace(r.ID) == "" {
r.ID = newID()
s.rules = append(s.rules, r)
} else {
found := false
for i := range s.rules {
if s.rules[i].ID == r.ID {
s.rules[i] = r
found = true
break
}
}
if !found {
s.rules = append(s.rules, r)
}
}
return r, s.save()
}
// Delete removes a rule by id.
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.rules {
if s.rules[i].ID == id {
s.rules = append(s.rules[:i], s.rules[i+1:]...)
return s.save()
}
}
return nil
}
// Match is a rule that fired for a spot (returned by Evaluate).
type Match struct {
Rule Rule
Spot Spot
}
// Evaluate returns every enabled rule that matches the spot AND isn't currently
// throttled. It records the fire time for matched rules so the AgainAfter window
// is honoured. workedFn (may be nil) reports whether the DX call is already
// worked on this band+mode — used by rules with SkipWorked.
func (s *Store) Evaluate(sp Spot, now time.Time, workedFn func(call, band, mode string) bool) []Match {
s.mu.Lock()
defer s.mu.Unlock()
var out []Match
for _, r := range s.rules {
if !r.Enabled || !ruleMatches(r, sp) {
continue
}
if r.SkipWorked && workedFn != nil && workedFn(sp.DXCall, sp.Band, sp.Mode) {
continue
}
key := r.ID + "|" + strings.ToUpper(sp.DXCall)
if r.AgainAfterMin >= 0 {
last, seen := s.lastFired[key]
if seen {
if r.AgainAfterMin == 0 {
continue // once per session
}
if now.Sub(last) < time.Duration(r.AgainAfterMin)*time.Minute {
continue
}
}
}
s.lastFired[key] = now
out = append(out, Match{Rule: r, Spot: sp})
}
return out
}
// ruleMatches reports whether every specified filter dimension matches the spot.
func ruleMatches(r Rule, sp Spot) bool {
if len(r.Calls) > 0 && !anyWildcard(r.Calls, sp.DXCall) {
return false
}
if len(r.Countries) > 0 && !containsFold(r.Countries, sp.Country) {
return false
}
if len(r.Continents) > 0 && !containsFold(r.Continents, sp.Continent) {
return false
}
if len(r.Bands) > 0 && !containsFold(r.Bands, sp.Band) {
return false
}
if len(r.Modes) > 0 && !containsFold(r.Modes, sp.Mode) {
return false
}
if strings.TrimSpace(r.SpotterCall) != "" && !matchWildcard(r.SpotterCall, sp.Spotter) {
return false
}
if len(r.SpotterContinents) > 0 && !containsFold(r.SpotterContinents, sp.SpotterContinent) {
return false
}
if len(r.SpotterCountries) > 0 && !containsFold(r.SpotterCountries, sp.SpotterCountry) {
return false
}
return true
}
func containsFold(list []string, v string) bool {
v = strings.TrimSpace(v)
if v == "" {
return false
}
for _, x := range list {
if strings.EqualFold(strings.TrimSpace(x), v) {
return true
}
}
return false
}
func anyWildcard(patterns []string, v string) bool {
for _, p := range patterns {
if matchWildcard(p, v) {
return true
}
}
return false
}
// matchWildcard does a case-insensitive full-string match where '*' matches any
// run of characters and '?' matches one (e.g. "IW3*", "*/P", "F?BPO").
func matchWildcard(pattern, v string) bool {
pattern = strings.TrimSpace(pattern)
v = strings.TrimSpace(v)
if pattern == "" {
return true
}
var b strings.Builder
b.WriteString("(?i)^")
for _, r := range pattern {
switch r {
case '*':
b.WriteString(".*")
case '?':
b.WriteString(".")
default:
b.WriteString(regexp.QuoteMeta(string(r)))
}
}
b.WriteString("$")
re, err := regexp.Compile(b.String())
if err != nil {
return strings.EqualFold(pattern, v)
}
return re.MatchString(v)
}
// InferMode guesses a spot's mode from its comment and frequency. Cluster spots
// don't carry a mode field, so we read common tags (FT8/FT4/CW/RTTY/…) then fall
// back to the digital watering holes and the band-plan CW/phone split.
func InferMode(comment string, freqHz int64) string {
c := strings.ToUpper(comment)
switch {
case strings.Contains(c, "FT8"):
return "FT8"
case strings.Contains(c, "FT4"):
return "FT4"
case strings.Contains(c, "RTTY"):
return "RTTY"
case strings.Contains(c, "PSK"):
return "PSK"
case strings.Contains(c, "JS8"):
return "JS8"
case strings.Contains(c, "CW"):
return "CW"
case strings.Contains(c, "SSB") || strings.Contains(c, "USB") || strings.Contains(c, "LSB") || strings.Contains(c, "PH"):
return "SSB"
}
khz := float64(freqHz) / 1000
// FT8 watering holes (…074) and FT4 (…080/…140) as a fallback.
for _, f := range []float64{1840, 3573, 7074, 10136, 14074, 18100, 21074, 24915, 28074, 50313} {
if khz >= f-1 && khz <= f+3 {
return "FT8"
}
}
// Band-plan CW segments (bottom of each band).
switch {
case khz >= 1810 && khz <= 1840,
khz >= 3500 && khz <= 3570,
khz >= 7000 && khz <= 7040,
khz >= 10100 && khz <= 10130,
khz >= 14000 && khz <= 14070,
khz >= 18068 && khz <= 18095,
khz >= 21000 && khz <= 21070,
khz >= 24890 && khz <= 24910,
khz >= 28000 && khz <= 28070:
return "CW"
}
return "SSB"
}
+5 -1
View File
@@ -247,7 +247,10 @@ type FlexTXState struct {
ATUStatus string `json:"atu_status,omitempty"`
ATUMemories bool `json:"atu_memories"`
// Active RX slice DSP controls.
RXAvail bool `json:"rx_avail"` // an RX slice exists
RXAvail bool `json:"rx_avail"` // an RX slice exists
Split bool `json:"split"` // RX/TX on separate slices
RXFreqHz int64 `json:"rx_freq_hz,omitempty"` // RX slice freq when split
TXFreqHz int64 `json:"tx_freq_hz,omitempty"` // TX slice freq when split
AGCMode string `json:"agc_mode,omitempty"`
AGCThreshold int `json:"agc_threshold"`
AudioLevel int `json:"audio_level"`
@@ -320,6 +323,7 @@ type FlexController interface {
SetMute(bool) error
SetRXAntenna(string) error
SetTXAntenna(string) error
SetSplit(bool) error
SetNB(bool) error
SetNBLevel(int) error
SetNR(bool) error
+85 -9
View File
@@ -54,6 +54,7 @@ type Flex struct {
spotsEnabled bool // push cluster spots + manage the panadapter overlay
spotIdx map[int]bool // panadapter spot indices currently known to the radio
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
@@ -148,7 +149,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
return &Flex{
host: strings.TrimSpace(host), port: port,
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{},
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, pendingSplit: map[int]bool{},
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
}
@@ -314,7 +315,18 @@ func (f *Flex) reader(conn net.Conn) {
f.spotIdx[idx] = true
}
}
// A successful "slice create" for split returns the new slice's index;
// make that slice the transmitter so RX/TX are on separate slices.
splitSeq := f.pendingSplit[seq]
if splitSeq {
delete(f.pendingSplit, seq)
}
f.mu.Unlock()
if splitSeq && ok && len(parts) >= 3 {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.send(fmt.Sprintf("slice s %d tx=1", idx))
}
}
}
}
// Connection ended.
@@ -1050,6 +1062,11 @@ func (f *Flex) FlexState() FlexTXState {
CWSidetone: f.tx.cwSidetone,
CWMonLevel: f.tx.cwMonLevel,
}
if rx, tx := f.pickSlicesLocked(); rx != nil && tx != nil && rx != tx && rx.freqHz != tx.freqHz {
st.Split = true
st.RXFreqHz = rx.freqHz
st.TXFreqHz = tx.freqHz
}
if _, rx := f.rxSliceLocked(); rx != nil {
st.RXAvail = true
st.Mode = strings.ToUpper(rx.mode)
@@ -1170,14 +1187,73 @@ func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level",
func (f *Flex) SetMute(on bool) error { return f.sendSlice("audio_mute", boolFlex(on)) }
func (f *Flex) SetRXAntenna(a string) error { return f.sendSlice("rxant", a) }
func (f *Flex) SetTXAntenna(a string) error { return f.sendSlice("txant", a) }
func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) }
func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) }
func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) }
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
// SetSplit toggles 2-slice split like SmartSDR's SPLIT button. ON creates a TX
// slice at RX freq + offset (+1 kHz on CW, +5 kHz otherwise) and makes it the
// transmitter; OFF removes the extra TX slice and returns to simplex (RX slice
// transmits again).
func (f *Flex) SetSplit(on bool) error {
f.mu.Lock()
if f.conn == nil {
f.mu.Unlock()
return fmt.Errorf("flex: not connected")
}
rx, tx := f.pickSlicesLocked()
rxIdx, txIdx := -1, -1
for i, s := range f.slices {
if s == rx {
rxIdx = i
}
if s == tx {
txIdx = i
}
}
alreadySplit := rx != nil && tx != nil && rx != tx
var rxFreq int64
var rxMode, rxAnt string
if rx != nil {
rxFreq, rxMode, rxAnt = rx.freqHz, rx.mode, rx.rxAnt
}
f.mu.Unlock()
if on {
if alreadySplit || rx == nil {
return nil // already split (or no slice)
}
offset := int64(5000)
if strings.HasPrefix(strings.ToUpper(rxMode), "CW") {
offset = 1000
}
cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(rxFreq+offset)/1e6, rxMode)
if rxAnt != "" {
cmd += " ant=" + rxAnt
}
if seq := f.send(cmd); seq > 0 {
f.mu.Lock()
f.pendingSplit[seq] = true // mark the new slice TX once its index returns
f.mu.Unlock()
}
return nil
}
if !alreadySplit {
return nil
}
if txIdx >= 0 {
f.send(fmt.Sprintf("slice remove %d", txIdx))
}
if rxIdx >= 0 {
f.send(fmt.Sprintf("slice s %d tx=1", rxIdx))
}
return nil
}
func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) }
func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) }
func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) }
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
// ── CW keyer controls (top-level "cw" commands) ──
+321
View File
@@ -0,0 +1,321 @@
//go:build windows
package cat
import (
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
// TCI is a native backend for Expert Electronics' TCI protocol (SunSDR2/MB1/
// ColibriNANO via ExpertSDR2/EESDR, and TCI-compatible apps). TCI is a text
// protocol over a WebSocket: the server streams state ("vfo:0,0,14100000;",
// "modulation:0,cw;", "trx:0,true;") and accepts the same commands to control
// the rig. We keep the pushed state cached so ReadState is instant, like Flex.
//
// Pure Go (gorilla/websocket, no CGO). Default port 40001.
type TCI struct {
host string
port int
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
spotsEnabled bool // mirror cluster spots onto the TCI panorama
mu sync.Mutex // guards conn + writes + state
conn *websocket.Conn
ready bool
// Cached state pushed by the radio.
device string
freqA int64 // VFO A (RX) frequency, Hz (vfo:0,0)
freqB int64 // VFO B (TX in split), Hz (vfo:0,1)
mode string
split bool
tx bool
lastSig string // last logged state signature (log only on change)
}
const tciDefaultPort = 40001
// NewTCI builds a TCI backend for the given host/port. digitalDefault is the
// mode surfaced when the radio reports a generic digital modulation; spots turns
// on mirroring OpsLog's cluster spots onto the TCI panorama.
func NewTCI(host string, port int, digitalDefault string, spots bool) *TCI {
if port <= 0 || port > 65535 {
port = tciDefaultPort
}
return &TCI{host: strings.TrimSpace(host), port: port, digitalDefault: strings.TrimSpace(digitalDefault), spotsEnabled: spots}
}
func (t *TCI) Name() string { return "tci" }
// Connect opens the WebSocket and starts the reader goroutine. The reader keeps
// our cached state current from the radio's push messages.
func (t *TCI) Connect() error {
t.mu.Lock()
already := t.conn != nil
host, port := t.host, t.port
t.mu.Unlock()
if already {
return nil
}
if host == "" {
return fmt.Errorf("tci: no host configured")
}
url := fmt.Sprintf("ws://%s", net.JoinHostPort(host, strconv.Itoa(port)))
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
conn, _, err := dialer.Dial(url, nil)
if err != nil {
return fmt.Errorf("tci: connect %s: %w", url, err)
}
t.mu.Lock()
t.conn = conn
t.ready = false
t.mu.Unlock()
debugLog.Printf("TCI: connected to %s", url)
go t.reader(conn)
if t.spotsEnabled {
_ = t.send("spot_clear;") // drop any leftover spots from a previous session
}
return nil
}
// SendSpot mirrors a cluster spot onto the TCI panorama (implements Spotter).
// The radio replaces a spot that has the same callsign, so re-spotting updates
// it in place. No-op when spot mirroring is disabled.
func (t *TCI) SendSpot(s SpotInfo) error {
if !t.spotsEnabled {
return nil
}
call := strings.TrimSpace(s.Callsign)
if call == "" || s.FreqHz <= 0 {
return nil
}
color := strings.TrimSpace(s.Color)
if color == "" {
color = "#FFFFA500" // opaque orange default
}
color = "0x" + strings.TrimPrefix(color, "#")
mode := strings.ToLower(strings.TrimSpace(s.Mode))
// Commas/semicolons would break TCI's comma-separated argument parsing.
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
return t.send(fmt.Sprintf("spot:%s,%s,%d,%s,%s;", call, mode, s.FreqHz, color, text))
}
// Disconnect closes the WebSocket; the reader goroutine then exits.
func (t *TCI) Disconnect() {
t.mu.Lock()
c := t.conn
t.conn = nil
t.ready = false
t.mu.Unlock()
if c != nil {
_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
_ = c.Close()
}
}
// ReadState returns the cached state pushed by the radio.
func (t *TCI) ReadState() (RigState, error) {
t.mu.Lock()
defer t.mu.Unlock()
if t.conn == nil {
return RigState{}, fmt.Errorf("tci: not connected")
}
st := RigState{Connected: t.ready, Rig: t.device}
if !t.ready {
return st, nil
}
// ADIF convention: FreqHz is the TX freq. In split, TX is VFO B.
if t.split && t.freqB > 0 {
st.FreqHz = t.freqB
st.RxFreqHz = t.freqA
st.Split = true
} else {
st.FreqHz = t.freqA
}
st.Mode = tciModeToADIF(t.mode, t.digitalDefault)
if st.FreqHz > 0 {
st.Band = BandFromHz(st.FreqHz)
}
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
if sig != t.lastSig {
t.lastSig = sig
debugLog.Printf("TCI: state tx=%d rx=%d split=%v mode=%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
}
return st, nil
}
// SetFrequency tunes VFO A (the main/RX VFO).
func (t *TCI) SetFrequency(hz int64) error {
return t.send(fmt.Sprintf("vfo:0,0,%d;", hz))
}
// SetMode maps an ADIF mode to a TCI modulation and sets it. USB vs LSB is
// chosen from the current VFO-A frequency (< 10 MHz → LSB).
func (t *TCI) SetMode(mode string) error {
t.mu.Lock()
freq := t.freqA
t.mu.Unlock()
m := adifToTCIMode(mode, freq)
if m == "" {
return nil
}
return t.send(fmt.Sprintf("modulation:0,%s;", m))
}
// SetPTT keys or unkeys the transmitter (VFO 0).
func (t *TCI) SetPTT(on bool) error {
return t.send(fmt.Sprintf("trx:0,%t;", on))
}
// send writes a command to the WebSocket (one writer at a time).
func (t *TCI) send(cmd string) error {
t.mu.Lock()
c := t.conn
t.mu.Unlock()
if c == nil {
return fmt.Errorf("tci: not connected")
}
_ = c.SetWriteDeadline(time.Now().Add(3 * time.Second))
if err := c.WriteMessage(websocket.TextMessage, []byte(cmd)); err != nil {
debugLog.Printf("TCI: send %q failed: %v", cmd, err)
return err
}
debugLog.Printf("TCI: → %s", cmd)
return nil
}
// reader drains push messages and keeps the cached state current until the
// connection closes.
func (t *TCI) reader(conn *websocket.Conn) {
for {
_, data, err := conn.ReadMessage()
if err != nil {
break
}
// A frame may carry several ";"-terminated commands.
for _, cmd := range strings.Split(string(data), ";") {
t.handle(strings.TrimSpace(cmd))
}
}
t.mu.Lock()
if t.conn == conn {
t.conn = nil
t.ready = false
}
t.mu.Unlock()
debugLog.Printf("TCI: reader ended")
}
// handle parses one "command:args" message and updates the cache.
func (t *TCI) handle(msg string) {
if msg == "" {
return
}
name, args := msg, ""
if i := strings.IndexByte(msg, ':'); i >= 0 {
name, args = msg[:i], msg[i+1:]
}
f := strings.Split(args, ",")
get := func(i int) string {
if i < len(f) {
return strings.TrimSpace(f[i])
}
return ""
}
t.mu.Lock()
defer t.mu.Unlock()
switch strings.ToLower(name) {
case "device":
t.device = strings.TrimSpace(args)
case "ready", "start":
t.ready = true
case "stop":
t.ready = false
case "vfo":
// vfo:<rx>,<channel>,<freq>
if get(0) == "0" {
hz, _ := strconv.ParseInt(get(2), 10, 64)
if hz > 0 {
t.ready = true // receiving live state → treat as ready even without an explicit "ready;"
switch get(1) {
case "0":
t.freqA = hz
case "1":
t.freqB = hz
}
}
}
case "modulation":
if get(0) == "0" {
t.mode = strings.ToLower(get(1))
}
case "split_enable":
if get(0) == "0" {
t.split = get(1) == "true"
}
case "trx":
if get(0) == "0" {
t.tx = get(1) == "true"
}
}
}
// tciModeToADIF converts a TCI modulation to an ADIF mode. Generic digital
// modulations surface the operator's chosen digital default (FT8/FT4/RTTY…).
func tciModeToADIF(m, digitalDefault string) string {
switch strings.ToLower(strings.TrimSpace(m)) {
case "usb", "lsb", "dsb":
return "SSB"
case "cw":
return "CW"
case "am", "sam":
return "AM"
case "nfm", "wfm", "fm":
return "FM"
case "digu", "digl":
if digitalDefault != "" {
return strings.ToUpper(digitalDefault)
}
return "DATA"
case "drm":
return "DIGITALVOICE"
case "":
return ""
default:
return strings.ToUpper(m)
}
}
// adifToTCIMode maps an ADIF mode to a TCI modulation. USB/LSB is chosen from
// the frequency (< 10 MHz → LSB) as usual. Digital modes → digu.
func adifToTCIMode(mode string, freqHz int64) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB", "USB", "LSB":
if freqHz > 0 && freqHz < 10_000_000 {
return "lsb"
}
return "usb"
case "CW", "CWR", "CW-R":
return "cw"
case "AM":
return "am"
case "FM", "NFM":
return "nfm"
case "RTTY":
return "digl"
case "":
return ""
default:
// FT8/FT4/PSK/DATA/JT… → upper-sideband digital.
return "digu"
}
}
+6
View File
@@ -16,6 +16,10 @@ import (
// subscription used elsewhere for callsign data — they're different keys.
const qrzAPIURL = "https://logbook.qrz.com/api"
// LogSink receives diagnostics such as raw QRZ responses. Set to applog.Printf
// at startup; defaults to a no-op so the package is usable without wiring.
var LogSink = func(string, ...any) {}
// UploadQRZ pushes one ADIF record to the QRZ.com logbook identified by
// apiKey. It returns OK when the QSO is inserted or already present
// (QRZ reports a duplicate as a FAIL with a "duplicate" reason, which we
@@ -67,6 +71,7 @@ func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord stri
if resp.StatusCode != http.StatusOK {
return UploadResult{}, fmt.Errorf("qrz: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
LogSink("qrz: INSERT raw response: %s", strings.TrimSpace(string(body)))
return parseQRZResponse(string(body))
}
@@ -171,6 +176,7 @@ func TestQRZ(ctx context.Context, client *http.Client, apiKey string) (string, e
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
LogSink("qrz: STATUS raw response: %s", strings.TrimSpace(string(body)))
vals, err := url.ParseQuery(strings.TrimSpace(string(body)))
if err != nil {
return "", fmt.Errorf("qrz: bad response: %w", err)
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.15"
appVersion = "0.16.2"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.