diff --git a/app.go b/app.go index b9bb6fb..b1ea694 100644 --- a/app.go +++ b/app.go @@ -36,6 +36,7 @@ import ( "hamlog/internal/profile" "hamlog/internal/qso" "hamlog/internal/rotator/pst" + "hamlog/internal/ultrabeam" "hamlog/internal/winkeyer" "hamlog/internal/settings" @@ -122,6 +123,11 @@ const ( keyRotatorPort = "rotator.port" 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. keyWKEnabled = "winkeyer.enabled" keyWKPort = "winkeyer.port" @@ -344,6 +350,7 @@ type App struct { extsvc *extsvc.Manager winkeyer *winkeyer.Manager clublog *clublog.Manager + ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled audioMgr *audio.Manager qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) @@ -351,6 +358,7 @@ type App struct { pttMu sync.Mutex pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted 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 dbPath string // active database file (may be a user-chosen location) dataDir string // /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. a.audioMgr = audio.NewManager(func() { st := a.dvkStatus() - // When a voice message finishes (or is stopped), drop CAT PTT. - if !st.Playing && a.dvkPttKeyed { - a.dvkPttKeyed = false - go a.dvkUnkeyPTT() + // When a voice message finishes (or is stopped), drop the PTT we keyed + // 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.pttMu.Unlock() + if keyed { + go a.dvkUnkeyPTT(gen) + } } if a.ctx != nil { wruntime.EventsEmit(a.ctx, "audio:status", st) @@ -682,6 +700,9 @@ func (a *App) startup(ctx context.Context) { a.qsoRec = audio.NewRecorder() a.startQSORecorderIfEnabled() + // Ultrabeam antenna: connect in the background if enabled. + a.startUltrabeam() + 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) // Keep going — the audio still reaches the rig; the user may use VOX. } else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" { + a.pttMu.Lock() a.dvkPttKeyed = true + a.pttMu.Unlock() } if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil { - if a.dvkPttKeyed { - a.dvkPttKeyed = false - go a.dvkUnkeyPTT() + a.pttMu.Lock() + keyed := a.dvkPttKeyed + gen := a.pttGen + a.dvkPttKeyed = false + a.pttMu.Unlock() + if keyed { + go a.dvkUnkeyPTT(gen) } return err } return nil } -// dvkUnkeyPTT releases serial PTT after a short tail so the rig doesn't clip -// the end of the message. -func (a *App) dvkUnkeyPTT() { +// dvkUnkeyPTT releases PTT after a short tail so the rig doesn't clip the end +// of the message — but ONLY if no newer key happened since (gen unchanged). A +// 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) + 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() } @@ -3924,6 +3973,7 @@ func (a *App) pttKey(cfg AudioSettings) error { } a.pttMu.Lock() a.pttKeyedMethod = "cat" + a.pttGen++ a.pttMu.Unlock() applog.Printf("dvk: PTT keyed (CAT/OmniRig)") return nil @@ -3954,6 +4004,7 @@ func (a *App) pttKey(cfg AudioSettings) error { } a.pttPort = port a.pttKeyedMethod = cfg.PTTMethod + a.pttGen++ applog.Printf("dvk: PTT keyed (%s on %s)", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort) return nil } @@ -3990,15 +4041,21 @@ func (a *App) pttUnkey() { } // TestPTT keys PTT for ~600ms so the user can confirm the rig transmits. -func (a *App) TestPTT() error { - cfg, _ := a.GetAudioSettings() +// TestPTT keys the transmitter for ~600ms using the GIVEN settings (the live +// 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" { - 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 { 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 } @@ -5941,6 +5998,149 @@ func boolStr(b bool) string { 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 --- // 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 { return out } - // Pass a cty.dat-backed resolver so the past-QSO map uses the SAME - // entity name we'll compare each spot against. Without it QRZ-stored - // "Turkey" wouldn't match cty.dat's "Asiatic Turkey" → false NEW. - resolveEntity := func(callsign string) string { - if a.dxcc == nil { - return "" + // Compare by DXCC entity NUMBER, not name. For each logged QSO the key is + // its stored DXCC if present (the authoritative value set at log time, incl. + // ClubLog date exceptions), else the stored country resolved to a number, + // else the cty.dat prefix lookup. This fixes false NEWs where cty.dat + // re-resolves a logged callsign to a different entity than how it was logged + // (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 !ok || m.Entity == nil { - return "" + if n := dxcc.EntityDXCC(country); n > 0 { + return n } - 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) + } + } + return 0 } - entities, err := a.qso.EntitySlotMap(a.ctx, resolveEntity) + entities, err := a.qso.EntitySlotMap(a.ctx, keyFor) if err != nil { return out } @@ -6467,10 +6675,13 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus { if !ok || m.Entity == nil { continue } - country := strings.ToLower(m.Entity.Name) out[i].Country = m.Entity.Name 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 { out[i].Status = "new" continue diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a83f8f1..3044177 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import { GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, RefreshCtyDat, RotatorGoTo, RotatorStop, GetRotatorHeading, + GetUltrabeamStatus, SetUltrabeamDirection, OpenExternalURL, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ListClusterServers, ClusterSpotStatuses, SendClusterSpot, @@ -306,6 +307,7 @@ export default function App() { // CAT — receives live rig state via Wails events. const [catState, setCatState] = useState({ enabled: false, connected: false } as any); 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 // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default // in Preferences > Hardware > CAT interface. @@ -579,6 +581,8 @@ export default function App() { type SpotModeCat = 'SSB' | 'CW' | 'DATA'; const [clusterModeFilter, setClusterModeFilter] = useState>(new Set()); 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); // Which side the band map docks to (persisted). Toggled from its header. const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>( @@ -747,6 +751,17 @@ export default function App() { 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). useEffect(() => { setBandRx(band); }, [band]); @@ -2438,142 +2453,7 @@ export default function App() { {spots.length} live - {/* Row 2: filters */} -
- setClusterSearch(e.target.value.toUpperCase())} - /> - Bands: - {bands.map((b) => { - const on = clusterBands.has(b); - return ( - - ); - })} - {clusterBands.size > 0 && ( - - )} -
- - -
- Status: - {([ - { 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 ( - - ); - })} -
- Mode: - {([ - { 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 ( - - ); - })} -
- - -
+ {/* Filters moved to the right-side panel (see below). */} {(() => { // Apply every filter. `bandsActive` is the band set the @@ -2601,6 +2481,18 @@ export default function App() { const st = spotStatus[k]?.status || ''; 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; }); let rendered = list as (ClusterSpot & { repeats?: number })[]; @@ -2678,8 +2570,135 @@ export default function App() {
{/* /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) */} +
+
Filters
+
+ {/* Callsign search */} + setClusterSearch(e.target.value.toUpperCase())} + /> + + {/* Toggles */} +
+ + +
+ + {/* Band filter — multi-select listbox */} +
+
+ Bands +
+ + {clusterBands.size > 0 && ( + + )} +
+
+
+ {bands.map((b) => { + const on = clusterBands.has(b); + return ( + + ); + })} +
+
+ + {/* Mode lock */} + + + {/* Status filter */} +
+
Status
+
+ {([ + { 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 ( + + ); + })} +
+
+ + {/* Mode filter */} +
+
Mode
+
+ {([ + { 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 ( + + ); + })} +
+
+ + {/* Source */} +
+
Source
+ +
+
+
@@ -2779,6 +2798,42 @@ export default function App() { disabled={!rotatorHeading.enabled} onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }} /> + {ubStatus.enabled && ( +
+ + {([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: '180°' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => ( + + ))} +
+ )}
); diff --git a/frontend/src/components/ClusterGrid.tsx b/frontend/src/components/ClusterGrid.tsx index a9d335c..a7079fa 100644 --- a/frontend/src/components/ClusterGrid.tsx +++ b/frontend/src/components/ClusterGrid.tsx @@ -125,17 +125,18 @@ const COL_CATALOG: ColEntry[] = [ const isNew = status?.status === 'new'; const workedCall = !!status?.worked_call; const style: any = { - display: 'inline-block', padding: '1px 6px', borderRadius: 4, fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 12, }; if (isNew) { + // New DXCC entity — soft rose pill, no clashing border. style.backgroundColor = '#ffe4e6'; - style.color = '#9f1239'; - style.border = '1px solid #fda4af'; + style.color = '#be123c'; + style.padding = '1px 7px'; + style.borderRadius = 4; } else if (workedCall) { - style.color = '#0369a1'; + style.color = '#0369a1'; // already worked this exact call } else { - style.color = '#b8410c'; + style.color = '#b8410c'; // new call in a worked entity } return {p.value}; }, @@ -164,15 +165,12 @@ const COL_CATALOG: ColEntry[] = [ spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz) ]; const newBand = status?.status === 'new-band'; - const bg = newBand ? '#fde68a' : '#f0d9a8'; - const fg = newBand ? '#92400e' : '#7a4a14'; return p.value ? {p.value} @@ -190,15 +188,12 @@ const COL_CATALOG: ColEntry[] = [ spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz) ]; const newSlot = status?.status === 'new-slot'; - const bg = newSlot ? '#fef08a' : '#d1fae5'; - const fg = newSlot ? '#854d0e' : '#047857'; return p.value ? {p.value} diff --git a/frontend/src/components/DetailsPanel.tsx b/frontend/src/components/DetailsPanel.tsx index a586ec1..618aa40 100644 --- a/frontend/src/components/DetailsPanel.tsx +++ b/frontend/src/components/DetailsPanel.tsx @@ -67,7 +67,39 @@ interface Props { 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 { if (v === '') return undefined; @@ -76,9 +108,9 @@ function numOrUndef(v: string): number | undefined { } // 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 ( -
+
{children}
@@ -217,26 +249,31 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, onChange({ ant_el: numOrUndef(e.target.value) })} /> - - onChange({ ant_path: e.target.value })} /> - - - - onChange({ tx_pwr: numOrUndef(e.target.value) })} /> -
+
+ + + + + + onChange({ my_rig: e.target.value })} /> diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 8eda91b..f0b66f4 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -11,6 +11,7 @@ import { GetCATSettings, SaveCATSettings, ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop, + GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT, GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty, @@ -193,7 +194,7 @@ const TREE: TreeNode[] = [ { kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' }, { kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' }, { 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' }, ], }, @@ -368,6 +369,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { const [rotatorTesting, setRotatorTesting] = useState(false); 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. type WKMac = { label: string; text: string }; type WKSettings = { @@ -583,6 +591,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { await reloadClusterServers(); setCatCfg(c); setRotator(r); + try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} setBackupCfg(b as any); setQslDefaults(qd as any); setExtSvc(es as any); @@ -751,6 +760,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { await SaveLookupSettings(lookup as any); await SaveCATSettings(catCfg as any); await SaveRotatorSettings(rotator as any); + await SaveUltrabeamSettings(ultrabeam as any); await SaveWinkeyerSettings(wk as any); await SaveAudioSettings(audioCfg 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 ( + <> + +
+ +
+
+ + setUltrabeam((s) => ({ ...s, host: e.target.value }))} + placeholder="192.168.1.50" + className="font-mono" + /> +
+
+ + setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))} + className="font-mono" + /> +
+
+
+ +
+ {ubTest && ( +
+ {ubTest.msg} +
+ )} +

+ 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. +

+
+ + ); + } + function RotatorPanel() { return ( <> @@ -2715,7 +2793,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { {audioCfg.ptt_method !== 'none' && ( - )} @@ -2926,7 +3004,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { cat: CATPanel, rotator: RotatorPanel, winkeyer: WinkeyerPanel, - antenna: () => , + antenna: UltrabeamPanel, audio: AudioPanel, }; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 0b48f8a..70e1372 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -169,6 +169,10 @@ export function GetStationSettings():Promise; export function GetUIPref(arg1:string):Promise; +export function GetUltrabeamSettings():Promise; + +export function GetUltrabeamStatus():Promise; + export function GetWinkeyerSettings():Promise; export function GetWinkeyerStatus():Promise; @@ -293,6 +297,8 @@ export function SaveStationSettings(arg1:main.StationSettings):Promise; export function SaveUDPIntegration(arg1:udp.Config):Promise; +export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise; + export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise; export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise>; @@ -317,6 +323,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise; export function SetUIPref(arg1:string,arg2:string):Promise; +export function SetUltrabeamDirection(arg1:number):Promise; + export function SwitchCATRig(arg1:number):Promise; export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise; @@ -329,12 +337,16 @@ export function TestLoTWUpload():Promise; export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise; -export function TestPTT():Promise; +export function TestPTT(arg1:main.AudioSettings):Promise; export function TestQRZUpload():Promise; export function TestRotator(arg1:main.RotatorSettings):Promise; +export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise; + +export function UltrabeamRetract():Promise; + export function UpdateAwardReferenceList(arg1:string):Promise; export function UpdateQSO(arg1:qso.QSO):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 006b31f..f93a170 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -310,6 +310,14 @@ export function 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() { return window['go']['main']['App']['GetWinkeyerSettings'](); } @@ -558,6 +566,10 @@ export function SaveUDPIntegration(arg1) { return window['go']['main']['App']['SaveUDPIntegration'](arg1); } +export function SaveUltrabeamSettings(arg1) { + return window['go']['main']['App']['SaveUltrabeamSettings'](arg1); +} + export function 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); } +export function SetUltrabeamDirection(arg1) { + return window['go']['main']['App']['SetUltrabeamDirection'](arg1); +} + export function 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); } -export function TestPTT() { - return window['go']['main']['App']['TestPTT'](); +export function TestPTT(arg1) { + return window['go']['main']['App']['TestPTT'](arg1); } export function TestQRZUpload() { @@ -642,6 +658,14 @@ export function 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) { return window['go']['main']['App']['UpdateAwardReferenceList'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index fe442b3..b7d348f 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1282,6 +1282,44 @@ export namespace main { 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 { label: string; text: string; diff --git a/internal/cat/omnirig.go b/internal/cat/omnirig.go index 75b55bb..a0dff36 100644 --- a/internal/cat/omnirig.go +++ b/internal/cat/omnirig.go @@ -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", on, status, statusStr, writeable, txWriteable, name) - if on && !txWriteable { - debugLog.Printf("OmniRig.SetPTT: ⚠ this rig's OmniRig .ini does NOT expose TX keying (PM_TX not writeable). " + - "Use VOX or serial RTS/DTR PTT instead.") + // When OmniRig DID report its writeable params (writeable != -1) and PM_TX + // is NOT among them, writing Tx is a silent no-op: the rig never keys and + // 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 // parameter is set via the writeable Tx PROPERTY (PM_TX / PM_RX). diff --git a/internal/qso/qso.go b/internal/qso/qso.go index e93968c..25dd687 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -1241,44 +1241,47 @@ type EntitySlot struct { 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 -// cty.dat for this). When non-nil, the resolved name wins over the -// stored `country` column — that's important because QRZ's "Turkey" -// disagrees with cty.dat's "Asiatic Turkey" and the cluster status -// comparison would otherwise miss past QSOs. When nil, we fall back to -// the stored country (useful for tests). +// keyFor maps a QSO (its callsign + stored DXCC + stored country) to a DXCC +// entity number. Keying by NUMBER — not name — is what makes the cluster +// "new / new-band / new-slot" check robust: QRZ's "Turkey" and cty.dat's +// "Asiatic Turkey" are the same entity (390), and a logged Lord Howe Island +// QSO (stored DXCC 147) matches a VJ2L spot even though cty.dat resolves the +// 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. -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, - `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 != '' AND mode IS NOT NULL AND mode != ''`) if err != nil { return nil, err } defer rows.Close() - out := make(map[string]*EntitySlot, 256) + out := make(map[int]*EntitySlot, 256) for rows.Next() { 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 } - key := country - if resolveEntity != nil { - if name := strings.ToLower(strings.TrimSpace(resolveEntity(call))); name != "" { - key = name - } + key := 0 + if keyFor != nil { + key = keyFor(call, storedDXCC, country) + } else { + key = storedDXCC } - if key == "" { + if key == 0 { continue } e, ok := out[key] if !ok { e = &EntitySlot{ - Country: key, + Country: country, Bands: make(map[string]struct{}), Slots: make(map[string]map[string]struct{}), } diff --git a/internal/ultrabeam/ultrabeam.go b/internal/ultrabeam/ultrabeam.go new file mode 100644 index 0000000..e70efef --- /dev/null +++ b/internal/ultrabeam/ultrabeam.go @@ -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 +}