diff --git a/app.go b/app.go index 67c776d..2e36726 100644 --- a/app.go +++ b/app.go @@ -16,6 +16,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "hamlog/internal/adif" @@ -38,11 +39,11 @@ import ( "hamlog/internal/extsvc" "hamlog/internal/integrations/udp" "hamlog/internal/lookup" + "hamlog/internal/lotwusers" "hamlog/internal/netctl" + "hamlog/internal/offlineq" "hamlog/internal/operating" "hamlog/internal/pota" - "hamlog/internal/lotwusers" - "hamlog/internal/offlineq" "hamlog/internal/powergenius" "hamlog/internal/profile" "hamlog/internal/qslcard" @@ -50,6 +51,7 @@ import ( "hamlog/internal/rotator/pst" "hamlog/internal/settings" "hamlog/internal/solar" + "hamlog/internal/steppir" "hamlog/internal/ultrabeam" "hamlog/internal/winkeyer" @@ -84,27 +86,27 @@ const ( keyListsRSTCW = "lists.rst_cw" keyListsRSTDigital = "lists.rst_digital" - keyCATEnabled = "cat.enabled" - keyCATBackend = "cat.backend" // "omnirig" | "flex" - keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2 - keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend) - keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992) - keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter + keyCATEnabled = "cat.enabled" + keyCATBackend = "cat.backend" // "omnirig" | "flex" + keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2 + keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend) + keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992) + keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter keyCATFlexDecodeSpots = "cat.flex.decode_spots" // push WSJT-X decodes (heard stations) to the panadapter keyCATFlexDecodeSecs = "cat.flex.decode_secs" // decode spot display duration (seconds) before auto-removal - keyCATPollMs = "cat.poll_ms" - keyCATDelayMs = "cat.delay_ms" // pause between commands - keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA - keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5) - keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200) - keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98) - keyCATIcomNetHost = "cat.icom.net.host" // Icom network remote: rig IP/hostname - keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID - keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password - keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental) - keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2) - keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001) - keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama + keyCATPollMs = "cat.poll_ms" + keyCATDelayMs = "cat.delay_ms" // pause between commands + keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA + keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5) + keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200) + keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98) + keyCATIcomNetHost = "cat.icom.net.host" // Icom network remote: rig IP/hostname + keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID + keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password + keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental) + keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2) + keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001) + keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama // Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so // global (not per-profile) like CAT/rotator. Device fields store the @@ -153,12 +155,19 @@ const ( keyRotatorPort = "rotator.port" keyRotatorHasElevation = "rotator.has_elevation" - // Ultrabeam antenna (TCP, e.g. via an RS232↔Ethernet adapter) — Hardware → Antenna. + // Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the + // "ultrabeam." prefix for backward compatibility with configs saved before the + // SteppIR support; keyMotorType / keyMotorTransport default to the old Ultrabeam + // TCP behaviour when absent, so an existing install keeps working untouched. keyUltrabeamEnabled = "ultrabeam.enabled" keyUltrabeamHost = "ultrabeam.host" keyUltrabeamPort = "ultrabeam.port" - keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency - keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz + keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency + keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz + keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam) + keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp) + keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0) + keyMotorBaud = "ultrabeam.baud" // serial baud (SteppIR default 9600) // Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP // port is fixed at 9007, so only the IP is configurable. @@ -275,27 +284,27 @@ type QSLDefaults struct { // CATSettings is the user-tweakable rig-control configuration. Stored as // individual key/value pairs to keep the settings table flat. type CATSettings struct { - Enabled bool `json:"enabled"` - Backend string `json:"backend"` // "omnirig" | "flex" | "icom" | "icom-net" | "tci" - OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot) - FlexHost string `json:"flex_host"` // FlexRadio IP (native backend) - FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992) - FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter - FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter - FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120) - IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5) - IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200) - IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152) - IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server) - IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID - IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password - IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification - TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR) - TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001) - TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama - PollMs int `json:"poll_ms"` // poll interval in ms (default 250) - DelayMs int `json:"delay_ms"` // pause between commands (default 0) - DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…) + Enabled bool `json:"enabled"` + Backend string `json:"backend"` // "omnirig" | "flex" | "icom" | "icom-net" | "tci" + OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot) + FlexHost string `json:"flex_host"` // FlexRadio IP (native backend) + FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992) + FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter + FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter + FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120) + IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5) + IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200) + IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152) + IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server) + IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID + IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password + IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification + TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR) + TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001) + TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama + PollMs int `json:"poll_ms"` // poll interval in ms (default 250) + DelayMs int `json:"delay_ms"` // pause between commands (default 0) + DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…) } // ModePreset is a mode entry with default RST values to auto-populate @@ -403,33 +412,41 @@ type LookupSettings struct { // App is the application context bound to the Wails runtime. type App struct { - ctx context.Context - db *sql.DB - qso *qso.Repo - settings *settings.Store - profiles *profile.Repo - lookup *lookup.Manager - cache *lookup.Cache - cat *cat.Manager - dxcc *dxcc.Manager - cluster *cluster.Manager - pota *pota.Cache - awardRefs *awardref.Repo - qslTemplates *qslcard.Repo - operating *operating.Repo - udp *udp.Manager - udpRepo *udp.Repo - extsvc *extsvc.Manager - winkeyer *winkeyer.Manager - clublog *clublog.Manager - ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled - ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off - antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled - pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled - audioMgr *audio.Manager - qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) - solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping - lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign) + ctx context.Context + db *sql.DB + qso *qso.Repo + settings *settings.Store + profiles *profile.Repo + lookup *lookup.Manager + cache *lookup.Cache + cat *cat.Manager + dxcc *dxcc.Manager + cluster *cluster.Manager + // Cluster spots/lines are processed OFF the socket-read goroutine. Enriching a + // spot (DXCC/POTA), emitting it to the UI, running alert rules — which can hit + // a remote MySQL via isWorkedBandMode — and mirroring it to the Flex all used + // to run inline in the read loop, so a single slow step stopped draining the + // TCP socket and the whole feed fell behind the node (visible as the grid + // lagging telnet). The read loop now just enqueues here; one worker does the work. + clusterEventCh chan clusterEvent + clusterDropped int64 // spots/lines dropped when the queue was full (atomic) + pota *pota.Cache + awardRefs *awardref.Repo + qslTemplates *qslcard.Repo + operating *operating.Repo + udp *udp.Manager + udpRepo *udp.Repo + extsvc *extsvc.Manager + winkeyer *winkeyer.Manager + clublog *clublog.Manager + motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled + ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off + antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled + pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled + audioMgr *audio.Manager + qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) + solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping + lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign) // NET Control: persistent net definitions/rosters (global JSON) + the live // session (in-memory only — active stations currently in QSO). @@ -449,42 +466,43 @@ type App struct { dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends pttMu sync.Mutex - udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check - 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) - logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite) - dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup - dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite - offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable - offlineMode bool // last write failed because the DB was unreachable + udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check + 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) + logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite) + dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup + dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite + offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable + offlineMode bool // last write failed because the DB was unreachable - catFlexSpots bool // push cluster spots to the FlexRadio panadapter - catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter - catFlexDecodeSecs int // decode spot display duration (seconds) - liveActMu sync.Mutex // guards the entry-strip activity reported for live status - liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off) - liveBand string - liveMode string - awardSnapMu sync.Mutex // guards the award QSO snapshot - awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations - awardSnapRev string // logbook revision the snapshot was built at ("" = none) - dataDir string // /data — holds config.json, logs, cty.dat + catFlexSpots bool // push cluster spots to the FlexRadio panadapter + catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter + catFlexDecodeSecs int // decode spot display duration (seconds) + liveActMu sync.Mutex // guards the entry-strip activity reported for live status + liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off) + liveBand string + liveMode string + awardSnapMu sync.Mutex // guards the award QSO snapshot + awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations + awardSnapRev string // logbook revision the snapshot was built at ("" = none) + dataDir string // /data — holds config.json, logs, cty.dat // shuttingDown gates beforeClose re-entry: the first user attempt to // close fires shutdown tasks (backup, future LoTW upload, ...) while // blocking the window close; the subsequent programmatic Quit() call // must be allowed through. shuttingDown bool + compact bool // window is in the compact one-row mode (skip saving its tiny size as the normal geometry) // Cached operator location used to compute distance/bearing for // cluster spots. Refreshed on profile activation; zero means // "unknown" and we skip the per-spot computation. - opLat float64 - opLon float64 - opSet bool + opLat float64 + opLon float64 + opSet bool opCall string // active profile callsign, cached for outbound UDP emitters // Dedup for the frequency/mode outbound UDP emitters (PstRotator, N1MM): only @@ -862,52 +880,17 @@ func (a *App) startup(ctx context.Context) { // with country + continent via cty.dat BEFORE emitting it, so the UI // renders the row with all metadata already filled (no flicker of // empty Country / Cont columns while the batch status fetch runs). + // Cluster events are processed OFF the socket-read goroutine (see clusterEvent / + // clusterEventWorker). Sized large so ordinary traffic and even an SH/DX/100 + // burst never fill it; a full queue drops-and-counts rather than block the read + // loop, which was the actual cause of the feed falling behind telnet. + a.clusterEventCh = make(chan clusterEvent, 8192) + go a.clusterEventWorker() + a.cluster = cluster.NewManager( - func(s cluster.Spot) { - if a.dxcc != nil { - if m, ok := a.dxcc.Lookup(s.DXCall); ok && m.Entity != nil { - s.Country = m.Entity.Name - s.Continent = m.Continent - s.CQZone = m.CQZone - s.ITUZone = m.ITUZone - if a.opSet && (m.Lat != 0 || m.Lon != 0) { - s.DistanceKm = int(haversineKm(a.opLat, a.opLon, m.Lat, m.Lon) + 0.5) - sp := initialBearingDeg(a.opLat, a.opLon, m.Lat, m.Lon) - s.ShortPath = int(sp + 0.5) - s.LongPath = (s.ShortPath + 180) % 360 - } - } - } - // POTA: tag the spot when the DX station is currently activating a park. - if a.pota != nil { - if info, ok := a.pota.Lookup(s.DXCall); ok { - s.POTARef = info.Reference - s.POTAName = info.ParkName - } - } - if a.ctx != nil { - wruntime.EventsEmit(a.ctx, "cluster:spot", s) - } - // A HISTORICAL spot (recovered from a SH/DX table) goes to the grid and - // stops there. It is a replay of the past: firing 100 alerts at once, or - // painting 100 stale stations on the panadapter as if they were on the - // air right now, would be actively misleading. - if s.Historical { - return - } - // Fire any matching alert rules (sound / visual / e-mail). - a.evaluateAlerts(s) - // Mirror the spot onto the FlexRadio panadapter when enabled. The - // Color is left to the backend default for now — status-based - // colouring can be filled in here later (new entity / worked / …). - if a.catFlexSpots && a.cat != nil { - a.cat.SendSpot(cat.SpotInfo{ - FreqHz: s.FreqHz, - Callsign: s.DXCall, - Comment: s.Comment, - }) - } - }, + // onSpot / onLine run on the session's socket-read goroutine, so they must + // do nothing slow: just enqueue and return, keeping the TCP socket drained. + func(s cluster.Spot) { a.enqueueClusterEvent(clusterEvent{spot: &s}) }, func() { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status()) @@ -916,11 +899,7 @@ func (a *App) startup(ctx context.Context) { // Raw traffic → the cluster console. Spots are parsed out of this stream, // but SH/DX, WHO, the MOTD and error replies are NOT spots — without this // they were dropped on the floor and the command box looked inert. - func(l cluster.Line) { - if a.ctx != nil { - wruntime.EventsEmit(a.ctx, "cluster:line", l) - } - }, + func(l cluster.Line) { a.enqueueClusterEvent(clusterEvent{line: &l}) }, ) a.refreshOperatorGrid() if cs, _ := a.clusterAutoConnect(); cs { @@ -1031,6 +1010,9 @@ func (a *App) startup(ctx context.Context) { go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence + // Reopen where the window was last left (size + position). + a.restoreWindowState() + fmt.Println("OpsLog: db ready at", a.dbPath) } @@ -1063,6 +1045,10 @@ func (a *App) beforeClose(ctx context.Context) bool { } a.shuttingDown = true + // Capture geometry now, before any shutdown UI can resize the window, so the + // next launch reopens exactly here. + a.saveWindowState() + steps := a.plannedShutdownSteps() if len(steps) == 0 { // Nothing to do — exit immediately, no need to flash a modal. @@ -1311,6 +1297,83 @@ type dbPointer struct { func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") } +// ── Window geometry (window.json) ────────────────────────────────────── +// +// Remembered across restarts so the window reopens where and how you left it. +// Kept in its OWN local file, NOT the database: the DB may be a MySQL server +// shared by several machines, and each has its own screens — persisting geometry +// there would make two installs fight over one position. +type windowState struct { + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` + Maximised bool `json:"maximised"` +} + +func windowStatePath(dataDir string) string { return filepath.Join(dataDir, "window.json") } + +func readWindowState(dataDir string) (windowState, bool) { + var w windowState + b, err := os.ReadFile(windowStatePath(dataDir)) + if err != nil { + return w, false + } + if json.Unmarshal(b, &w) != nil { + return w, false + } + return w, true +} + +func writeWindowState(dataDir string, w windowState) { + if b, err := json.MarshalIndent(w, "", " "); err == nil { + _ = os.WriteFile(windowStatePath(dataDir), b, 0o644) + } +} + +// saveWindowState captures the current geometry. Called as the window closes. +// While in compact mode the window is pinned to a tiny fixed size, so we keep the +// previously-saved normal geometry rather than overwrite it with 1240×158 — only +// the maximised flag is meaningful to update there. +func (a *App) saveWindowState() { + if a.ctx == nil || a.dataDir == "" { + return + } + prev, _ := readWindowState(a.dataDir) + max := wruntime.WindowIsMaximised(a.ctx) + ws := prev + ws.Maximised = max + if !max && !a.compact { + w, h := wruntime.WindowGetSize(a.ctx) + x, y := wruntime.WindowGetPosition(a.ctx) + if w > 0 && h > 0 { + ws.Width, ws.Height, ws.X, ws.Y = w, h, x, y + } + } + writeWindowState(a.dataDir, ws) +} + +// restoreWindowState reopens the window where it was last left. The window is +// CREATED maximised (WindowStartState in main.go), which is also the first-run +// default — so we only act when a windowed geometry was saved, leaving the +// maximised majority untouched and flash-free. +func (a *App) restoreWindowState() { + if a.ctx == nil { + return + } + ws, ok := readWindowState(a.dataDir) + if !ok || ws.Maximised { + return // first run, or last closed maximised — the default already covers it + } + // Guard against a corrupt / absurd size that would open an unusable window. + if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH { + return + } + wruntime.WindowUnmaximise(a.ctx) + wruntime.WindowSetSize(a.ctx, ws.Width, ws.Height) + wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y) +} + // readBootstrap returns the full bootstrap config (DB path + MySQL), or a zero // value if the file is missing/unreadable. func readBootstrap(dataDir string) dbPointer { @@ -3956,10 +4019,10 @@ type AwardImportPreviewEntry struct { Code string `json:"code"` Name string `json:"name"` References int `json:"references"` - Exists bool `json:"exists"` // an award with this code is already installed - MineName string `json:"mine_name"` // the name of the one you already have - MineRefs int `json:"mine_refs"` // how many references yours carries - Protected bool `json:"protected"` // yours is a protected built-in + Exists bool `json:"exists"` // an award with this code is already installed + MineName string `json:"mine_name"` // the name of the one you already have + MineRefs int `json:"mine_refs"` // how many references yours carries + Protected bool `json:"protected"` // yours is a protected built-in } // AwardImportPreview is what the file holds, and what would collide. @@ -4674,6 +4737,7 @@ func (a *App) SetCompactMode(on bool) { if a.ctx == nil { return } + a.compact = on if on { // Lock the window to the compact size by pinning min == max. Without // the max pin, dragging the frameless window (esp. across monitors / @@ -5281,27 +5345,27 @@ func (a *App) GetCATSettings() (CATSettings, error) { return CATSettings{}, err } out := CATSettings{ - Enabled: m[keyCATEnabled] == "1", - Backend: m[keyCATBackend], - OmniRigNum: 1, - FlexHost: m[keyCATFlexHost], - FlexPort: 4992, - FlexSpots: m[keyCATFlexSpots] == "1", + Enabled: m[keyCATEnabled] == "1", + Backend: m[keyCATBackend], + OmniRigNum: 1, + FlexHost: m[keyCATFlexHost], + FlexPort: 4992, + FlexSpots: m[keyCATFlexSpots] == "1", FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1", FlexDecodeSecs: 120, - IcomPort: m[keyCATIcomPort], - IcomBaud: 115200, - IcomAddr: 0x98, // IC-7610 default - IcomNetHost: m[keyCATIcomNetHost], - IcomNetUser: m[keyCATIcomNetUser], - IcomNetPass: m[keyCATIcomNetPass], - IcomNetAudio: m[keyCATIcomNetAudio] == "1", - TCIHost: m[keyCATTCIHost], - TCIPort: 40001, - TCISpots: m[keyCATTCISpots] == "1", - PollMs: 250, - DelayMs: 0, - DigitalDefault: m[keyCATDigitalDefault], + IcomPort: m[keyCATIcomPort], + IcomBaud: 115200, + IcomAddr: 0x98, // IC-7610 default + IcomNetHost: m[keyCATIcomNetHost], + IcomNetUser: m[keyCATIcomNetUser], + IcomNetPass: m[keyCATIcomNetPass], + IcomNetAudio: m[keyCATIcomNetAudio] == "1", + TCIHost: m[keyCATTCIHost], + TCIPort: 40001, + TCISpots: m[keyCATTCISpots] == "1", + PollMs: 250, + DelayMs: 0, + DigitalDefault: m[keyCATDigitalDefault], } if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 { out.FlexPort = n @@ -5389,27 +5453,27 @@ func (a *App) SaveCATSettings(s CATSettings) error { s.DigitalDefault = "FT8" } for k, v := range map[string]string{ - keyCATEnabled: enabled, - keyCATBackend: s.Backend, - keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum), - keyCATFlexHost: strings.TrimSpace(s.FlexHost), - keyCATFlexPort: strconv.Itoa(s.FlexPort), - keyCATFlexSpots: flexSpots, + keyCATEnabled: enabled, + keyCATBackend: s.Backend, + keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum), + keyCATFlexHost: strings.TrimSpace(s.FlexHost), + keyCATFlexPort: strconv.Itoa(s.FlexPort), + keyCATFlexSpots: flexSpots, keyCATFlexDecodeSpots: flexDecodeSpots, keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs), - keyCATIcomPort: strings.TrimSpace(s.IcomPort), - keyCATIcomBaud: strconv.Itoa(s.IcomBaud), - keyCATIcomAddr: strconv.Itoa(s.IcomAddr), - keyCATIcomNetHost: strings.TrimSpace(s.IcomNetHost), - keyCATIcomNetUser: strings.TrimSpace(s.IcomNetUser), - keyCATIcomNetPass: s.IcomNetPass, - keyCATIcomNetAudio: icomNetAudio, - keyCATTCIHost: strings.TrimSpace(s.TCIHost), - keyCATTCIPort: strconv.Itoa(s.TCIPort), - keyCATTCISpots: tciSpots, - keyCATPollMs: strconv.Itoa(s.PollMs), - keyCATDelayMs: strconv.Itoa(s.DelayMs), - keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)), + keyCATIcomPort: strings.TrimSpace(s.IcomPort), + keyCATIcomBaud: strconv.Itoa(s.IcomBaud), + keyCATIcomAddr: strconv.Itoa(s.IcomAddr), + keyCATIcomNetHost: strings.TrimSpace(s.IcomNetHost), + keyCATIcomNetUser: strings.TrimSpace(s.IcomNetUser), + keyCATIcomNetPass: s.IcomNetPass, + keyCATIcomNetAudio: icomNetAudio, + keyCATTCIHost: strings.TrimSpace(s.TCIHost), + keyCATTCIPort: strconv.Itoa(s.TCIPort), + keyCATTCISpots: tciSpots, + keyCATPollMs: strconv.Itoa(s.PollMs), + keyCATDelayMs: strconv.Itoa(s.DelayMs), + keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err @@ -5755,6 +5819,91 @@ func (a *App) SetAlertEmailTo(addr string) error { // match (visual/sound via a frontend event, e-mail via SMTP). Cheap when no // rules exist; the worked-before check only runs for rules that opt into it and // already matched, so it's off the hot path. +// clusterEvent is one item off the cluster feed — a parsed spot OR a raw console +// line. A single union type on one channel preserves the order lines and spots +// arrived in (the console shows a line, then its parsed spot), which two separate +// channels could not guarantee. +type clusterEvent struct { + spot *cluster.Spot + line *cluster.Line +} + +// enqueueClusterEvent hands an event to the worker WITHOUT blocking. It runs on +// the cluster session's socket-read goroutine: blocking here would stop draining +// the TCP socket, the node's send buffer would fill, and the feed would fall +// behind — the exact bug this indirection fixes. If the queue is full (processing +// can't keep up), drop and count rather than stall the whole feed. +func (a *App) enqueueClusterEvent(ev clusterEvent) { + select { + case a.clusterEventCh <- ev: + default: + n := atomic.AddInt64(&a.clusterDropped, 1) + if n == 1 || n%100 == 0 { + applog.Printf("cluster: processing backlog — dropped %d event(s); the feed is faster than enrichment/UI", n) + } + } +} + +// clusterEventWorker drains clusterEventCh and does everything that used to run +// inline in the socket-read callback: enrich a spot (DXCC/POTA/distance), emit it +// to the UI, run alert rules (which may query a remote MySQL) and mirror it to the +// Flex panadapter — all serialised on this one goroutine, off the read path. +func (a *App) clusterEventWorker() { + for ev := range a.clusterEventCh { + if ev.line != nil { + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "cluster:line", *ev.line) + } + continue + } + if ev.spot == nil { + continue + } + s := *ev.spot + if a.dxcc != nil { + if m, ok := a.dxcc.Lookup(s.DXCall); ok && m.Entity != nil { + s.Country = m.Entity.Name + s.Continent = m.Continent + s.CQZone = m.CQZone + s.ITUZone = m.ITUZone + if a.opSet && (m.Lat != 0 || m.Lon != 0) { + s.DistanceKm = int(haversineKm(a.opLat, a.opLon, m.Lat, m.Lon) + 0.5) + sp := initialBearingDeg(a.opLat, a.opLon, m.Lat, m.Lon) + s.ShortPath = int(sp + 0.5) + s.LongPath = (s.ShortPath + 180) % 360 + } + } + } + // POTA: tag the spot when the DX station is currently activating a park. + if a.pota != nil { + if info, ok := a.pota.Lookup(s.DXCall); ok { + s.POTARef = info.Reference + s.POTAName = info.ParkName + } + } + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "cluster:spot", s) + } + // A HISTORICAL spot (recovered from a SH/DX table) goes to the grid and + // stops there. It is a replay of the past: firing 100 alerts at once, or + // painting 100 stale stations on the panadapter as if they were on the air + // right now, would be actively misleading. + if s.Historical { + continue + } + // Fire any matching alert rules (sound / visual / e-mail). + a.evaluateAlerts(s) + // Mirror the spot onto the FlexRadio panadapter when enabled. + if a.catFlexSpots && a.cat != nil { + a.cat.SendSpot(cat.SpotInfo{ + FreqHz: s.FreqHz, + Callsign: s.DXCall, + Comment: s.Comment, + }) + } + } +} + func (a *App) evaluateAlerts(s cluster.Spot) { if a.alertStore == nil { return @@ -9043,7 +9192,7 @@ func (a *App) SetCATFrequency(hz int64) error { // honouring the same enabled/follow/in-range/step-deadband rules as the follow // loop. Called from SetCATFrequency so a spot click moves the antenna instantly. func (a *App) ultrabeamFollowNow(freqHz int64) { - c := a.ultrabeam + c := a.motorAnt if c == nil || freqHz <= 0 { return } @@ -9051,13 +9200,13 @@ func (a *App) ultrabeamFollowNow(freqHz int64) { if err != nil || !s.Enabled || !s.Follow { return } - applog.Printf("ultrabeam: followNow — deliberate CAT set to %.3f MHz", float64(freqHz)/1e6) + applog.Printf("motor-antenna: followNow — deliberate CAT set to %.3f MHz", float64(freqHz)/1e6) step := s.StepKHz if step <= 0 { step = 50 } - st, err := c.GetStatus() - if err != nil || st == nil || !st.Connected { + st := c.Status() + if !st.Connected { return } if st.FreqMin > 0 && st.FreqMax > 0 { @@ -10357,32 +10506,109 @@ func boolStr(b bool) string { return "0" } -// ── Ultrabeam antenna (TCP) ──────────────────────────────────────────── +// ── Motorized antenna (Ultrabeam / SteppIR) ──────────────────────────── -// 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"` - Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency - StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100) +// motorAntenna is the shared control surface of a motorized antenna. The +// Ultrabeam and SteppIR clients differ only in wire protocol; everything the app +// does — poll, follow the rig, switch pattern, retract — is the same, so it runs +// against this interface and neither the follow loop nor the UI cares which is on +// the other end. +type motorStatus struct { + Connected bool + Direction int // 0 normal, 1 180°, 2 bidirectional + Frequency int // kHz + Band int + Moving bool + FreqMin int // MHz, 0 = unknown (SteppIR does not report a tunable range) + FreqMax int // MHz, 0 = unknown +} +type motorAntenna interface { + Start() error + Stop() + SetFrequency(khz, dir int) error + SetDirection(dir int) error + Retract() error + LastSetKHz() int + Status() motorStatus } -// GetUltrabeamSettings returns the persisted Ultrabeam config with defaults. +// ubAdapter / steppirAdapter wrap each concrete client to the shared interface, +// translating only the status shape (both already use the same 0/1/2 direction +// convention, so commands pass straight through). +type ubAdapter struct{ c *ultrabeam.Client } + +func (a ubAdapter) Start() error { return a.c.Start() } +func (a ubAdapter) Stop() { a.c.Stop() } +func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) } +func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) } +func (a ubAdapter) Retract() error { return a.c.Retract() } +func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() } +func (a ubAdapter) Status() motorStatus { + st, err := a.c.GetStatus() + if err != nil || st == nil { + return motorStatus{} + } + return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0, FreqMin: st.FreqMin, FreqMax: st.FreqMax} +} + +type steppirAdapter struct{ c *steppir.Client } + +func (a steppirAdapter) Start() error { return a.c.Start() } +func (a steppirAdapter) Stop() { a.c.Stop() } +func (a steppirAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) } +func (a steppirAdapter) SetDirection(d int) error { return a.c.SetDirection(d) } +func (a steppirAdapter) Retract() error { return a.c.Retract() } +func (a steppirAdapter) LastSetKHz() int { return a.c.LastSetKHz() } +func (a steppirAdapter) Status() motorStatus { + st, err := a.c.GetStatus() + if err != nil || st == nil { + return motorStatus{} + } + return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0} +} + +// UltrabeamSettings is the JSON shape for the Hardware → Antenna panel. The name +// is kept (bindings + frontend) though it now covers SteppIR too. +type UltrabeamSettings struct { + Enabled bool `json:"enabled"` + Type string `json:"type"` // "ultrabeam" | "steppir" + Transport string `json:"transport"` // "tcp" | "serial" + Host string `json:"host"` // tcp + Port int `json:"port"` // tcp + COM string `json:"com"` // serial device + Baud int `json:"baud"` // serial baud + Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency + StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100) +} + +// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting +// to the pre-SteppIR behaviour (Ultrabeam over TCP) so an existing install is +// unchanged. func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) { - out := UltrabeamSettings{Port: 23, StepKHz: 50} + out := UltrabeamSettings{Type: "ultrabeam", Transport: "tcp", Port: 23, Baud: 9600, StepKHz: 50} if a.settings == nil { return out, fmt.Errorf("db not initialized") } - m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep) + m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep, + keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud) if err != nil { return out, err } out.Enabled = m[keyUltrabeamEnabled] == "1" + if t := m[keyMotorType]; t == "steppir" || t == "ultrabeam" { + out.Type = t + } + if tr := m[keyMotorTransport]; tr == "serial" || tr == "tcp" { + out.Transport = tr + } out.Host = m[keyUltrabeamHost] if p, _ := strconv.Atoi(m[keyUltrabeamPort]); p > 0 && p <= 65535 { out.Port = p } + out.COM = m[keyMotorCOM] + if b, _ := strconv.Atoi(m[keyMotorBaud]); b >= 1200 && b <= 115200 { + out.Baud = b + } out.Follow = m[keyUltrabeamFollow] == "1" if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 { out.StepKHz = st @@ -10402,12 +10628,25 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error { if s.StepKHz != 25 && s.StepKHz != 50 && s.StepKHz != 100 { s.StepKHz = 50 } + if s.Type != "steppir" { + s.Type = "ultrabeam" + } + if s.Transport != "serial" { + s.Transport = "tcp" + } + if s.Baud < 1200 || s.Baud > 115200 { + s.Baud = 9600 + } for k, v := range map[string]string{ keyUltrabeamEnabled: boolStr(s.Enabled), keyUltrabeamHost: strings.TrimSpace(s.Host), keyUltrabeamPort: strconv.Itoa(s.Port), keyUltrabeamFollow: boolStr(s.Follow), keyUltrabeamStep: strconv.Itoa(s.StepKHz), + keyMotorType: s.Type, + keyMotorTransport: s.Transport, + keyMotorCOM: strings.TrimSpace(s.COM), + keyMotorBaud: strconv.Itoa(s.Baud), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err @@ -10417,6 +10656,27 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error { return nil } +// newMotorClient builds the concrete client for the configured antenna type and +// transport, wrapped in the shared interface. Returns nil if nothing usable is +// configured (no host for TCP, no COM for serial). +func newMotorClient(s UltrabeamSettings) motorAntenna { + if s.Type == "steppir" { + tr := steppir.Transport{Mode: s.Transport, Host: strings.TrimSpace(s.Host), Port: s.Port, COM: strings.TrimSpace(s.COM), Baud: s.Baud} + if tr.Mode == "serial" && tr.COM == "" { + return nil + } + if tr.Mode != "serial" && tr.Host == "" { + return nil + } + return steppirAdapter{steppir.New(tr)} + } + // Ultrabeam is TCP only. + if strings.TrimSpace(s.Host) == "" { + return nil + } + return ubAdapter{ultrabeam.New(s.Host, s.Port)} +} + // 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). @@ -10426,22 +10686,26 @@ func (a *App) startUltrabeam() { close(a.ubFollowStop) a.ubFollowStop = nil } - if a.ultrabeam != nil { + if a.motorAnt != nil { // Background teardown so saving Settings doesn't block on an in-progress // connect (Stop waits for the dial timeout). - go a.ultrabeam.Stop() - a.ultrabeam = nil + go a.motorAnt.Stop() + a.motorAnt = nil } s, err := a.GetUltrabeamSettings() - if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" { + if err != nil || !s.Enabled { return } - a.ultrabeam = ultrabeam.New(s.Host, s.Port) - _ = a.ultrabeam.Start() + c := newMotorClient(s) + if c == nil { + return // not configured (missing host/COM) + } + a.motorAnt = c + _ = a.motorAnt.Start() if s.Follow { stop := make(chan struct{}) a.ubFollowStop = stop - go a.ultrabeamFollowLoop(a.ultrabeam, s.StepKHz, stop) + go a.ultrabeamFollowLoop(a.motorAnt, s.StepKHz, stop) } } @@ -10449,7 +10713,7 @@ func (a *App) startUltrabeam() { // whenever it drifts at least stepKHz from what the antenna is set to — so the // elements track the band without the motors chasing every small QSY. Runs // until stop is closed (a settings change or shutdown). -func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan struct{}) { +func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struct{}) { if stepKHz <= 0 { stepKHz = 50 } @@ -10468,8 +10732,8 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan if !rs.Connected || rs.FreqHz <= 0 { continue } - st, err := c.GetStatus() - if err != nil || st == nil || !st.Connected { + st := c.Status() + if !st.Connected { continue } rigKHz := int(rs.FreqHz / 1000) @@ -10529,74 +10793,81 @@ 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 { + if a.motorAnt == nil { return out } + st := a.motorAnt.Status() out.Connected = st.Connected out.Direction = st.Direction out.Frequency = st.Frequency out.Band = st.Band - out.Moving = st.MotorsMoving != 0 + out.Moving = st.Moving 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 a.motorAnt == nil { + return fmt.Errorf("antenna not connected — enable it in Settings → Antenna") } if direction < 0 || direction > 2 { return fmt.Errorf("invalid direction %d", direction) } - // The device has no standalone direction command: it re-issues the current - // frequency with the new direction byte. If the antenna hasn't reported a - // frequency yet (just connected / remote link still settling), fall back to - // the rig's current CAT frequency so the control still works. - st, _ := a.ultrabeam.GetStatus() - if (st == nil || st.Frequency <= 0) && a.cat != nil { + // Neither controller has a standalone direction command: both re-issue the + // current frequency with the new direction byte. If the antenna hasn't reported + // a frequency yet (just connected / link settling), fall back to the rig's CAT + // frequency so the control still works. + st := a.motorAnt.Status() + if st.Frequency <= 0 && a.cat != nil { if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 { - return a.ultrabeam.SetFrequency(int(rs.FreqHz/1000), direction) + return a.motorAnt.SetFrequency(int(rs.FreqHz/1000), direction) } } - return a.ultrabeam.SetDirection(direction) + return a.motorAnt.SetDirection(direction) } // UltrabeamRetract retracts all elements (storage / safe position). func (a *App) UltrabeamRetract() error { - if a.ultrabeam == nil { - return fmt.Errorf("Ultrabeam not connected") + if a.motorAnt == nil { + return fmt.Errorf("antenna not connected") } - return a.ultrabeam.Retract() + return a.motorAnt.Retract() } -// TestUltrabeam opens a one-shot TCP connection and reads one status frame to -// verify host/port without disturbing the running poller. +// TestUltrabeam opens a one-shot connection and reads one status frame to verify +// the transport (TCP host/port or serial COM) without disturbing the running poller. func (a *App) TestUltrabeam(s UltrabeamSettings) error { - if strings.TrimSpace(s.Host) == "" { + if s.Transport == "serial" { + if strings.TrimSpace(s.COM) == "" { + return fmt.Errorf("serial port required") + } + } else 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) + c := newMotorClient(s) + if c == nil { + return fmt.Errorf("antenna not configured") + } 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) + // The poller connects + reads status on its tick; give it a few cycles to come + // up, then check we got a live status frame. + deadline := time.Now().Add(8 * time.Second) for time.Now().Before(deadline) { time.Sleep(500 * time.Millisecond) - if st, err := c.GetStatus(); err == nil && st != nil && st.Connected { + if st := c.Status(); st.Connected { return nil } } + if s.Transport == "serial" { + return fmt.Errorf("no response on %s", s.COM) + } return fmt.Errorf("no response from %s:%d", s.Host, s.Port) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f322f55..90fcd84 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3503,10 +3503,10 @@ export default function App() { ); })()} - {/* Ultrabeam pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */} + {/* Motorized-antenna pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */} {ubStatus.enabled && (
+ title={ubStatus.connected ? (ubStatus.moving ? 'Antenna: moving…' : 'Antenna pattern') : 'Antenna: connecting…'}> diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index c121dc8..56e9c1f 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -261,7 +261,7 @@ const SECTION_LABELS: Partial> = { cat: 'CAT interface', rotator: 'PstRotator', winkeyer: 'CW Keyer', - antenna: 'UltraBeam', + antenna: 'Ultrabeam / Steppir', antgenius: 'Antenna Genius', pgxl: 'Power Genius', flex: 'FlexRadio', @@ -826,9 +826,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan 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; follow: boolean; step_khz: number }>({ - enabled: false, host: '', port: 23, follow: false, step_khz: 50, + // Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings. + const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number }>({ + enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, }); const [ubTesting, setUbTesting] = useState(false); const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null); @@ -2267,36 +2267,92 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan } function UltrabeamPanel() { + const isSteppir = ultrabeam.type === 'steppir'; + const isSerial = isSteppir && ultrabeam.transport === 'serial'; 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" - /> + + {/* Ultrabeam is TCP only; picking it forces the transport back to TCP + so the serial fields never apply to it. */} +
+ {isSteppir && ( +
+ + +
+ )}
+ {isSerial ? ( +
+
+ + setUltrabeam((s) => ({ ...s, com: e.target.value }))} + placeholder="COM3" + className="font-mono" + /> +
+
+ + +
+
+ ) : ( +
+
+ + 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" + /> +
+
+ )} + {isSteppir && ( +

{t('hw.steppirHint')}

+ )}
-
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 2cca52a..4bac996 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -91,7 +91,7 @@ const en: Dict = { 'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster', 'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'CW Keyer', - 'sec.antenna': 'UltraBeam', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices', + 'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices', // General panel 'gen.hint': 'App behaviour (saved instantly).', 'gen.autofocusWB': 'Auto-focus "Worked before" for known stations', @@ -165,7 +165,7 @@ const en: Dict = { 'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.', 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.", 'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).', - 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer', + 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer', // CAT panel body 'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', 'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password', @@ -355,7 +355,7 @@ const fr: Dict = { 'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster', 'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'Manipulateur CW', - 'sec.antenna': 'UltraBeam', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio', + 'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio', 'gen.hint': 'Comportement de l\'application (enregistré immédiatement).', 'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues', 'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale', @@ -420,7 +420,7 @@ const fr: Dict = { 'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.", 'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).", - 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal', + 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal', 'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', 'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau', 'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.", diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index d07290d..585c350 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2465,8 +2465,12 @@ export namespace main { } export class UltrabeamSettings { enabled: boolean; + type: string; + transport: string; host: string; port: number; + com: string; + baud: number; follow: boolean; step_khz: number; @@ -2477,8 +2481,12 @@ export namespace main { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.enabled = source["enabled"]; + this.type = source["type"]; + this.transport = source["transport"]; this.host = source["host"]; this.port = source["port"]; + this.com = source["com"]; + this.baud = source["baud"]; this.follow = source["follow"]; this.step_khz = source["step_khz"]; } diff --git a/internal/steppir/steppir.go b/internal/steppir/steppir.go new file mode 100644 index 0000000..cc8fa01 --- /dev/null +++ b/internal/steppir/steppir.go @@ -0,0 +1,363 @@ +// Package steppir controls a SteppIR SDA-100 / SDA-2000 antenna controller over +// its "Transceiver Interface" serial protocol, reached either directly on a COM +// port or over TCP through an RS232↔Ethernet bridge (the same way OpsLog talks to +// an Ultrabeam). The client mirrors the ultrabeam.Client surface so the app can +// drive either behind one interface. +// +// Protocol (cross-checked against the SteppIR "Transceiver Interface Operation" +// note, the we7u/steppir library, and the la1k.no write-up — three independent +// sources that agree, which is what makes the byte layout trustworthy): +// +// SET : "@A" 00 00 0x0D (11 bytes) +// = int32 big-endian of (Hz / 10) +// = 0x00 normal · 0x40 180° · 0x80 bidirectional · 0x20 3/4-wave +// = '1' set freq+dir · 'R' autotrack ON · 'U' autotrack OFF +// 'S' home/retract · 'V' calibrate +// STATUS: "?A" 0x0D → 11 bytes back: +// [2:6] int32 big-endian frequency (× 10 = Hz) +// [6] active-motor bitmask (0xFF = command received / setup) +// [7] & 0xE0 direction +// +// Timing: the controller needs ≥100 ms between commands and dislikes status +// polls faster than ~10/s. The poll loop runs at 2 s, well inside that. +package steppir + +import ( + "encoding/binary" + "fmt" + "io" + "log" + "net" + "sync" + "time" + + "go.bug.st/serial" +) + +// Direction values, matching the app-wide convention (also used by Ultrabeam): +// 0 normal, 1 reverse (180°), 2 bidirectional. +const ( + DirNormal = 0 + Dir180 = 1 + DirBi = 2 +) + +// SteppIR direction bytes on the wire. +const ( + wireNormal = 0x00 + wire180 = 0x40 + wireBi = 0x80 +) + +// Transport says how to reach the controller. +type Transport struct { + Mode string // "tcp" | "serial" + Host string // tcp + Port int // tcp + COM string // serial device (COM3, /dev/ttyUSB0) + Baud int // serial baud (controller default 9600; 1200-19200 valid) +} + +// Status is the antenna state, in the same shape the app reads from the +// Ultrabeam so the two are interchangeable at the UI. +type Status struct { + Connected bool `json:"connected"` + Frequency int `json:"frequency"` // kHz + Band int `json:"band"` // 0 (SteppIR does not report a band index) + Direction int `json:"direction"` // 0 normal, 1 180°, 2 bidirectional + MotorsMoving int `json:"motors_moving"` +} + +type Client struct { + tr Transport + + connMu sync.Mutex + conn io.ReadWriteCloser + + statusMu sync.RWMutex + lastStatus *Status + lastSetKHz int + + // A just-commanded direction is held until the controller's poll reports it — + // the motors take a second or two, and a stale poll would otherwise snap the + // UI back. Same trick as the Ultrabeam client. + pendingDir int + pendingDirAt time.Time + pendingDirSet bool + + // After a Home/Retract the controller drops out of AUTOTRACK and ignores + // frequency sets until it is turned back ON. Set on Retract, cleared by + // re-enabling on the next SetFrequency. + needAutotrack bool + + stopChan chan struct{} + running bool +} + +func New(tr Transport) *Client { + if tr.Baud <= 0 { + tr.Baud = 9600 + } + return &Client{tr: tr, stopChan: make(chan struct{})} +} + +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() +} + +// LastSetKHz returns the frequency last commanded, or 0. +func (c *Client) LastSetKHz() int { + c.statusMu.RLock() + defer c.statusMu.RUnlock() + return c.lastSetKHz +} + +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 +} + +// open dials the transport. Callers hold connMu. +func (c *Client) open() (io.ReadWriteCloser, error) { + switch c.tr.Mode { + case "serial": + if c.tr.COM == "" { + return nil, fmt.Errorf("steppir: no serial port configured") + } + p, err := serial.Open(c.tr.COM, &serial.Mode{BaudRate: c.tr.Baud}) + if err != nil { + return nil, err + } + // A finite read timeout so a silent controller doesn't wedge the poll loop. + _ = p.SetReadTimeout(2 * time.Second) + return p, nil + default: // tcp + if c.tr.Host == "" { + return nil, fmt.Errorf("steppir: no host configured") + } + d := net.Dialer{Timeout: 5 * time.Second} + return d.Dial("tcp", net.JoinHostPort(c.tr.Host, fmt.Sprintf("%d", c.tr.Port))) + } +} + +func (c *Client) pollLoop() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case <-c.stopChan: + return + case <-ticker.C: + c.connMu.Lock() + if c.conn == nil { + conn, err := c.open() + if err != nil { + c.connMu.Unlock() + c.setDisconnected() + continue + } + c.conn = conn + } + c.connMu.Unlock() + + st, err := c.queryStatus() + if err != nil { + log.Printf("steppir: status query failed, reconnecting: %v", err) + c.closeConn() + c.setDisconnected() + continue + } + st.Connected = true + c.statusMu.Lock() + if c.pendingDirSet { + if time.Since(c.pendingDirAt) > 4*time.Second || st.Direction == c.pendingDir { + c.pendingDirSet = false + } else { + st.Direction = c.pendingDir + } + } + c.lastStatus = st + c.statusMu.Unlock() + } + } +} + +func (c *Client) setDisconnected() { + c.statusMu.Lock() + c.lastStatus = &Status{Connected: false} + c.statusMu.Unlock() +} + +func (c *Client) closeConn() { + c.connMu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.connMu.Unlock() +} + +// setDeadline applies a read/write deadline on TCP; serial uses its own timeout. +func setDeadline(conn io.ReadWriteCloser, d time.Duration) { + if nc, ok := conn.(net.Conn); ok { + _ = nc.SetDeadline(time.Now().Add(d)) + } +} + +func (c *Client) queryStatus() (*Status, error) { + c.connMu.Lock() + conn := c.conn + c.connMu.Unlock() + if conn == nil { + return nil, fmt.Errorf("steppir: not connected") + } + setDeadline(conn, 3*time.Second) + if _, err := conn.Write([]byte("?A\r")); err != nil { + return nil, fmt.Errorf("write status cmd: %w", err) + } + buf := make([]byte, 11) + if _, err := io.ReadFull(conn, buf); err != nil { + return nil, fmt.Errorf("read status: %w", err) + } + return parseStatus(buf) +} + +// parseStatus decodes an 11-byte status frame. +func parseStatus(b []byte) (*Status, error) { + if len(b) < 11 { + return nil, fmt.Errorf("steppir: short status frame (%d bytes)", len(b)) + } + freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10 + active := b[6] + dir := decodeDir(b[7]) + // active==0xFF means "command just received" (not motion); the 0x01 bit is + // documented as always set. Treat anything else non-zero as motors busy. + moving := 0 + if active != 0xFF && (active & ^byte(0x01)) != 0 { + moving = 1 + } + return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil +} + +func decodeDir(b byte) int { + switch b & 0xE0 { + case wireBi: + return DirBi + case wire180: + return Dir180 + default: + return DirNormal + } +} + +func dirWireByte(dir int) byte { + switch dir { + case Dir180: + return wire180 + case DirBi: + return wireBi + default: + return wireNormal + } +} + +// buildSet frames a SET command: "@A" 00 00 CR. +func buildSet(freqHz int, dir int, cmd byte) []byte { + var f [4]byte + binary.BigEndian.PutUint32(f[:], uint32(freqHz/10)) + out := make([]byte, 0, 11) + out = append(out, '@', 'A') + out = append(out, f[:]...) + out = append(out, 0x00, dirWireByte(dir), cmd, 0x00, 0x0D) + return out +} + +func (c *Client) writeCmd(pkt []byte) error { + c.connMu.Lock() + conn := c.conn + c.connMu.Unlock() + if conn == nil { + return fmt.Errorf("steppir: not connected") + } + setDeadline(conn, 3*time.Second) + if _, err := conn.Write(pkt); err != nil { + c.closeConn() + return err + } + // The controller needs breathing room between commands. + time.Sleep(120 * time.Millisecond) + return nil +} + +// SetFrequency tunes the elements to freqKhz with the given direction. If a prior +// Retract dropped AUTOTRACK, re-enable it first — otherwise the set is ignored. +func (c *Client) SetFrequency(freqKhz int, direction int) error { + if c.needAutotrack { + if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil { + return err + } + c.needAutotrack = false + } + if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil { + return err + } + c.statusMu.Lock() + c.lastSetKHz = freqKhz + c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true + c.statusMu.Unlock() + return nil +} + +// SetDirection changes the pattern. SteppIR has no standalone direction command — +// it is a SET with the current frequency and the new direction byte. +func (c *Client) SetDirection(direction int) error { + khz := c.LastSetKHz() + if khz <= 0 { + if st, _ := c.GetStatus(); st != nil { + khz = st.Frequency + } + } + if khz <= 0 { + return fmt.Errorf("steppir: no frequency known yet — cannot set direction") + } + return c.SetFrequency(khz, direction) +} + +// Retract homes the elements into the hubs (storage). This leaves AUTOTRACK off, +// so the next SetFrequency re-enables it. +func (c *Client) Retract() error { + // A valid frequency must accompany the command; reuse the last one. + khz := c.LastSetKHz() + if khz <= 0 { + if st, _ := c.GetStatus(); st != nil && st.Frequency > 0 { + khz = st.Frequency + } else { + khz = 14000 // any in-range value; the controller just homes + } + } + if err := c.writeCmd(buildSet(khz*1000, DirNormal, 'S')); err != nil { + return err + } + c.needAutotrack = true + return nil +} diff --git a/internal/steppir/steppir_test.go b/internal/steppir/steppir_test.go new file mode 100644 index 0000000..88f233e --- /dev/null +++ b/internal/steppir/steppir_test.go @@ -0,0 +1,84 @@ +package steppir + +import ( + "encoding/binary" + "testing" +) + +// The exact bytes are the correctness checksum. If buildSet ever drifts from the +// three-source-agreed layout, this fails — a wrong packet is a silently mistuned +// antenna, far worse than a compile error. +func TestBuildSetLayout(t *testing.T) { + // 14.074 MHz, normal, set-freq. freq/10 = 1_407_400 = 0x00 0x15 0x79 0xA8. + pkt := buildSet(14_074_000, DirNormal, '1') + want := []byte{'@', 'A', 0x00, 0x15, 0x79, 0xA8, 0x00, 0x00, '1', 0x00, 0x0D} + if len(pkt) != 11 { + t.Fatalf("packet is %d bytes, want 11", len(pkt)) + } + for i := range want { + if pkt[i] != want[i] { + t.Fatalf("byte %d = 0x%02X, want 0x%02X\n got %X\nwant %X", i, pkt[i], want[i], pkt, want) + } + } + // Frequency must round-trip: bytes [2:6] × 10 = Hz. + if got := int(binary.BigEndian.Uint32(pkt[2:6])) * 10; got != 14_074_000 { + t.Fatalf("freq round-trip = %d, want 14074000", got) + } +} + +func TestBuildSetDirectionAndCommand(t *testing.T) { + cases := []struct { + dir int + cmd byte + wantDir byte + wantCmd byte + }{ + {DirNormal, '1', 0x00, '1'}, + {Dir180, '1', 0x40, '1'}, + {DirBi, '1', 0x80, '1'}, + {DirNormal, 'S', 0x00, 'S'}, // retract / home + {DirNormal, 'R', 0x00, 'R'}, // autotrack on + } + for _, c := range cases { + pkt := buildSet(21_000_000, c.dir, c.cmd) + if pkt[7] != c.wantDir { + t.Errorf("dir %d → byte 0x%02X, want 0x%02X", c.dir, pkt[7], c.wantDir) + } + if pkt[8] != c.wantCmd { + t.Errorf("cmd %q → byte 0x%02X, want 0x%02X", c.cmd, pkt[8], c.wantCmd) + } + } +} + +// parseStatus decodes what the controller sends back — the inverse of buildSet's +// frequency field, plus the direction nibble. +func TestParseStatus(t *testing.T) { + frame := []byte{0x00, 0x00, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '1', '2', 0x0D} + st, err := parseStatus(frame) + if err != nil { + t.Fatal(err) + } + if st.Frequency != 14074 { + t.Errorf("freq = %d kHz, want 14074", st.Frequency) + } + if st.Direction != Dir180 { + t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180) + } + if st.MotorsMoving != 0 { // 0x01 is the always-set bit, not motion + t.Errorf("moving = %d, want 0 (only the always-on bit set)", st.MotorsMoving) + } + + // Motors busy: a bit beyond 0x01 is set. + frame[6] = 0x07 + st, _ = parseStatus(frame) + if st.MotorsMoving == 0 { + t.Error("active-motors 0x07 should read as moving") + } + + // 0xFF is "command received", not motion. + frame[6] = 0xFF + st, _ = parseStatus(frame) + if st.MotorsMoving != 0 { + t.Error("active-motors 0xFF (command received) must not read as moving") + } +}