update
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
|||||||
"hamlog/internal/lookup"
|
"hamlog/internal/lookup"
|
||||||
"hamlog/internal/profile"
|
"hamlog/internal/profile"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
|
"hamlog/internal/rotator/pst"
|
||||||
"hamlog/internal/settings"
|
"hamlog/internal/settings"
|
||||||
|
|
||||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
@@ -53,6 +54,11 @@ const (
|
|||||||
keyCATPollMs = "cat.poll_ms"
|
keyCATPollMs = "cat.poll_ms"
|
||||||
keyCATDelayMs = "cat.delay_ms" // pause between commands
|
keyCATDelayMs = "cat.delay_ms" // pause between commands
|
||||||
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
||||||
|
|
||||||
|
keyRotatorEnabled = "rotator.enabled"
|
||||||
|
keyRotatorHost = "rotator.host"
|
||||||
|
keyRotatorPort = "rotator.port"
|
||||||
|
keyRotatorHasElevation = "rotator.has_elevation"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
||||||
@@ -473,14 +479,14 @@ func (a *App) OpenADIFFile() (string, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ImportADIF(path string) (adif.ImportResult, error) {
|
func (a *App) ImportADIF(path string, skipDuplicates bool) (adif.ImportResult, error) {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return adif.ImportResult{}, fmt.Errorf("db not initialized")
|
return adif.ImportResult{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return adif.ImportResult{}, fmt.Errorf("empty path")
|
return adif.ImportResult{}, fmt.Errorf("empty path")
|
||||||
}
|
}
|
||||||
im := &adif.Importer{Repo: a.qso}
|
im := &adif.Importer{Repo: a.qso, SkipDuplicates: skipDuplicates}
|
||||||
return im.ImportFile(a.ctx, path)
|
return im.ImportFile(a.ctx, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,3 +942,123 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error
|
|||||||
}
|
}
|
||||||
return a.profiles.Duplicate(a.ctx, id, newName)
|
return a.profiles.Duplicate(a.ctx, id, newName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Rotator bindings (PstRotator UDP v0) ---
|
||||||
|
|
||||||
|
// RotatorSettings is the JSON shape for the Hardware → Rotator panel.
|
||||||
|
type RotatorSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Host string `json:"host"` // default 127.0.0.1
|
||||||
|
Port int `json:"port"` // default 12000
|
||||||
|
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRotatorSettings returns the persisted rotator config with defaults.
|
||||||
|
func (a *App) GetRotatorSettings() (RotatorSettings, error) {
|
||||||
|
out := RotatorSettings{Host: "127.0.0.1", Port: 12000}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
|
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Enabled = m[keyRotatorEnabled] == "1"
|
||||||
|
if h := m[keyRotatorHost]; h != "" {
|
||||||
|
out.Host = h
|
||||||
|
}
|
||||||
|
if p, _ := strconv.Atoi(m[keyRotatorPort]); p > 0 && p <= 65535 {
|
||||||
|
out.Port = p
|
||||||
|
}
|
||||||
|
out.HasElevation = m[keyRotatorHasElevation] == "1"
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRotatorSettings persists the rotator config. Connection is per-call
|
||||||
|
// (UDP, no socket to (re)open) so no reload step is needed.
|
||||||
|
func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if s.Host == "" {
|
||||||
|
s.Host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if s.Port <= 0 || s.Port > 65535 {
|
||||||
|
s.Port = 12000
|
||||||
|
}
|
||||||
|
for k, v := range map[string]string{
|
||||||
|
keyRotatorEnabled: boolStr(s.Enabled),
|
||||||
|
keyRotatorHost: s.Host,
|
||||||
|
keyRotatorPort: strconv.Itoa(s.Port),
|
||||||
|
keyRotatorHasElevation: boolStr(s.HasElevation),
|
||||||
|
} {
|
||||||
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotatorClient returns a fresh PST UDP client built from current settings,
|
||||||
|
// or an error if the rotator is disabled / misconfigured.
|
||||||
|
func (a *App) rotatorClient() (*pst.Client, RotatorSettings, error) {
|
||||||
|
s, err := a.GetRotatorSettings()
|
||||||
|
if err != nil {
|
||||||
|
return nil, s, err
|
||||||
|
}
|
||||||
|
if !s.Enabled {
|
||||||
|
return nil, s, fmt.Errorf("rotator disabled in settings")
|
||||||
|
}
|
||||||
|
return pst.New(s.Host, s.Port), s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotatorGoTo points the antenna at the given azimuth (and optional
|
||||||
|
// elevation if the rotator is configured for it).
|
||||||
|
func (a *App) RotatorGoTo(az int, el int) error {
|
||||||
|
c, s, err := a.rotatorClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.GoTo(az, s.HasElevation, el)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotatorStop interrupts any in-progress rotation.
|
||||||
|
func (a *App) RotatorStop() error {
|
||||||
|
c, _, err := a.rotatorClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotatorPark moves the antenna to its parked position (configured in
|
||||||
|
// PstRotator itself).
|
||||||
|
func (a *App) RotatorPark() error {
|
||||||
|
c, _, err := a.rotatorClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.Park()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRotator sends a no-op GoTo to the rotator's current heading to
|
||||||
|
// verify the UDP link without actually moving the antenna. We use 0° as
|
||||||
|
// the test target — pick a known direction the user expects to see.
|
||||||
|
// Returns nil on success or a descriptive error.
|
||||||
|
func (a *App) TestRotator(s RotatorSettings) error {
|
||||||
|
if s.Host == "" {
|
||||||
|
s.Host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if s.Port <= 0 || s.Port > 65535 {
|
||||||
|
s.Port = 12000
|
||||||
|
}
|
||||||
|
return pst.New(s.Host, s.Port).GoTo(0, false, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolStr(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|||||||
+124
-34
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Hash, Loader2, Lock,
|
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Hash, Loader2, Lock,
|
||||||
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Send, Settings, Trash2, Unlock, X,
|
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Send, Settings, Square, Trash2, Unlock, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
SetCompactMode,
|
SetCompactMode,
|
||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
||||||
RefreshCtyDat,
|
RefreshCtyDat,
|
||||||
|
RotatorGoTo, RotatorStop,
|
||||||
GetCATSettings,
|
GetCATSettings,
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime';
|
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime';
|
||||||
@@ -32,6 +33,10 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
@@ -344,6 +349,11 @@ export default function App() {
|
|||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
const [importErrorsOpen, setImportErrorsOpen] = useState(false);
|
const [importErrorsOpen, setImportErrorsOpen] = useState(false);
|
||||||
|
const [importDupsOpen, setImportDupsOpen] = useState(false);
|
||||||
|
// ADIF import confirmation: after the user picks a file, hold the path
|
||||||
|
// until they confirm the options (skip duplicates etc.).
|
||||||
|
const [pendingImportPath, setPendingImportPath] = useState<string | null>(null);
|
||||||
|
const [importSkipDups, setImportSkipDups] = useState(true);
|
||||||
|
|
||||||
// === Lookup + WB ===
|
// === Lookup + WB ===
|
||||||
const [lookupResult, setLookupResult] = useState<LookupResult | null>(null);
|
const [lookupResult, setLookupResult] = useState<LookupResult | null>(null);
|
||||||
@@ -673,14 +683,29 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const path = await OpenADIFFile();
|
const path = await OpenADIFFile();
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
setImporting(true);
|
// Stash the path and open the options dialog. The actual import
|
||||||
setImportResult(null);
|
// is fired from runImport() when the user clicks "Import".
|
||||||
setImportErrorsOpen(false);
|
setPendingImportPath(path);
|
||||||
const res = await ImportADIF(path);
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runImport() {
|
||||||
|
const path = pendingImportPath;
|
||||||
|
if (!path || importing) return;
|
||||||
|
setPendingImportPath(null);
|
||||||
|
setImporting(true);
|
||||||
|
setImportResult(null);
|
||||||
|
setImportErrorsOpen(false);
|
||||||
|
setImportDupsOpen(false);
|
||||||
|
try {
|
||||||
|
const res = await ImportADIF(path, importSkipDups);
|
||||||
setImportResult(res);
|
setImportResult(res);
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} catch (e: any) {
|
||||||
finally { setImporting(false); }
|
setError(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const menus: Menu[] = useMemo(() => [
|
const menus: Menu[] = useMemo(() => [
|
||||||
@@ -820,37 +845,55 @@ export default function App() {
|
|||||||
<div className="w-px h-4 bg-border mx-2" />
|
<div className="w-px h-4 bg-border mx-2" />
|
||||||
<Badge variant="accent" className="font-mono">{band}</Badge>
|
<Badge variant="accent" className="font-mono">{band}</Badge>
|
||||||
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono" variant="outline">{mode}</Badge>
|
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 font-mono" variant="outline">{mode}</Badge>
|
||||||
{/* Bearing pill — clickable hook for the future rotator action.
|
{/* Bearing controls — three separate buttons so SP and LP are
|
||||||
Today it's a passive display; once the rotor backend lands we
|
both directly clickable, plus an always-visible Stop. The
|
||||||
wire onClick → rotate to short-path azimuth. */}
|
old Shift/Ctrl shortcuts were not discoverable enough. */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const p = pathBetween(station.my_grid, grid);
|
const p = pathBetween(station.my_grid, grid);
|
||||||
const disabled = !p;
|
const disabled = !p;
|
||||||
|
const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)));
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="inline-flex items-center rounded-full border border-sky-300 bg-sky-100 overflow-hidden text-[11px] font-mono font-semibold">
|
||||||
type="button"
|
<button
|
||||||
disabled={disabled}
|
type="button"
|
||||||
onClick={() => { /* TODO: rotor.gotoAzimuth(p.bearingShort) */ }}
|
disabled={disabled}
|
||||||
title={
|
onClick={() => p && goto(p.bearingShort)}
|
||||||
p
|
title={p
|
||||||
? `Short-path ${Math.round(p.bearingShort)}° · ${Math.round(p.distanceShort).toLocaleString()} km\nLong-path ${Math.round(p.bearingLong)}° · ${Math.round(p.distanceLong).toLocaleString()} km\n(rotor control coming soon)`
|
? `Rotate short-path · ${Math.round(p.distanceShort).toLocaleString()} km`
|
||||||
: (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')
|
: (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')}
|
||||||
}
|
className={cn(
|
||||||
className={cn(
|
'inline-flex items-center gap-1 px-2 py-0.5 transition-colors',
|
||||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-mono font-semibold border transition-colors',
|
disabled
|
||||||
disabled
|
? 'text-muted-foreground/60 cursor-not-allowed'
|
||||||
? 'bg-muted/40 text-muted-foreground/60 border-border/40 cursor-not-allowed'
|
: 'text-sky-800 hover:bg-sky-200 cursor-pointer',
|
||||||
: 'bg-sky-100 text-sky-800 border-sky-300 hover:bg-sky-200 cursor-pointer',
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<Compass className="size-3" />
|
||||||
<Compass className="size-3" />
|
{p ? `${Math.round(p.bearingShort)}°` : '—°'}
|
||||||
{p ? `${Math.round(p.bearingShort)}°` : '—°'}
|
</button>
|
||||||
{p && (
|
<button
|
||||||
<span className="text-[9px] text-sky-700/80 font-medium ml-0.5">
|
type="button"
|
||||||
LP {Math.round(p.bearingLong)}°
|
disabled={disabled}
|
||||||
</span>
|
onClick={() => p && goto(p.bearingLong)}
|
||||||
)}
|
title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''}
|
||||||
</button>
|
className={cn(
|
||||||
|
'px-1.5 py-0.5 border-l border-sky-300 text-[10px] transition-colors',
|
||||||
|
disabled
|
||||||
|
? 'text-muted-foreground/60 cursor-not-allowed'
|
||||||
|
: 'text-sky-800 hover:bg-sky-200 cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
LP {p ? `${Math.round(p.bearingLong)}°` : '—'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => RotatorStop().catch((err) => setError(String(err?.message ?? err)))}
|
||||||
|
title="Stop rotation"
|
||||||
|
className="px-1.5 py-0.5 border-l border-sky-300 text-rose-700 hover:bg-rose-100 hover:text-rose-800 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<Square className="size-2.5 fill-current" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{catState.enabled && (
|
{catState.enabled && (
|
||||||
@@ -1226,8 +1269,19 @@ export default function App() {
|
|||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<strong>Import complete.</strong>
|
<strong>Import complete.</strong>
|
||||||
<Badge variant="outline" className="bg-white/60 font-mono text-emerald-700 border-emerald-300">{importResult.imported} imported</Badge>
|
<Badge variant="outline" className="bg-white/60 font-mono text-emerald-700 border-emerald-300">{importResult.imported} imported</Badge>
|
||||||
|
{importResult.duplicates > 0 && (
|
||||||
|
<Badge variant="outline" className="bg-white/60 font-mono text-sky-700 border-sky-300">{importResult.duplicates} duplicates</Badge>
|
||||||
|
)}
|
||||||
<Badge variant="outline" className="bg-white/60 font-mono text-amber-700 border-amber-300">{importResult.skipped} skipped</Badge>
|
<Badge variant="outline" className="bg-white/60 font-mono text-amber-700 border-amber-300">{importResult.skipped} skipped</Badge>
|
||||||
<Badge variant="outline" className="bg-white/60 font-mono">{importResult.total} total</Badge>
|
<Badge variant="outline" className="bg-white/60 font-mono">{importResult.total} total</Badge>
|
||||||
|
{importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
|
||||||
|
<button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}>
|
||||||
|
{importDupsOpen ? 'Hide' : 'Show'} duplicates
|
||||||
|
{importResult.duplicates > importResult.duplicate_samples.length
|
||||||
|
? ` (first ${importResult.duplicate_samples.length} of ${importResult.duplicates})`
|
||||||
|
: ''}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{importResult.errors && importResult.errors.length > 0 && (
|
{importResult.errors && importResult.errors.length > 0 && (
|
||||||
<button className="underline text-xs" onClick={() => setImportErrorsOpen((v) => !v)}>
|
<button className="underline text-xs" onClick={() => setImportErrorsOpen((v) => !v)}>
|
||||||
{importErrorsOpen ? 'Hide' : 'Show'} {importResult.errors.length} error{importResult.errors.length > 1 ? 's' : ''}
|
{importErrorsOpen ? 'Hide' : 'Show'} {importResult.errors.length} error{importResult.errors.length > 1 ? 's' : ''}
|
||||||
@@ -1235,6 +1289,11 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
<button className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button>
|
<button className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
{importDupsOpen && importResult.duplicate_samples && (
|
||||||
|
<ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
|
||||||
|
{importResult.duplicate_samples.map((d, i) => <li key={i}>{d}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
{importErrorsOpen && importResult.errors && (
|
{importErrorsOpen && importResult.errors && (
|
||||||
<ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
|
<ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
|
||||||
{importResult.errors.map((e, i) => <li key={i}>{e}</li>)}
|
{importResult.errors.map((e, i) => <li key={i}>{e}</li>)}
|
||||||
@@ -1352,6 +1411,37 @@ export default function App() {
|
|||||||
onCancel={() => { if (!deletingAll) setShowDeleteAll(false); }}
|
onCancel={() => { if (!deletingAll) setShowDeleteAll(false); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{pendingImportPath && (
|
||||||
|
<Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
|
||||||
|
<DialogContent className="max-w-lg px-6">
|
||||||
|
<DialogHeader className="px-2">
|
||||||
|
<DialogTitle>Import ADIF</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs break-all">
|
||||||
|
{pendingImportPath}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2 px-2 space-y-3">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={importSkipDups}
|
||||||
|
onCheckedChange={(c) => setImportSkipDups(!!c)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Skip duplicates
|
||||||
|
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||||
|
Records that match an existing QSO on (callsign + UTC minute + band + mode) are not re-inserted. Uncheck to import everything as-is — useful for merging two logs that overlap intentionally.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="px-2">
|
||||||
|
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button>
|
||||||
|
<Button onClick={runImport}>Import</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
GetListsSettings, SaveListsSettings,
|
GetListsSettings, SaveListsSettings,
|
||||||
GetCATSettings, SaveCATSettings,
|
GetCATSettings, SaveCATSettings,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
|
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -33,6 +34,7 @@ type StationSettings = StationSettingsForm;
|
|||||||
type ListsSettings = ListsSettingsForm;
|
type ListsSettings = ListsSettingsForm;
|
||||||
type ModePreset = ModePresetForm;
|
type ModePreset = ModePresetForm;
|
||||||
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
|
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
|
||||||
|
type RotatorSettings = Omit<mainModels.RotatorSettings, 'convertValues'>;
|
||||||
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||||
|
|
||||||
const emptyProfile = (): Profile => ({
|
const emptyProfile = (): Profile => ({
|
||||||
@@ -100,7 +102,7 @@ const TREE: TreeNode[] = [
|
|||||||
{
|
{
|
||||||
kind: 'group', label: 'Hardware Configuration', icon: Server, children: [
|
kind: 'group', label: 'Hardware Configuration', icon: Server, children: [
|
||||||
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
|
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
|
||||||
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator', disabled: true },
|
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
|
||||||
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
|
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
|
||||||
{ kind: 'item', label: 'Audio devices', id: 'audio', disabled: true },
|
{ kind: 'item', label: 'Audio devices', id: 'audio', disabled: true },
|
||||||
],
|
],
|
||||||
@@ -243,6 +245,11 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0,
|
||||||
digital_default: 'FT8',
|
digital_default: 'FT8',
|
||||||
});
|
});
|
||||||
|
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||||
|
enabled: false, host: '127.0.0.1', port: 12000, has_elevation: false,
|
||||||
|
});
|
||||||
|
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||||
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||||
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
|
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
|
||||||
// the panel as a plain function, not as a JSX element, so any useState
|
// the panel as a plain function, not as a JSX element, so any useState
|
||||||
@@ -278,8 +285,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [l, ls, c, ap] = await Promise.all([
|
const [l, ls, c, ap, r] = await Promise.all([
|
||||||
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
||||||
|
GetRotatorSettings(),
|
||||||
]);
|
]);
|
||||||
setLookup(l);
|
setLookup(l);
|
||||||
setActiveProfile(ap as Profile);
|
setActiveProfile(ap as Profile);
|
||||||
@@ -287,6 +295,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
await reloadProfiles();
|
await reloadProfiles();
|
||||||
setBandsText((ls.bands ?? []).join('\n'));
|
setBandsText((ls.bands ?? []).join('\n'));
|
||||||
setCatCfg(c);
|
setCatCfg(c);
|
||||||
|
setRotator(r);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(String(e?.message ?? e));
|
setErr(String(e?.message ?? e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -356,6 +365,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
}
|
}
|
||||||
await SaveLookupSettings(lookup as any);
|
await SaveLookupSettings(lookup as any);
|
||||||
await SaveCATSettings(catCfg as any);
|
await SaveCATSettings(catCfg as any);
|
||||||
|
await SaveRotatorSettings(rotator as any);
|
||||||
|
|
||||||
setMsg('Settings saved.');
|
setMsg('Settings saved.');
|
||||||
onSaved();
|
onSaved();
|
||||||
@@ -858,6 +868,85 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testRotator() {
|
||||||
|
setRotatorTesting(true);
|
||||||
|
setRotatorTest(null);
|
||||||
|
try {
|
||||||
|
await TestRotator(rotator as any);
|
||||||
|
setRotatorTest({ ok: true, msg: 'Packet sent — antenna should swing to 0° (north). If it didn\'t, check PstRotator host/port and that PstRotator\'s UDP listener is enabled.' });
|
||||||
|
} catch (e: any) {
|
||||||
|
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
|
} finally {
|
||||||
|
setRotatorTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RotatorPanel() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Rotator (PstRotator)"
|
||||||
|
hint="HamLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing."
|
||||||
|
/>
|
||||||
|
<div className="space-y-4 max-w-xl">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
|
||||||
|
Enable PstRotator control
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Host</Label>
|
||||||
|
<Input
|
||||||
|
value={rotator.host ?? ''}
|
||||||
|
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
||||||
|
placeholder="127.0.0.1"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>UDP port</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={1} max={65535}
|
||||||
|
value={rotator.port}
|
||||||
|
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || 12000 }))}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
||||||
|
This rotator supports elevation (VHF / satellite)
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
||||||
|
{rotatorTesting ? 'Sending…' : 'Test (point to 0°)'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||||
|
Park
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{rotatorTest && (
|
||||||
|
<div className={cn(
|
||||||
|
'text-xs rounded-md p-2.5 border',
|
||||||
|
rotatorTest.ok
|
||||||
|
? 'bg-emerald-50 text-emerald-800 border-emerald-200'
|
||||||
|
: 'bg-destructive/10 text-destructive border-destructive/30',
|
||||||
|
)}>
|
||||||
|
{rotatorTest.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
From the main entry strip, click the bearing pill to rotate to the short-path azimuth.
|
||||||
|
Shift+click for long-path, Ctrl+click to stop.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Map sections to their content + icon (for placeholder).
|
// Map sections to their content + icon (for placeholder).
|
||||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||||
station: StationPanel,
|
station: StationPanel,
|
||||||
@@ -869,7 +958,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
backup: () => <ComingSoon id="backup" icon={Database} />,
|
backup: () => <ComingSoon id="backup" icon={Database} />,
|
||||||
awards: () => <ComingSoon id="awards" icon={Award} />,
|
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||||
cat: CATPanel,
|
cat: CATPanel,
|
||||||
rotator: () => <ComingSoon id="rotator" icon={Compass} />,
|
rotator: RotatorPanel,
|
||||||
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
|
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
|
||||||
audio: () => <ComingSoon id="audio" icon={Server} />,
|
audio: () => <ComingSoon id="audio" icon={Server} />,
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+13
-1
@@ -37,11 +37,13 @@ export function GetLookupSettings():Promise<main.LookupSettings>;
|
|||||||
|
|
||||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
|
|
||||||
|
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||||
|
|
||||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|
||||||
export function GetStationSettings():Promise<main.StationSettings>;
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
|
|
||||||
export function ImportADIF(arg1:string):Promise<adif.ImportResult>;
|
export function ImportADIF(arg1:string,arg2:boolean):Promise<adif.ImportResult>;
|
||||||
|
|
||||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||||
|
|
||||||
@@ -53,6 +55,12 @@ export function OpenADIFFile():Promise<string>;
|
|||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function RotatorPark():Promise<void>;
|
||||||
|
|
||||||
|
export function RotatorStop():Promise<void>;
|
||||||
|
|
||||||
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||||
@@ -61,6 +69,8 @@ export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||||
|
|
||||||
|
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||||
|
|
||||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||||
@@ -73,6 +83,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
|
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||||
|
|
||||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export function GetQSO(arg1) {
|
|||||||
return window['go']['main']['App']['GetQSO'](arg1);
|
return window['go']['main']['App']['GetQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetRotatorSettings() {
|
||||||
|
return window['go']['main']['App']['GetRotatorSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetStartupStatus() {
|
export function GetStartupStatus() {
|
||||||
return window['go']['main']['App']['GetStartupStatus']();
|
return window['go']['main']['App']['GetStartupStatus']();
|
||||||
}
|
}
|
||||||
@@ -70,8 +74,8 @@ export function GetStationSettings() {
|
|||||||
return window['go']['main']['App']['GetStationSettings']();
|
return window['go']['main']['App']['GetStationSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportADIF(arg1) {
|
export function ImportADIF(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ImportADIF'](arg1);
|
return window['go']['main']['App']['ImportADIF'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListProfiles() {
|
export function ListProfiles() {
|
||||||
@@ -94,6 +98,18 @@ export function RefreshCtyDat() {
|
|||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RotatorGoTo(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RotatorPark() {
|
||||||
|
return window['go']['main']['App']['RotatorPark']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RotatorStop() {
|
||||||
|
return window['go']['main']['App']['RotatorStop']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveCATSettings(arg1) {
|
export function SaveCATSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveCATSettings'](arg1);
|
return window['go']['main']['App']['SaveCATSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -110,6 +126,10 @@ export function SaveProfile(arg1) {
|
|||||||
return window['go']['main']['App']['SaveProfile'](arg1);
|
return window['go']['main']['App']['SaveProfile'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveRotatorSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveStationSettings(arg1) {
|
export function SaveStationSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -134,6 +154,10 @@ export function TestLookupProvider(arg1, arg2, arg3, arg4) {
|
|||||||
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestRotator(arg1) {
|
||||||
|
return window['go']['main']['App']['TestRotator'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function UpdateQSO(arg1) {
|
export function UpdateQSO(arg1) {
|
||||||
return window['go']['main']['App']['UpdateQSO'](arg1);
|
return window['go']['main']['App']['UpdateQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export namespace adif {
|
|||||||
total: number;
|
total: number;
|
||||||
imported: number;
|
imported: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
|
duplicates: number;
|
||||||
|
duplicate_samples: string[];
|
||||||
errors: string[];
|
errors: string[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -15,6 +17,8 @@ export namespace adif {
|
|||||||
this.total = source["total"];
|
this.total = source["total"];
|
||||||
this.imported = source["imported"];
|
this.imported = source["imported"];
|
||||||
this.skipped = source["skipped"];
|
this.skipped = source["skipped"];
|
||||||
|
this.duplicates = source["duplicates"];
|
||||||
|
this.duplicate_samples = source["duplicate_samples"];
|
||||||
this.errors = source["errors"];
|
this.errors = source["errors"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,6 +270,24 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RotatorSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
has_elevation: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RotatorSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.has_elevation = source["has_elevation"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class StartupStatus {
|
export class StartupStatus {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
err: string;
|
err: string;
|
||||||
|
|||||||
+85
-9
@@ -13,18 +13,24 @@ import (
|
|||||||
|
|
||||||
// ImportResult summarises an ADIF import for the UI.
|
// ImportResult summarises an ADIF import for the UI.
|
||||||
type ImportResult struct {
|
type ImportResult struct {
|
||||||
Total int `json:"total"` // records found in the file
|
Total int `json:"total"` // records found in the file
|
||||||
Imported int `json:"imported"` // successfully inserted
|
Imported int `json:"imported"` // successfully inserted
|
||||||
Skipped int `json:"skipped"` // dropped (missing required fields, etc.)
|
Skipped int `json:"skipped"` // dropped (missing required fields)
|
||||||
Errors []string `json:"errors"` // up to maxErrors error messages
|
Duplicates int `json:"duplicates"` // matched an existing or earlier-in-file QSO
|
||||||
|
DuplicateSamples []string `json:"duplicate_samples"` // up to maxDuplicateSamples human-readable IDs of skipped duplicates
|
||||||
|
Errors []string `json:"errors"` // up to maxErrors error messages
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxErrors = 50
|
const (
|
||||||
|
maxErrors = 50
|
||||||
|
maxDuplicateSamples = 200 // cap memory + JSON payload size
|
||||||
|
)
|
||||||
|
|
||||||
// Importer streams an ADI file into a QSO repository.
|
// Importer streams an ADI file into a QSO repository.
|
||||||
type Importer struct {
|
type Importer struct {
|
||||||
Repo *qso.Repo
|
Repo *qso.Repo
|
||||||
BatchSize int // 0 → 500
|
BatchSize int // 0 → 500
|
||||||
|
SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportFile opens the file at path and imports it into the repo.
|
// ImportFile opens the file at path and imports it into the repo.
|
||||||
@@ -47,6 +53,16 @@ func (im *Importer) Import(ctx context.Context, r interface {
|
|||||||
res := ImportResult{}
|
res := ImportResult{}
|
||||||
batch := make([]qso.QSO, 0, im.BatchSize)
|
batch := make([]qso.QSO, 0, im.BatchSize)
|
||||||
|
|
||||||
|
// One upfront query for every existing dedup key — cheaper than N
|
||||||
|
// per-record EXISTS calls. The same map gets new keys appended as we
|
||||||
|
// import so duplicates inside the file are caught too. Loaded
|
||||||
|
// unconditionally so the "duplicates" count is meaningful even when
|
||||||
|
// SkipDuplicates is off (the user still wants to know how many).
|
||||||
|
seen, err := im.Repo.ExistingDedupeKeys(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("load dedupe keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
flush := func() error {
|
flush := func() error {
|
||||||
if len(batch) == 0 {
|
if len(batch) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -57,7 +73,7 @@ func (im *Importer) Import(ctx context.Context, r interface {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err := Parse(r, func(rec Record) error {
|
err = Parse(r, func(rec Record) error {
|
||||||
res.Total++
|
res.Total++
|
||||||
q, ok := recordToQSO(rec)
|
q, ok := recordToQSO(rec)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -68,6 +84,24 @@ func (im *Importer) Import(ctx context.Context, r interface {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
||||||
|
if _, dup := seen[key]; dup {
|
||||||
|
res.Duplicates++
|
||||||
|
if len(res.DuplicateSamples) < maxDuplicateSamples {
|
||||||
|
res.DuplicateSamples = append(res.DuplicateSamples,
|
||||||
|
fmt.Sprintf("%s · %s · %s · %s",
|
||||||
|
q.Callsign,
|
||||||
|
q.QSODate.UTC().Format("2006-01-02 15:04"),
|
||||||
|
q.Band, q.Mode))
|
||||||
|
}
|
||||||
|
if im.SkipDuplicates {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Fall through: insert anyway. Don't add to seen[] — keeps
|
||||||
|
// the duplicate count meaningful if the same row appears 3x.
|
||||||
|
} else {
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
}
|
||||||
batch = append(batch, q)
|
batch = append(batch, q)
|
||||||
if len(batch) >= im.BatchSize {
|
if len(batch) >= im.BatchSize {
|
||||||
return flush()
|
return flush()
|
||||||
@@ -137,10 +171,21 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
|||||||
}
|
}
|
||||||
band := strings.ToLower(strings.TrimSpace(rec["band"]))
|
band := strings.ToLower(strings.TrimSpace(rec["band"]))
|
||||||
mode := strings.ToUpper(strings.TrimSpace(rec["mode"]))
|
mode := strings.ToUpper(strings.TrimSpace(rec["mode"]))
|
||||||
|
submode := strings.ToUpper(strings.TrimSpace(rec["submode"]))
|
||||||
date := parseDateTime(rec["qso_date"], rec["time_on"])
|
date := parseDateTime(rec["qso_date"], rec["time_on"])
|
||||||
if date.IsZero() || band == "" || mode == "" {
|
if date.IsZero() || band == "" || mode == "" {
|
||||||
return qso.QSO{}, false
|
return qso.QSO{}, false
|
||||||
}
|
}
|
||||||
|
// ADIF promotes specific digital flavours into the SUBMODE field with
|
||||||
|
// a generic parent in MODE (e.g. MODE=MFSK SUBMODE=FT4). Loggers
|
||||||
|
// uniformly display the submode in that case — promote it so all
|
||||||
|
// downstream code (entry strip, worked-before, exports) sees "FT4"
|
||||||
|
// rather than "MFSK". SSB+USB/LSB and RTTY+JOH stay on the parent
|
||||||
|
// because the parent IS the displayed mode there.
|
||||||
|
if submode != "" && submodeSubsumesParent(submode) {
|
||||||
|
mode = submode
|
||||||
|
submode = "" // redundant once promoted
|
||||||
|
}
|
||||||
|
|
||||||
q := qso.QSO{
|
q := qso.QSO{
|
||||||
Callsign: call,
|
Callsign: call,
|
||||||
@@ -149,7 +194,7 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
|||||||
Band: band,
|
Band: band,
|
||||||
BandRX: strings.ToLower(rec["band_rx"]),
|
BandRX: strings.ToLower(rec["band_rx"]),
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
Submode: strings.ToUpper(rec["submode"]),
|
Submode: submode,
|
||||||
}
|
}
|
||||||
if hz, ok := parseFreqHz(rec["freq"]); ok {
|
if hz, ok := parseFreqHz(rec["freq"]); ok {
|
||||||
q.FreqHz = &hz
|
q.FreqHz = &hz
|
||||||
@@ -362,3 +407,34 @@ func parseFloat(s string) (float64, bool) {
|
|||||||
}
|
}
|
||||||
return v, true
|
return v, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// promotableSubmodes are SUBMODE values that uniquely identify the mode
|
||||||
|
// every logger displays — the ADIF parent (MFSK, DATA, PSK, JT…) is
|
||||||
|
// generic and unhelpful to show. SSB's USB/LSB and RTTY's JOH are NOT
|
||||||
|
// here because operators want to see "SSB" / "RTTY", not the sideband
|
||||||
|
// or RTTY variant.
|
||||||
|
var promotableSubmodes = map[string]bool{
|
||||||
|
// MFSK family (modern FT/Q65/JS8/MSK144 live here)
|
||||||
|
"FT2": true, "FT4": true, "FT8": true, "JS8": true, "MSK144": true, "ISCAT": true,
|
||||||
|
"Q65": true, "FST4": true, "FST4W": true,
|
||||||
|
"MFSK16": true, "MFSK32": true, "MFSK64": true, "MFSK128": true,
|
||||||
|
"OLIVIA": true,
|
||||||
|
// JT family (some loggers still parent these under DATA)
|
||||||
|
"JT65": true, "JT9": true, "JT4": true, "JT6M": true, "JT44": true, "T10": true,
|
||||||
|
// PSK family
|
||||||
|
"PSK31": true, "PSK63": true, "PSK125": true, "PSK250": true, "PSK500": true,
|
||||||
|
"QPSK31": true, "QPSK63": true, "QPSK125": true, "QPSK250": true, "QPSK500": true,
|
||||||
|
// THOR / DOMINO / HELL
|
||||||
|
"THOR4": true, "THOR8": true, "THOR16": true, "THOR32": true,
|
||||||
|
"DOMINOF": true, "DOMINOEX": true,
|
||||||
|
"HELL80": true, "FMHELL": true,
|
||||||
|
// DIGITALVOICE variants
|
||||||
|
"FREEDV": true,
|
||||||
|
// VARA family — Log4OM parents these under DYNAMIC. VarAC chat uses
|
||||||
|
// "VARA HF" (with the space) so we match both spellings.
|
||||||
|
"VARA": true, "VARA HF": true, "VARA FM": true, "VARAC": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func submodeSubsumesParent(submode string) bool {
|
||||||
|
return promotableSubmodes[submode]
|
||||||
|
}
|
||||||
|
|||||||
@@ -807,6 +807,39 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
|||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
|
||||||
|
// used by the ADIF importer to skip records that would re-create the
|
||||||
|
// same contact. The key is callsign|YYYY-MM-DDTHH:MM|band|mode — minute
|
||||||
|
// precision so two loggers that wrote a few seconds apart still match.
|
||||||
|
//
|
||||||
|
// On a 25k-row table this returns ~25k strings (~2MB RAM) in one pass —
|
||||||
|
// far cheaper than N exists-queries during the import loop.
|
||||||
|
func (r *Repo) ExistingDedupeKeys(ctx context.Context) (map[string]struct{}, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT callsign, strftime('%Y-%m-%dT%H:%M', qso_date), band, mode
|
||||||
|
FROM qso`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]struct{}, 1024)
|
||||||
|
for rows.Next() {
|
||||||
|
var call, when, band, mode string
|
||||||
|
if err := rows.Scan(&call, &when, &band, &mode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[DedupeKey(call, when, band, mode)] = struct{}{}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DedupeKey is the canonical dedupe identity for a QSO. Exposed so the
|
||||||
|
// importer can compute the key from in-flight records and check against
|
||||||
|
// the same map ExistingDedupeKeys returns.
|
||||||
|
func DedupeKey(callsign, qsoDateMinute, band, mode string) string {
|
||||||
|
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode)
|
||||||
|
}
|
||||||
|
|
||||||
// scanner is what both *sql.Row and *sql.Rows satisfy for our needs.
|
// scanner is what both *sql.Row and *sql.Rows satisfy for our needs.
|
||||||
type scanner interface {
|
type scanner interface {
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Package pst sends commands to PstRotator over its UDP listener.
|
||||||
|
//
|
||||||
|
// PstRotator (Codrut Buda YO3DMU) exposes a simple text/XML protocol on
|
||||||
|
// a configurable UDP port (default 12000 on localhost). Each command is a
|
||||||
|
// single fire-and-forget datagram — no handshake, no response. This keeps
|
||||||
|
// us connectionless and means a misconfigured port silently no-ops rather
|
||||||
|
// than hanging the UI. Run the matching "Test" action to confirm the link.
|
||||||
|
package pst
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is a stateless UDP sender. Safe to construct cheaply per call —
|
||||||
|
// the underlying socket only lives for the length of one Write.
|
||||||
|
type Client struct {
|
||||||
|
Host string // hostname or IP of the PstRotator host (usually "127.0.0.1")
|
||||||
|
Port int // UDP port (PstRotator default = 12000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Client with sane defaults applied for empty fields.
|
||||||
|
func New(host string, port int) *Client {
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
port = 12000
|
||||||
|
}
|
||||||
|
return &Client{Host: host, Port: port}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoTo points the antenna at azimuth (0-359°). If hasElevation is true
|
||||||
|
// and el >= 0 the elevation field is included too (VHF/satellite setups);
|
||||||
|
// otherwise PstRotator just turns in azimuth.
|
||||||
|
func (c *Client) GoTo(az int, hasElevation bool, el int) error {
|
||||||
|
az = ((az % 360) + 360) % 360 // normalise to [0,360)
|
||||||
|
if hasElevation && el >= 0 && el <= 180 {
|
||||||
|
return c.send(fmt.Sprintf("<PST><AZIMUTH>%d</AZIMUTH><ELEVATION>%d</ELEVATION></PST>", az, el))
|
||||||
|
}
|
||||||
|
return c.send(fmt.Sprintf("<PST><AZIMUTH>%d</AZIMUTH></PST>", az))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop interrupts any in-progress rotation.
|
||||||
|
func (c *Client) Stop() error {
|
||||||
|
return c.send("<PST><STOP>1</STOP></PST>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Park sends the rotator to its parked position (configured inside
|
||||||
|
// PstRotator itself — we just trigger it).
|
||||||
|
func (c *Client) Park() error {
|
||||||
|
return c.send("<PST><PARK>1</PARK></PST>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) send(payload string) error {
|
||||||
|
addr := fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||||
|
conn, err := net.DialTimeout("udp", addr, 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dial PstRotator at %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
if _, err := conn.Write([]byte(payload)); err != nil {
|
||||||
|
return fmt.Errorf("send to PstRotator: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user