This commit is contained in:
2026-06-07 02:51:00 +02:00
parent 16c04fc12b
commit 8040a37315
11 changed files with 1150 additions and 224 deletions
+236 -25
View File
@@ -36,6 +36,7 @@ import (
"hamlog/internal/profile" "hamlog/internal/profile"
"hamlog/internal/qso" "hamlog/internal/qso"
"hamlog/internal/rotator/pst" "hamlog/internal/rotator/pst"
"hamlog/internal/ultrabeam"
"hamlog/internal/winkeyer" "hamlog/internal/winkeyer"
"hamlog/internal/settings" "hamlog/internal/settings"
@@ -122,6 +123,11 @@ const (
keyRotatorPort = "rotator.port" keyRotatorPort = "rotator.port"
keyRotatorHasElevation = "rotator.has_elevation" keyRotatorHasElevation = "rotator.has_elevation"
// Ultrabeam antenna (TCP, e.g. via an RS232↔Ethernet adapter) — Hardware → Antenna.
keyUltrabeamEnabled = "ultrabeam.enabled"
keyUltrabeamHost = "ultrabeam.host"
keyUltrabeamPort = "ultrabeam.port"
// WinKeyer CW keyer (serial) — Hardware → CW Keyer. // WinKeyer CW keyer (serial) — Hardware → CW Keyer.
keyWKEnabled = "winkeyer.enabled" keyWKEnabled = "winkeyer.enabled"
keyWKPort = "winkeyer.port" keyWKPort = "winkeyer.port"
@@ -344,6 +350,7 @@ type App struct {
extsvc *extsvc.Manager extsvc *extsvc.Manager
winkeyer *winkeyer.Manager winkeyer *winkeyer.Manager
clublog *clublog.Manager clublog *clublog.Manager
ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled
audioMgr *audio.Manager audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
@@ -351,6 +358,7 @@ type App struct {
pttMu sync.Mutex pttMu sync.Mutex
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupErr string // captured for surfacing to the frontend startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location) dbPath string // active database file (may be a user-chosen location)
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
@@ -670,10 +678,20 @@ func (a *App) startup(ctx context.Context) {
// Digital Voice Keyer + QSO recorder (WASAPI). Idle until used. // Digital Voice Keyer + QSO recorder (WASAPI). Idle until used.
a.audioMgr = audio.NewManager(func() { a.audioMgr = audio.NewManager(func() {
st := a.dvkStatus() st := a.dvkStatus()
// When a voice message finishes (or is stopped), drop CAT PTT. // When a voice message finishes (or is stopped), drop the PTT we keyed
if !st.Playing && a.dvkPttKeyed { // for it — but tag the release with the current key generation so it
// can't cut a transmission a newer message already started.
if !st.Playing {
a.pttMu.Lock()
keyed := a.dvkPttKeyed
gen := a.pttGen
if keyed {
a.dvkPttKeyed = false a.dvkPttKeyed = false
go a.dvkUnkeyPTT() }
a.pttMu.Unlock()
if keyed {
go a.dvkUnkeyPTT(gen)
}
} }
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "audio:status", st) wruntime.EventsEmit(a.ctx, "audio:status", st)
@@ -682,6 +700,9 @@ func (a *App) startup(ctx context.Context) {
a.qsoRec = audio.NewRecorder() a.qsoRec = audio.NewRecorder()
a.startQSORecorderIfEnabled() a.startQSORecorderIfEnabled()
// Ultrabeam antenna: connect in the background if enabled.
a.startUltrabeam()
fmt.Println("OpsLog: db ready at", a.dbPath) fmt.Println("OpsLog: db ready at", a.dbPath)
} }
@@ -3890,22 +3911,50 @@ func (a *App) DVKPlay(slot int) error {
applog.Printf("dvk: PTT on failed: %v", err) applog.Printf("dvk: PTT on failed: %v", err)
// Keep going — the audio still reaches the rig; the user may use VOX. // Keep going — the audio still reaches the rig; the user may use VOX.
} else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" { } else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" {
a.pttMu.Lock()
a.dvkPttKeyed = true a.dvkPttKeyed = true
a.pttMu.Unlock()
} }
if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil { if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil {
if a.dvkPttKeyed { a.pttMu.Lock()
keyed := a.dvkPttKeyed
gen := a.pttGen
a.dvkPttKeyed = false a.dvkPttKeyed = false
go a.dvkUnkeyPTT() a.pttMu.Unlock()
if keyed {
go a.dvkUnkeyPTT(gen)
} }
return err return err
} }
return nil return nil
} }
// dvkUnkeyPTT releases serial PTT after a short tail so the rig doesn't clip // dvkUnkeyPTT releases PTT after a short tail so the rig doesn't clip the end
// the end of the message. // of the message — but ONLY if no newer key happened since (gen unchanged). A
func (a *App) dvkUnkeyPTT() { // rapid replay (or a Test PTT) starts a fresh transmission whose key must not
// be cut by this stale, delayed release.
func (a *App) dvkUnkeyPTT(gen int64) {
time.Sleep(120 * time.Millisecond) time.Sleep(120 * time.Millisecond)
a.unkeyIfCurrent(gen)
}
// pttGenNow returns the current PTT key generation.
func (a *App) pttGenNow() int64 {
a.pttMu.Lock()
defer a.pttMu.Unlock()
return a.pttGen
}
// unkeyIfCurrent drops PTT only when the key generation hasn't advanced since
// gen was captured — so a delayed release never cuts a transmission the user
// (or a new DVK message) started in the meantime.
func (a *App) unkeyIfCurrent(gen int64) {
a.pttMu.Lock()
stale := a.pttGen != gen
a.pttMu.Unlock()
if stale {
return
}
a.pttUnkey() a.pttUnkey()
} }
@@ -3924,6 +3973,7 @@ func (a *App) pttKey(cfg AudioSettings) error {
} }
a.pttMu.Lock() a.pttMu.Lock()
a.pttKeyedMethod = "cat" a.pttKeyedMethod = "cat"
a.pttGen++
a.pttMu.Unlock() a.pttMu.Unlock()
applog.Printf("dvk: PTT keyed (CAT/OmniRig)") applog.Printf("dvk: PTT keyed (CAT/OmniRig)")
return nil return nil
@@ -3954,6 +4004,7 @@ func (a *App) pttKey(cfg AudioSettings) error {
} }
a.pttPort = port a.pttPort = port
a.pttKeyedMethod = cfg.PTTMethod a.pttKeyedMethod = cfg.PTTMethod
a.pttGen++
applog.Printf("dvk: PTT keyed (%s on %s)", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort) applog.Printf("dvk: PTT keyed (%s on %s)", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort)
return nil return nil
} }
@@ -3990,15 +4041,21 @@ func (a *App) pttUnkey() {
} }
// TestPTT keys PTT for ~600ms so the user can confirm the rig transmits. // TestPTT keys PTT for ~600ms so the user can confirm the rig transmits.
func (a *App) TestPTT() error { // TestPTT keys the transmitter for ~600ms using the GIVEN settings (the live
cfg, _ := a.GetAudioSettings() // UI selection), so the user can test a method/port without saving first —
// matching TestRotator / TestUltrabeam.
func (a *App) TestPTT(cfg AudioSettings) error {
if cfg.PTTMethod == "" || cfg.PTTMethod == "none" { if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
return fmt.Errorf("PTT method is None (VOX) — nothing to test") return fmt.Errorf("PTT method is None (VOX) — pick CAT, RTS or DTR first")
}
if (cfg.PTTMethod == "rts" || cfg.PTTMethod == "dtr") && strings.TrimSpace(cfg.PTTPort) == "" {
return fmt.Errorf("select a COM port for %s PTT", strings.ToUpper(cfg.PTTMethod))
} }
if err := a.pttKey(cfg); err != nil { if err := a.pttKey(cfg); err != nil {
return err return err
} }
go func() { time.Sleep(600 * time.Millisecond); a.pttUnkey() }() gen := a.pttGenNow()
go func() { time.Sleep(600 * time.Millisecond); a.unkeyIfCurrent(gen) }()
return nil return nil
} }
@@ -5941,6 +5998,149 @@ func boolStr(b bool) string {
return "0" return "0"
} }
// ── Ultrabeam antenna (TCP) ────────────────────────────────────────────
// UltrabeamSettings is the JSON shape for the Hardware → Antenna panel.
type UltrabeamSettings struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
}
// GetUltrabeamSettings returns the persisted Ultrabeam config with defaults.
func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
out := UltrabeamSettings{Port: 23}
if a.settings == nil {
return out, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort)
if err != nil {
return out, err
}
out.Enabled = m[keyUltrabeamEnabled] == "1"
out.Host = m[keyUltrabeamHost]
if p, _ := strconv.Atoi(m[keyUltrabeamPort]); p > 0 && p <= 65535 {
out.Port = p
}
return out, nil
}
// SaveUltrabeamSettings persists the config and (re)starts or stops the TCP
// poller so the change takes effect immediately.
func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if s.Port <= 0 || s.Port > 65535 {
s.Port = 23
}
for k, v := range map[string]string{
keyUltrabeamEnabled: boolStr(s.Enabled),
keyUltrabeamHost: strings.TrimSpace(s.Host),
keyUltrabeamPort: strconv.Itoa(s.Port),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
a.startUltrabeam()
return nil
}
// startUltrabeam stops any existing client and starts a fresh one if the
// antenna is enabled and configured. Safe to call repeatedly (on startup and
// after a settings save).
func (a *App) startUltrabeam() {
if a.ultrabeam != nil {
a.ultrabeam.Stop()
a.ultrabeam = nil
}
s, err := a.GetUltrabeamSettings()
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
return
}
a.ultrabeam = ultrabeam.New(s.Host, s.Port)
_ = a.ultrabeam.Start()
}
// UltrabeamStatusInfo is the live antenna status for the UI (status bar +
// direction control). Enabled mirrors the setting; the rest comes from the
// device's most recent status poll.
type UltrabeamStatusInfo struct {
Enabled bool `json:"enabled"`
Connected bool `json:"connected"`
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional
Frequency int `json:"frequency"` // KHz
Band int `json:"band"`
Moving bool `json:"moving"`
}
// GetUltrabeamStatus returns the antenna's current state for the UI poll.
func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
out := UltrabeamStatusInfo{}
s, _ := a.GetUltrabeamSettings()
out.Enabled = s.Enabled
if a.ultrabeam == nil {
return out
}
st, err := a.ultrabeam.GetStatus()
if err != nil || st == nil {
return out
}
out.Connected = st.Connected
out.Direction = st.Direction
out.Frequency = st.Frequency
out.Band = st.Band
out.Moving = st.MotorsMoving != 0
return out
}
// SetUltrabeamDirection switches the antenna pattern: 0=normal, 1=180°,
// 2=bidirectional (re-issues the current frequency with the new direction).
func (a *App) SetUltrabeamDirection(direction int) error {
if a.ultrabeam == nil {
return fmt.Errorf("Ultrabeam not connected — enable it in Settings → Antenna")
}
if direction < 0 || direction > 2 {
return fmt.Errorf("invalid direction %d", direction)
}
return a.ultrabeam.SetDirection(direction)
}
// UltrabeamRetract retracts all elements (storage / safe position).
func (a *App) UltrabeamRetract() error {
if a.ultrabeam == nil {
return fmt.Errorf("Ultrabeam not connected")
}
return a.ultrabeam.Retract()
}
// TestUltrabeam opens a one-shot TCP connection and reads one status frame to
// verify host/port without disturbing the running poller.
func (a *App) TestUltrabeam(s UltrabeamSettings) error {
if strings.TrimSpace(s.Host) == "" {
return fmt.Errorf("host required")
}
if s.Port <= 0 || s.Port > 65535 {
s.Port = 23
}
c := ultrabeam.New(s.Host, s.Port)
if err := c.Start(); err != nil {
return err
}
defer c.Stop()
// The poller connects + reads status on its 2s tick; give it a couple of
// cycles to come up, then check we got a live status frame.
deadline := time.Now().Add(6 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(500 * time.Millisecond)
if st, err := c.GetStatus(); err == nil && st != nil && st.Connected {
return nil
}
}
return fmt.Errorf("no response from %s:%d", s.Host, s.Port)
}
// --- WinKeyer (CW keyer) bindings --- // --- WinKeyer (CW keyer) bindings ---
// WKMacro is one CW message slot (F1…): a short label + the macro text, which // WKMacro is one CW message slot (F1…): a short label + the macro text, which
@@ -6430,20 +6630,28 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if a.qso == nil { if a.qso == nil {
return out return out
} }
// Pass a cty.dat-backed resolver so the past-QSO map uses the SAME // Compare by DXCC entity NUMBER, not name. For each logged QSO the key is
// entity name we'll compare each spot against. Without it QRZ-stored // its stored DXCC if present (the authoritative value set at log time, incl.
// "Turkey" wouldn't match cty.dat's "Asiatic Turkey" → false NEW. // ClubLog date exceptions), else the stored country resolved to a number,
resolveEntity := func(callsign string) string { // else the cty.dat prefix lookup. This fixes false NEWs where cty.dat
if a.dxcc == nil { // re-resolves a logged callsign to a different entity than how it was logged
return "" // (e.g. VK2/SP9FIH logged as Lord Howe Island, but its prefix is Australia —
// so a VJ2L Lord Howe spot must still count as worked).
keyFor := func(call string, storedDXCC int, country string) int {
if storedDXCC > 0 {
return storedDXCC
} }
m, ok := a.dxcc.Lookup(callsign) if n := dxcc.EntityDXCC(country); n > 0 {
if !ok || m.Entity == nil { return n
return ""
} }
return m.Entity.Name if a.dxcc != nil {
if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
return dxcc.EntityDXCC(m.Entity.Name)
} }
entities, err := a.qso.EntitySlotMap(a.ctx, resolveEntity) }
return 0
}
entities, err := a.qso.EntitySlotMap(a.ctx, keyFor)
if err != nil { if err != nil {
return out return out
} }
@@ -6467,10 +6675,13 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if !ok || m.Entity == nil { if !ok || m.Entity == nil {
continue continue
} }
country := strings.ToLower(m.Entity.Name)
out[i].Country = m.Entity.Name out[i].Country = m.Entity.Name
out[i].Continent = m.Continent out[i].Continent = m.Continent
e, worked := entities[country] dxccNum := dxcc.EntityDXCC(m.Entity.Name)
if dxccNum == 0 {
continue // can't resolve the spot's entity number → don't guess
}
e, worked := entities[dxccNum]
if !worked { if !worked {
out[i].Status = "new" out[i].Status = "new"
continue continue
+193 -138
View File
@@ -16,6 +16,7 @@ import {
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
RefreshCtyDat, RefreshCtyDat,
RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading,
GetUltrabeamStatus, SetUltrabeamDirection,
OpenExternalURL, OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot, ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
@@ -306,6 +307,7 @@ export default function App() {
// CAT — receives live rig state via Wails events. // CAT — receives live rig state via Wails events.
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any); const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 }); const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig // Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
// in Preferences > Hardware > CAT interface. // in Preferences > Hardware > CAT interface.
@@ -579,6 +581,8 @@ export default function App() {
type SpotModeCat = 'SSB' | 'CW' | 'DATA'; type SpotModeCat = 'SSB' | 'CW' | 'DATA';
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set()); const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set());
const [clusterSearch, setClusterSearch] = useState(''); const [clusterSearch, setClusterSearch] = useState('');
// Hide spots already worked (exact call worked, or this band+mode slot done).
const [clusterHideWorked, setClusterHideWorked] = useState(false);
const [showBandMap, setShowBandMap] = useState(false); const [showBandMap, setShowBandMap] = useState(false);
// Which side the band map docks to (persisted). Toggled from its header. // Which side the band map docks to (persisted). Toggled from its header.
const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>( const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
@@ -747,6 +751,17 @@ export default function App() {
return () => { alive = false; window.clearInterval(id); }; return () => { alive = false; window.clearInterval(id); };
}, []); }, []);
// Poll the Ultrabeam antenna for its connection + pattern direction.
useEffect(() => {
let alive = true;
const tick = async () => {
try { const s: any = await GetUltrabeamStatus(); if (alive) setUbStatus(s); } catch {}
};
tick();
const id = window.setInterval(tick, 3000);
return () => { alive = false; window.clearInterval(id); };
}, []);
// RX band auto-follows the TX band (only differs for cross-band work). // RX band auto-follows the TX band (only differs for cross-band work).
useEffect(() => { setBandRx(band); }, [band]); useEffect(() => { setBandRx(band); }, [band]);
@@ -2438,142 +2453,7 @@ export default function App() {
<Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge> <Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge>
</div> </div>
{/* Row 2: filters */} {/* Filters moved to the right-side panel (see below). */}
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border/60 flex-wrap text-xs">
<Input
className="w-32 h-7 text-xs font-mono uppercase"
placeholder="Search call…"
value={clusterSearch}
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
/>
<span className="text-muted-foreground">Bands:</span>
{bands.map((b) => {
const on = clusterBands.has(b);
return (
<button
key={b}
type="button"
onClick={() => setClusterBands((s) => {
const n = new Set(s);
if (n.has(b)) n.delete(b); else n.add(b);
return n;
})}
className={cn(
'px-1.5 py-0.5 rounded border text-[10px] font-mono transition-colors',
on
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
)}
>
{b}
</button>
);
})}
{clusterBands.size > 0 && (
<button
type="button"
onClick={() => setClusterBands(new Set())}
className="text-[10px] text-muted-foreground hover:text-foreground underline"
title="Clear band filter"
>
clear
</button>
)}
<div className="w-px h-4 bg-border mx-1" />
<button
type="button"
onClick={() => setClusterLockBand((v) => !v)}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] transition-colors',
clusterLockBand
? 'bg-amber-100 text-amber-800 border-amber-300'
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
)}
title="Only show spots on the entry strip's current band"
>
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />}
Lock band ({band})
</button>
<button
type="button"
onClick={() => setClusterLockMode((v) => !v)}
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] transition-colors',
clusterLockMode
? 'bg-amber-100 text-amber-800 border-amber-300'
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
)}
title="Only show spots whose mode matches the entry strip"
>
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />}
Lock mode ({mode})
</button>
<div className="w-px h-4 bg-border mx-1" />
<span className="text-muted-foreground">Status:</span>
{([
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
]).map((s) => {
const on = clusterStatusFilter.has(s.k);
return (
<button
key={s.k}
type="button"
onClick={() => setClusterStatusFilter((cur) => {
const n = new Set(cur);
if (n.has(s.k)) n.delete(s.k); else n.add(s.k);
return n;
})}
className={cn(
'px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity',
on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`,
)}
>
{s.label}
</button>
);
})}
<div className="w-px h-4 bg-border mx-1" />
<span className="text-muted-foreground">Mode:</span>
{([
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
]).map((s) => {
const on = clusterModeFilter.has(s.k);
return (
<button
key={s.k}
type="button"
onClick={() => setClusterModeFilter((cur) => {
const n = new Set(cur);
if (n.has(s.k)) n.delete(s.k); else n.add(s.k);
return n;
})}
className={cn(
'px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity',
on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`,
)}
>
{s.label}
</button>
);
})}
<div className="flex-1" />
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
Group
</label>
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
<SelectTrigger className="w-32 h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
<SelectContent>
<SelectItem value="_">All sources</SelectItem>
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{(() => { {(() => {
// Apply every filter. `bandsActive` is the band set the // Apply every filter. `bandsActive` is the band set the
@@ -2601,6 +2481,18 @@ export default function App() {
const st = spotStatus[k]?.status || ''; const st = spotStatus[k]?.status || '';
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false; if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
} }
// Hide worked: drop spots whose exact call is already worked,
// or whose entity+band+mode slot is already in the log. The
// status is resolved asynchronously, so we also hide spots
// whose status isn't known yet — otherwise a worked spot would
// flash in (no status) then vanish once it resolves. A new
// spot waits for its status, then appears only if not worked.
if (clusterHideWorked) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const e = spotStatus[k];
if (!e) return false;
if (e.worked_call || e.status === 'worked') return false;
}
return true; return true;
}); });
let rendered = list as (ClusterSpot & { repeats?: number })[]; let rendered = list as (ClusterSpot & { repeats?: number })[];
@@ -2678,8 +2570,135 @@ export default function App() {
</Button> </Button>
</div> </div>
</div>{/* /left column */} </div>{/* /left column */}
{/* BandMap moved to a global side panel below — toggle is
now in the topbar, visible on every tab. */} {/* Right-side filter panel (Log4OM style) */}
<div className="w-56 shrink-0 border-l border-border/60 flex flex-col min-h-0 bg-muted/10">
<div className="px-2.5 py-2 border-b border-border/60 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filters</div>
<div className="flex-1 overflow-auto p-2.5 space-y-3 text-xs">
{/* Callsign search */}
<Input
className="h-7 text-xs font-mono uppercase"
placeholder="Search call…"
value={clusterSearch}
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
/>
{/* Toggles */}
<div className="space-y-1.5">
<label className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={clusterHideWorked} onCheckedChange={(c) => setClusterHideWorked(!!c)} />
Hide worked
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
Group duplicates
</label>
</div>
{/* Band filter — multi-select listbox */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">Bands</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setClusterLockBand((v) => !v)}
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
clusterLockBand ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
title="Lock to the entry strip's current band"
>
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
</button>
{clusterBands.size > 0 && (
<button type="button" onClick={() => setClusterBands(new Set())} className="text-[10px] text-muted-foreground hover:text-foreground underline">clear</button>
)}
</div>
</div>
<div className={cn('rounded border border-border max-h-36 overflow-auto bg-background', clusterLockBand && 'opacity-40 pointer-events-none')}>
{bands.map((b) => {
const on = clusterBands.has(b);
return (
<button
key={b}
type="button"
onClick={() => setClusterBands((s) => { const n = new Set(s); if (n.has(b)) n.delete(b); else n.add(b); return n; })}
className={cn('block w-full text-left px-2 py-0.5 font-mono text-[11px] border-b border-border/30 last:border-0',
on ? 'bg-primary text-primary-foreground' : 'hover:bg-accent/50')}
>
{b}
</button>
);
})}
</div>
</div>
{/* Mode lock */}
<button
type="button"
onClick={() => setClusterLockMode((v) => !v)}
className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px]',
clusterLockMode ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
title="Only show spots whose mode matches the entry strip"
>
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} Lock mode ({mode})
</button>
{/* Status filter */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
<div className="flex flex-wrap gap-1">
{([
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
]).map((s) => {
const on = clusterStatusFilter.has(s.k);
return (
<button key={s.k} type="button"
onClick={() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
{s.label}
</button>
);
})}
</div>
</div>
{/* Mode filter */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div>
<div className="flex flex-wrap gap-1">
{([
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
]).map((s) => {
const on = clusterModeFilter.has(s.k);
return (
<button key={s.k} type="button"
onClick={() => setClusterModeFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
{s.label}
</button>
);
})}
</div>
</div>
{/* Source */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Source</div>
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
<SelectTrigger className="w-full h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
<SelectContent>
<SelectItem value="_">All sources</SelectItem>
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</div>
</TabsContent> </TabsContent>
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1"> <TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
@@ -2779,6 +2798,42 @@ export default function App() {
disabled={!rotatorHeading.enabled} disabled={!rotatorHeading.enabled}
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }} onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/> />
{ubStatus.enabled && (
<div
className="inline-flex items-center gap-1 px-2 h-5 rounded border border-border text-[11px]"
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam connected') : 'Ultrabeam: connecting…'}
>
<button
type="button"
className="inline-flex items-center gap-1 cursor-pointer hover:text-foreground text-muted-foreground"
onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }}
title="Antenna settings"
>
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-amber-500' : 'bg-emerald-500') : 'bg-muted-foreground/40')} />
Ant
</button>
{([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: '180°' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => (
<button
key={o.d}
type="button"
disabled={!ubStatus.connected}
title={o.t}
onClick={() => {
SetUltrabeamDirection(o.d)
.then(() => setUbStatus((s) => ({ ...s, direction: o.d })))
.catch((e: any) => setError(String(e?.message ?? e)));
}}
className={cn(
'px-1 rounded text-[10px] font-medium transition-colors',
ubStatus.direction === o.d ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted',
!ubStatus.connected && 'opacity-40 cursor-default',
)}
>
{o.l}
</button>
))}
</div>
)}
<div className="flex-1" /> <div className="flex-1" />
</footer> </footer>
); );
+12 -17
View File
@@ -125,17 +125,18 @@ const COL_CATALOG: ColEntry[] = [
const isNew = status?.status === 'new'; const isNew = status?.status === 'new';
const workedCall = !!status?.worked_call; const workedCall = !!status?.worked_call;
const style: any = { const style: any = {
display: 'inline-block', padding: '1px 6px', borderRadius: 4,
fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 12, fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 12,
}; };
if (isNew) { if (isNew) {
// New DXCC entity — soft rose pill, no clashing border.
style.backgroundColor = '#ffe4e6'; style.backgroundColor = '#ffe4e6';
style.color = '#9f1239'; style.color = '#be123c';
style.border = '1px solid #fda4af'; style.padding = '1px 7px';
style.borderRadius = 4;
} else if (workedCall) { } else if (workedCall) {
style.color = '#0369a1'; style.color = '#0369a1'; // already worked this exact call
} else { } else {
style.color = '#b8410c'; style.color = '#b8410c'; // new call in a worked entity
} }
return <span style={style} title={isNew ? `NEW DXCC: ${status?.country ?? ''}` : workedCall ? 'Already worked this call' : undefined}>{p.value}</span>; return <span style={style} title={isNew ? `NEW DXCC: ${status?.country ?? ''}` : workedCall ? 'Already worked this call' : undefined}>{p.value}</span>;
}, },
@@ -164,15 +165,12 @@ const COL_CATALOG: ColEntry[] = [
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz) spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
]; ];
const newBand = status?.status === 'new-band'; const newBand = status?.status === 'new-band';
const bg = newBand ? '#fde68a' : '#f0d9a8';
const fg = newBand ? '#92400e' : '#7a4a14';
return p.value return p.value
? <span ? <span
style={{ style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6, fontFamily: 'ui-monospace, monospace', fontSize: 12,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600, fontWeight: newBand ? 700 : 400,
backgroundColor: bg, color: fg, lineHeight: '16px', ...(newBand ? { backgroundColor: '#fde68a', color: '#92400e', padding: '1px 7px', borderRadius: 4 } : {}),
border: newBand ? '1px solid #f59e0b' : undefined,
}} }}
title={newBand ? 'NEW BAND for this entity' : undefined} title={newBand ? 'NEW BAND for this entity' : undefined}
>{p.value}</span> >{p.value}</span>
@@ -190,15 +188,12 @@ const COL_CATALOG: ColEntry[] = [
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz) spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
]; ];
const newSlot = status?.status === 'new-slot'; const newSlot = status?.status === 'new-slot';
const bg = newSlot ? '#fef08a' : '#d1fae5';
const fg = newSlot ? '#854d0e' : '#047857';
return p.value return p.value
? <span ? <span
style={{ style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6, fontFamily: 'ui-monospace, monospace', fontSize: 12,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600, fontWeight: newSlot ? 700 : 400,
backgroundColor: bg, color: fg, lineHeight: '16px', ...(newSlot ? { backgroundColor: '#fef08a', color: '#854d0e', padding: '1px 7px', borderRadius: 4 } : {}),
border: newSlot ? '1px solid #eab308' : undefined,
}} }}
title={newSlot ? 'NEW SLOT (mode not yet worked on this band)' : undefined} title={newSlot ? 'NEW SLOT (mode not yet worked on this band)' : undefined}
>{p.value}</span> >{p.value}</span>
+52 -15
View File
@@ -67,7 +67,39 @@ interface Props {
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended'; export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR']; // ADIF PROP_MODE: stored value is the code, shown with the full name (Log4OM-style).
const PROP_MODES: { value: string; label: string }[] = [
{ value: 'NONE', label: '—' },
{ value: 'AS', label: 'Aircraft Scatter' },
{ value: 'AUR', label: 'Aurora' },
{ value: 'AUE', label: 'Aurora-E' },
{ value: 'BS', label: 'Back Scatter' },
{ value: 'ECH', label: 'EchoLink' },
{ value: 'EME', label: 'Earth-Moon-Earth' },
{ value: 'ES', label: 'Sporadic E' },
{ value: 'FAI', label: 'Field Aligned Irregularities' },
{ value: 'F2', label: 'F2 Reflection' },
{ value: 'GWAVE', label: 'Ground Wave' },
{ value: 'INTERNET', label: 'Internet-assisted' },
{ value: 'ION', label: 'Ionoscatter' },
{ value: 'IRL', label: 'IRLP' },
{ value: 'LOS', label: 'Line of Sight' },
{ value: 'MS', label: 'Meteor Scatter' },
{ value: 'RPT', label: 'Terrestrial / atmospheric repeater' },
{ value: 'RS', label: 'Rain Scatter' },
{ value: 'SAT', label: 'Satellite' },
{ value: 'TEP', label: 'Trans-Equatorial' },
{ value: 'TR', label: 'Tropospheric Ducting' },
];
// ADIF ANT_PATH enum (Grayline, Other, Short Path, Long Path).
const ANT_PATHS: { value: string; label: string }[] = [
{ value: 'NONE', label: '—' },
{ value: 'S', label: 'Short Path' },
{ value: 'L', label: 'Long Path' },
{ value: 'G', label: 'Grayline' },
{ value: 'O', label: 'Other' },
];
function numOrUndef(v: string): number | undefined { function numOrUndef(v: string): number | undefined {
if (v === '') return undefined; if (v === '') return undefined;
@@ -76,9 +108,9 @@ function numOrUndef(v: string): number | undefined {
} }
// Compact field helper to keep the JSX dense. // Compact field helper to keep the JSX dense.
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6; children: React.ReactNode }) { function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 4 | 6; children: React.ReactNode }) {
return ( return (
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 6 && 'col-span-6')}> <div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 4 && 'col-span-4', span === 6 && 'col-span-6')}>
<Label className="mb-1">{label}</Label> <Label className="mb-1">{label}</Label>
{children} {children}
</div> </div>
@@ -217,26 +249,31 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
<Field label="Elevation (°)"> <Field label="Elevation (°)">
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} /> <Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
</Field> </Field>
<Field label="Ant. path">
<Input value={details.ant_path} placeholder="S / L / G" onChange={(e) => onChange({ ant_path: e.target.value })} />
</Field>
<Field label="Propagation">
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === 'NONE' ? '—' : p}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field label="TX power (W)"> <Field label="TX power (W)">
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} /> <Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
</Field> </Field>
<div className="flex items-end pb-1.5"> <div className="col-span-3 flex items-end pb-1.5">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} /> <Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
Satellite mode Satellite mode
</label> </label>
</div> </div>
<Field label="Ant. path" span={2}>
<Select value={details.ant_path || 'NONE'} onValueChange={(v) => onChange({ ant_path: v === 'NONE' ? '' : v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field label="Propagation" span={4}>
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field label="Rig" span={3}> <Field label="Rig" span={3}>
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} /> <Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
</Field> </Field>
+81 -3
View File
@@ -11,6 +11,7 @@ import {
GetCATSettings, SaveCATSettings, GetCATSettings, SaveCATSettings,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop, GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT, GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty, GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
@@ -193,7 +194,7 @@ const TREE: TreeNode[] = [
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' }, { kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' }, { kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' }, { kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true }, { kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna' },
{ kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' }, { kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' },
], ],
}, },
@@ -368,6 +369,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [rotatorTesting, setRotatorTesting] = useState(false); const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null); const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
// Ultrabeam antenna (TCP) settings.
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: number }>({
enabled: false, host: '', port: 23,
});
const [ubTesting, setUbTesting] = useState(false);
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
// WinKeyer CW keyer settings + macro editor. // WinKeyer CW keyer settings + macro editor.
type WKMac = { label: string; text: string }; type WKMac = { label: string; text: string };
type WKSettings = { type WKSettings = {
@@ -583,6 +591,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
await reloadClusterServers(); await reloadClusterServers();
setCatCfg(c); setCatCfg(c);
setRotator(r); setRotator(r);
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
setBackupCfg(b as any); setBackupCfg(b as any);
setQslDefaults(qd as any); setQslDefaults(qd as any);
setExtSvc(es as any); setExtSvc(es as any);
@@ -751,6 +760,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); await SaveRotatorSettings(rotator as any);
await SaveUltrabeamSettings(ultrabeam as any);
await SaveWinkeyerSettings(wk as any); await SaveWinkeyerSettings(wk as any);
await SaveAudioSettings(audioCfg as any); await SaveAudioSettings(audioCfg as any);
await SaveEmailSettings(emailCfg as any); await SaveEmailSettings(emailCfg as any);
@@ -1499,6 +1509,74 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
} }
} }
async function testUltrabeam() {
setUbTesting(true);
setUbTest(null);
try {
await TestUltrabeam(ultrabeam as any);
setUbTest({ ok: true, msg: 'Connected — the Ultrabeam responded with a status frame.' });
} catch (e: any) {
setUbTest({ ok: false, msg: String(e?.message ?? e) });
} finally {
setUbTesting(false);
}
}
function UltrabeamPanel() {
return (
<>
<SectionHeader
title="Antenna (Ultrabeam)"
hint="OpsLog talks to the Ultrabeam controller over TCP — typically via an RS232↔Ethernet adapter. Enter its IP address and port; the pattern direction (Normal / 180° / Bidirectional) is then controlled from the status bar."
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
Enable Ultrabeam control
</label>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input
value={ultrabeam.host ?? ''}
onChange={(e) => setUltrabeam((s) => ({ ...s, host: e.target.value }))}
placeholder="192.168.1.50"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>TCP port</Label>
<Input
type="number" min={1} max={65535}
value={ultrabeam.port}
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
className="font-mono"
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || !ultrabeam.host.trim()}>
{ubTesting ? 'Connecting…' : 'Test connection'}
</Button>
</div>
{ubTest && (
<div className={cn(
'text-xs rounded-md p-2.5 border',
ubTest.ok
? 'bg-emerald-50 text-emerald-800 border-emerald-200'
: 'bg-destructive/10 text-destructive border-destructive/30',
)}>
{ubTest.msg}
</div>
)}
<p className="text-xs text-muted-foreground">
Once enabled, the antenna's direction control (Normal / 180° / Bidirectional) appears in the bottom status bar, next to CAT and Rotator. Changing the direction re-tunes the elements at the current frequency.
</p>
</div>
</>
);
}
function RotatorPanel() { function RotatorPanel() {
return ( return (
<> <>
@@ -2715,7 +2793,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</SelectContent> </SelectContent>
</Select> </Select>
{audioCfg.ptt_method !== 'none' && ( {audioCfg.ptt_method !== 'none' && (
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT().catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}> <Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT(audioCfg as any).catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}>
Test PTT Test PTT
</Button> </Button>
)} )}
@@ -2926,7 +3004,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
cat: CATPanel, cat: CATPanel,
rotator: RotatorPanel, rotator: RotatorPanel,
winkeyer: WinkeyerPanel, winkeyer: WinkeyerPanel,
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />, antenna: UltrabeamPanel,
audio: AudioPanel, audio: AudioPanel,
}; };
+13 -1
View File
@@ -169,6 +169,10 @@ export function GetStationSettings():Promise<main.StationSettings>;
export function GetUIPref(arg1:string):Promise<string>; export function GetUIPref(arg1:string):Promise<string>;
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>; export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
export function GetWinkeyerStatus():Promise<winkeyer.Status>; export function GetWinkeyerStatus():Promise<winkeyer.Status>;
@@ -293,6 +297,8 @@ export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>; export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>; export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>; export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
@@ -317,6 +323,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>; export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>; export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>; export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
@@ -329,12 +337,16 @@ export function TestLoTWUpload():Promise<string>;
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 TestPTT():Promise<void>; export function TestPTT(arg1:main.AudioSettings):Promise<void>;
export function TestQRZUpload():Promise<string>; export function TestQRZUpload():Promise<string>;
export function TestRotator(arg1:main.RotatorSettings):Promise<void>; export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
export function UltrabeamRetract():Promise<void>;
export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>; export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>;
export function UpdateQSO(arg1:qso.QSO):Promise<void>; export function UpdateQSO(arg1:qso.QSO):Promise<void>;
+26 -2
View File
@@ -310,6 +310,14 @@ export function GetUIPref(arg1) {
return window['go']['main']['App']['GetUIPref'](arg1); return window['go']['main']['App']['GetUIPref'](arg1);
} }
export function GetUltrabeamSettings() {
return window['go']['main']['App']['GetUltrabeamSettings']();
}
export function GetUltrabeamStatus() {
return window['go']['main']['App']['GetUltrabeamStatus']();
}
export function GetWinkeyerSettings() { export function GetWinkeyerSettings() {
return window['go']['main']['App']['GetWinkeyerSettings'](); return window['go']['main']['App']['GetWinkeyerSettings']();
} }
@@ -558,6 +566,10 @@ export function SaveUDPIntegration(arg1) {
return window['go']['main']['App']['SaveUDPIntegration'](arg1); return window['go']['main']['App']['SaveUDPIntegration'](arg1);
} }
export function SaveUltrabeamSettings(arg1) {
return window['go']['main']['App']['SaveUltrabeamSettings'](arg1);
}
export function SaveWinkeyerSettings(arg1) { export function SaveWinkeyerSettings(arg1) {
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1); return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
} }
@@ -606,6 +618,10 @@ export function SetUIPref(arg1, arg2) {
return window['go']['main']['App']['SetUIPref'](arg1, arg2); return window['go']['main']['App']['SetUIPref'](arg1, arg2);
} }
export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
}
export function SwitchCATRig(arg1) { export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1); return window['go']['main']['App']['SwitchCATRig'](arg1);
} }
@@ -630,8 +646,8 @@ 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 TestPTT() { export function TestPTT(arg1) {
return window['go']['main']['App']['TestPTT'](); return window['go']['main']['App']['TestPTT'](arg1);
} }
export function TestQRZUpload() { export function TestQRZUpload() {
@@ -642,6 +658,14 @@ export function TestRotator(arg1) {
return window['go']['main']['App']['TestRotator'](arg1); return window['go']['main']['App']['TestRotator'](arg1);
} }
export function TestUltrabeam(arg1) {
return window['go']['main']['App']['TestUltrabeam'](arg1);
}
export function UltrabeamRetract() {
return window['go']['main']['App']['UltrabeamRetract']();
}
export function UpdateAwardReferenceList(arg1) { export function UpdateAwardReferenceList(arg1) {
return window['go']['main']['App']['UpdateAwardReferenceList'](arg1); return window['go']['main']['App']['UpdateAwardReferenceList'](arg1);
} }
+38
View File
@@ -1282,6 +1282,44 @@ export namespace main {
this.my_pota_ref = source["my_pota_ref"]; this.my_pota_ref = source["my_pota_ref"];
} }
} }
export class UltrabeamSettings {
enabled: boolean;
host: string;
port: number;
static createFrom(source: any = {}) {
return new UltrabeamSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.host = source["host"];
this.port = source["port"];
}
}
export class UltrabeamStatusInfo {
enabled: boolean;
connected: boolean;
direction: number;
frequency: number;
band: number;
moving: boolean;
static createFrom(source: any = {}) {
return new UltrabeamStatusInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.connected = source["connected"];
this.direction = source["direction"];
this.frequency = source["frequency"];
this.band = source["band"];
this.moving = source["moving"];
}
}
export class WKMacro { export class WKMacro {
label: string; label: string;
text: string; text: string;
+8 -3
View File
@@ -337,9 +337,14 @@ func (o *OmniRig) SetPTT(on bool) error {
} }
debugLog.Printf("OmniRig.SetPTT(%v): status=%d(%s) writeableParams=0x%X PM_TX-writeable=%v → Tx=%s", debugLog.Printf("OmniRig.SetPTT(%v): status=%d(%s) writeableParams=0x%X PM_TX-writeable=%v → Tx=%s",
on, status, statusStr, writeable, txWriteable, name) on, status, statusStr, writeable, txWriteable, name)
if on && !txWriteable { // When OmniRig DID report its writeable params (writeable != -1) and PM_TX
debugLog.Printf("OmniRig.SetPTT: ⚠ this rig's OmniRig .ini does NOT expose TX keying (PM_TX not writeable). " + // is NOT among them, writing Tx is a silent no-op: the rig never keys and
"Use VOX or serial RTS/DTR PTT instead.") // SetPTT would otherwise return success, leaving the user puzzled ("Test PTT
// does nothing"). Surface a clear, actionable error instead. If we couldn't
// read the writeable params (-1), fall through and try anyway (best effort).
if on && writeable != -1 && writeable&pmTX == 0 {
debugLog.Printf("OmniRig.SetPTT: ⚠ PM_TX not writeable for this rig profile (writeableParams=0x%X)", writeable)
return fmt.Errorf("this rig's OmniRig profile doesn't expose CAT TX keying (PM_TX not writeable) — use RTS/DTR or VOX for PTT")
} }
// OmniRig has NO SetTx method (that returns "unknown name"); the Tx // OmniRig has NO SetTx method (that returns "unknown name"); the Tx
// parameter is set via the writeable Tx PROPERTY (PM_TX / PM_RX). // parameter is set via the writeable Tx PROPERTY (PM_TX / PM_RX).
+21 -18
View File
@@ -1241,44 +1241,47 @@ type EntitySlot struct {
Slots map[string]map[string]struct{} // band → modes worked Slots map[string]map[string]struct{} // band → modes worked
} }
// EntitySlotMap returns slot data for every QSO, grouping by entity. // EntitySlotMap returns slot data for every QSO, grouped by DXCC entity NUMBER.
// //
// `resolveEntity` maps a callsign to its canonical entity name (we use // keyFor maps a QSO (its callsign + stored DXCC + stored country) to a DXCC
// cty.dat for this). When non-nil, the resolved name wins over the // entity number. Keying by NUMBER — not name — is what makes the cluster
// stored `country` column — that's important because QRZ's "Turkey" // "new / new-band / new-slot" check robust: QRZ's "Turkey" and cty.dat's
// disagrees with cty.dat's "Asiatic Turkey" and the cluster status // "Asiatic Turkey" are the same entity (390), and a logged Lord Howe Island
// comparison would otherwise miss past QSOs. When nil, we fall back to // QSO (stored DXCC 147) matches a VJ2L spot even though cty.dat resolves the
// the stored country (useful for tests). // logged callsign "VK2/SP9FIH" to Australia by prefix. The caller decides the
// precedence (stored DXCC → stored country → cty.dat prefix). keyFor returning
// 0 (unresolvable) skips the QSO.
// //
// One DB scan regardless of input size. Cheap to call per cluster batch. // One DB scan regardless of input size. Cheap to call per cluster batch.
func (r *Repo) EntitySlotMap(ctx context.Context, resolveEntity func(callsign string) string) (map[string]*EntitySlot, error) { func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int) (map[int]*EntitySlot, error) {
rows, err := r.db.QueryContext(ctx, rows, err := r.db.QueryContext(ctx,
`SELECT callsign, lower(coalesce(country,'')), lower(band), upper(mode) FROM qso `SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
WHERE band IS NOT NULL AND band != '' WHERE band IS NOT NULL AND band != ''
AND mode IS NOT NULL AND mode != ''`) AND mode IS NOT NULL AND mode != ''`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
out := make(map[string]*EntitySlot, 256) out := make(map[int]*EntitySlot, 256)
for rows.Next() { for rows.Next() {
var call, country, band, mode string var call, country, band, mode string
if err := rows.Scan(&call, &country, &band, &mode); err != nil { var storedDXCC int
if err := rows.Scan(&call, &storedDXCC, &country, &band, &mode); err != nil {
return nil, err return nil, err
} }
key := country key := 0
if resolveEntity != nil { if keyFor != nil {
if name := strings.ToLower(strings.TrimSpace(resolveEntity(call))); name != "" { key = keyFor(call, storedDXCC, country)
key = name } else {
key = storedDXCC
} }
} if key == 0 {
if key == "" {
continue continue
} }
e, ok := out[key] e, ok := out[key]
if !ok { if !ok {
e = &EntitySlot{ e = &EntitySlot{
Country: key, Country: country,
Bands: make(map[string]struct{}), Bands: make(map[string]struct{}),
Slots: make(map[string]map[string]struct{}), Slots: make(map[string]map[string]struct{}),
} }
+468
View File
@@ -0,0 +1,468 @@
// Package ultrabeam drives an Ultrabeam remote-controlled antenna over TCP
// (typically via an RS232↔Ethernet adapter). The wire protocol (STX/ETX
// framing, DLE escaping, XOR checksum) and command codes are the manufacturer's.
package ultrabeam
import (
"bufio"
"fmt"
"log"
"net"
"sync"
"time"
)
// Protocol constants
const (
STX byte = 0xF5 // 245 decimal
ETX byte = 0xFA // 250 decimal
DLE byte = 0xF6 // 246 decimal
)
// Command codes
const (
CMD_STATUS byte = 1 // General status query
CMD_RETRACT byte = 2 // Retract elements
CMD_FREQ byte = 3 // Change frequency
CMD_READ_BANDS byte = 9 // Read current band adjustments
CMD_PROGRESS byte = 10 // Read progress bar
CMD_MODIFY_ELEM byte = 12 // Modify element length
)
// Reply codes
const (
UB_OK byte = 0 // Normal execution
UB_BAD byte = 1 // Invalid command
UB_PAR byte = 2 // Bad parameters
UB_ERR byte = 3 // Error executing command
)
// Direction modes
const (
DIR_NORMAL byte = 0
DIR_180 byte = 1
DIR_BIDIR byte = 2
)
type Client struct {
host string
port int
conn net.Conn
connMu sync.Mutex
reader *bufio.Reader
lastStatus *Status
statusMu sync.RWMutex
stopChan chan struct{}
running bool
seqNum byte
seqMu sync.Mutex
}
type Status struct {
FirmwareMinor int `json:"firmware_minor"`
FirmwareMajor int `json:"firmware_major"`
CurrentOperation int `json:"current_operation"`
Frequency int `json:"frequency"` // KHz
Band int `json:"band"`
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
OffState bool `json:"off_state"`
MotorsMoving int `json:"motors_moving"` // Bitmask
FreqMin int `json:"freq_min"` // MHz
FreqMax int `json:"freq_max"` // MHz
ElementLengths []int `json:"element_lengths"` // mm
ProgressTotal int `json:"progress_total"` // mm
ProgressCurrent int `json:"progress_current"` // 0-60
Connected bool `json:"connected"`
}
func New(host string, port int) *Client {
return &Client{
host: host,
port: port,
stopChan: make(chan struct{}),
seqNum: 0,
}
}
func (c *Client) Start() error {
c.running = true
go c.pollLoop()
return nil
}
func (c *Client) Stop() {
if !c.running {
return
}
c.running = false
close(c.stopChan)
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connMu.Unlock()
}
func (c *Client) pollLoop() {
ticker := time.NewTicker(2 * time.Second) // Increased from 500ms to 2s
defer ticker.Stop()
pollCount := 0
for {
select {
case <-ticker.C:
pollCount++
// Try to connect if not connected
c.connMu.Lock()
if c.conn == nil {
log.Printf("Ultrabeam: Not connected, attempting connection...")
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
if err != nil {
log.Printf("Ultrabeam: Connection failed: %v", err)
c.connMu.Unlock()
// Mark as disconnected
c.statusMu.Lock()
c.lastStatus = &Status{Connected: false}
c.statusMu.Unlock()
continue
}
c.conn = conn
c.reader = bufio.NewReader(c.conn)
log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port)
}
c.connMu.Unlock()
// Query status
status, err := c.queryStatus()
if err != nil {
log.Printf("Ultrabeam: Failed to query status: %v", err)
// Close connection and retry
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.reader = nil
}
c.connMu.Unlock()
// Mark as disconnected
c.statusMu.Lock()
c.lastStatus = &Status{Connected: false}
c.statusMu.Unlock()
continue
}
// Mark as connected
status.Connected = true
// Query progress if motors moving
if status.MotorsMoving != 0 {
progress, err := c.queryProgress()
if err == nil {
status.ProgressTotal = progress[0]
status.ProgressCurrent = progress[1]
}
} else {
// Motors stopped - reset progress
status.ProgressTotal = 0
status.ProgressCurrent = 0
}
c.statusMu.Lock()
c.lastStatus = status
c.statusMu.Unlock()
case <-c.stopChan:
return
}
}
}
func (c *Client) GetStatus() (*Status, error) {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
if c.lastStatus == nil {
return &Status{Connected: false}, nil
}
return c.lastStatus, nil
}
// getNextSeq returns the next sequence number
func (c *Client) getNextSeq() byte {
c.seqMu.Lock()
defer c.seqMu.Unlock()
seq := c.seqNum
c.seqNum = (c.seqNum + 1) % 128
return seq
}
// calculateChecksum calculates the checksum for a packet
func calculateChecksum(data []byte) byte {
chk := byte(0x55)
for _, b := range data {
chk ^= b
chk++
}
return chk
}
// quoteByte handles DLE escaping
func quoteByte(b byte) []byte {
if b == STX || b == ETX || b == DLE {
return []byte{DLE, b & 0x7F} // Clear MSB
}
return []byte{b}
}
// buildPacket creates a complete packet with checksum and escaping
func (c *Client) buildPacket(cmd byte, data []byte) []byte {
seq := c.getNextSeq()
// Calculate checksum on unquoted data
payload := append([]byte{seq, cmd}, data...)
chk := calculateChecksum(payload)
// Build packet with quoting
packet := []byte{STX}
// Add quoted SEQ
packet = append(packet, quoteByte(seq)...)
// Add quoted CMD
packet = append(packet, quoteByte(cmd)...)
// Add quoted data
for _, b := range data {
packet = append(packet, quoteByte(b)...)
}
// Add quoted checksum
packet = append(packet, quoteByte(chk)...)
// Add ETX
packet = append(packet, ETX)
return packet
}
// parsePacket parses a received packet, handling DLE unescaping
func parsePacket(data []byte) (seq byte, cmd byte, payload []byte, err error) {
if len(data) < 5 { // STX + SEQ + CMD + CHK + ETX
return 0, 0, nil, fmt.Errorf("packet too short")
}
if data[0] != STX {
return 0, 0, nil, fmt.Errorf("missing STX")
}
if data[len(data)-1] != ETX {
return 0, 0, nil, fmt.Errorf("missing ETX")
}
// Unquote the data
var unquoted []byte
dle := false
for i := 1; i < len(data)-1; i++ {
b := data[i]
if b == DLE {
dle = true
continue
}
if dle {
b |= 0x80 // Set MSB
dle = false
}
unquoted = append(unquoted, b)
}
if len(unquoted) < 3 {
return 0, 0, nil, fmt.Errorf("unquoted packet too short")
}
seq = unquoted[0]
cmd = unquoted[1]
chk := unquoted[len(unquoted)-1]
payload = unquoted[2 : len(unquoted)-1]
// Verify checksum
calcChk := calculateChecksum(unquoted[:len(unquoted)-1])
if calcChk != chk {
return 0, 0, nil, fmt.Errorf("checksum mismatch: got %02X, expected %02X", chk, calcChk)
}
return seq, cmd, payload, nil
}
// sendCommand sends a command and waits for reply
func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
c.connMu.Lock()
defer c.connMu.Unlock()
if c.conn == nil || c.reader == nil {
return nil, fmt.Errorf("not connected")
}
// Build and send packet
packet := c.buildPacket(cmd, data)
_, err := c.conn.Write(packet)
if err != nil {
return nil, fmt.Errorf("failed to write: %w", err)
}
// Read reply with timeout
c.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) // Reduced from 2s to 1s
// Read until we get a complete packet
var buffer []byte
for {
b, err := c.reader.ReadByte()
if err != nil {
return nil, fmt.Errorf("failed to read: %w", err)
}
buffer = append(buffer, b)
// Check if we have a complete packet
if b == ETX && len(buffer) > 0 && buffer[0] == STX {
break
}
// Prevent infinite loop
if len(buffer) > 256 {
return nil, fmt.Errorf("packet too long")
}
}
// Parse reply
_, replyCmd, payload, err := parsePacket(buffer)
if err != nil {
return nil, fmt.Errorf("failed to parse reply: %w", err)
}
// Log for debugging unknown codes
if replyCmd != UB_OK && replyCmd != UB_BAD && replyCmd != UB_PAR && replyCmd != UB_ERR {
log.Printf("Ultrabeam: Unknown reply code %d (0x%02X), raw packet: %v", replyCmd, replyCmd, buffer)
}
// Check for errors
switch replyCmd {
case UB_BAD:
return nil, fmt.Errorf("invalid command")
case UB_PAR:
return nil, fmt.Errorf("bad parameters")
case UB_ERR:
return nil, fmt.Errorf("execution error")
case UB_OK:
return payload, nil
default:
// Unknown codes might indicate "busy" or "in progress"
// Treat as non-fatal, return empty payload
log.Printf("Ultrabeam: Unusual reply code %d, treating as busy/in-progress", replyCmd)
return []byte{}, nil
}
}
// queryStatus queries general status (command 1)
func (c *Client) queryStatus() (*Status, error) {
reply, err := c.sendCommand(CMD_STATUS, nil)
if err != nil {
return nil, err
}
if len(reply) < 12 {
return nil, fmt.Errorf("status reply too short: %d bytes", len(reply))
}
status := &Status{
FirmwareMinor: int(reply[0]),
FirmwareMajor: int(reply[1]),
CurrentOperation: int(reply[2]),
Frequency: int(reply[3]) | (int(reply[4]) << 8),
Band: int(reply[5]),
Direction: int(reply[6] & 0x0F),
OffState: (reply[7] & 0x02) != 0,
MotorsMoving: int(reply[9]),
FreqMin: int(reply[10]),
FreqMax: int(reply[11]),
}
return status, nil
}
// queryProgress queries motor progress (command 10)
func (c *Client) queryProgress() ([]int, error) {
reply, err := c.sendCommand(CMD_PROGRESS, nil)
if err != nil {
return nil, err
}
if len(reply) < 4 {
return nil, fmt.Errorf("progress reply too short")
}
total := int(reply[0]) | (int(reply[1]) << 8)
current := int(reply[2]) | (int(reply[3]) << 8)
return []int{total, current}, nil
}
// SetFrequency changes frequency and optional direction (command 3)
func (c *Client) SetFrequency(freqKhz int, direction int) error {
data := []byte{
byte(freqKhz & 0xFF),
byte((freqKhz >> 8) & 0xFF),
byte(direction),
}
_, err := c.sendCommand(CMD_FREQ, data)
return err
}
// SetDirection changes only the pattern direction (Normal / 180° / Bidirectional)
// by re-issuing the current frequency with the new direction byte — the device
// has no standalone direction command. Needs a status poll to have populated the
// current frequency first.
func (c *Client) SetDirection(direction int) error {
c.statusMu.RLock()
freq := 0
if c.lastStatus != nil {
freq = c.lastStatus.Frequency
}
c.statusMu.RUnlock()
if freq <= 0 {
return fmt.Errorf("current frequency not known yet — wait for the antenna to report status")
}
return c.SetFrequency(freq, direction)
}
// Retract retracts all elements (command 2)
func (c *Client) Retract() error {
_, err := c.sendCommand(CMD_RETRACT, nil)
return err
}
// ModifyElement modifies element length (command 12)
func (c *Client) ModifyElement(elementNum int, lengthMm int) error {
if elementNum < 0 || elementNum > 5 {
return fmt.Errorf("invalid element number: %d", elementNum)
}
data := []byte{
byte(elementNum),
0, // Reserved
byte(lengthMm & 0xFF),
byte((lengthMm >> 8) & 0xFF),
}
_, err := c.sendCommand(CMD_MODIFY_ELEM, data)
return err
}