feat: While closing OpsLog will keep the same size and position for next launch
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hamlog/internal/adif"
|
"hamlog/internal/adif"
|
||||||
@@ -38,11 +39,11 @@ import (
|
|||||||
"hamlog/internal/extsvc"
|
"hamlog/internal/extsvc"
|
||||||
"hamlog/internal/integrations/udp"
|
"hamlog/internal/integrations/udp"
|
||||||
"hamlog/internal/lookup"
|
"hamlog/internal/lookup"
|
||||||
|
"hamlog/internal/lotwusers"
|
||||||
"hamlog/internal/netctl"
|
"hamlog/internal/netctl"
|
||||||
|
"hamlog/internal/offlineq"
|
||||||
"hamlog/internal/operating"
|
"hamlog/internal/operating"
|
||||||
"hamlog/internal/pota"
|
"hamlog/internal/pota"
|
||||||
"hamlog/internal/lotwusers"
|
|
||||||
"hamlog/internal/offlineq"
|
|
||||||
"hamlog/internal/powergenius"
|
"hamlog/internal/powergenius"
|
||||||
"hamlog/internal/profile"
|
"hamlog/internal/profile"
|
||||||
"hamlog/internal/qslcard"
|
"hamlog/internal/qslcard"
|
||||||
@@ -50,6 +51,7 @@ import (
|
|||||||
"hamlog/internal/rotator/pst"
|
"hamlog/internal/rotator/pst"
|
||||||
"hamlog/internal/settings"
|
"hamlog/internal/settings"
|
||||||
"hamlog/internal/solar"
|
"hamlog/internal/solar"
|
||||||
|
"hamlog/internal/steppir"
|
||||||
"hamlog/internal/ultrabeam"
|
"hamlog/internal/ultrabeam"
|
||||||
"hamlog/internal/winkeyer"
|
"hamlog/internal/winkeyer"
|
||||||
|
|
||||||
@@ -153,12 +155,19 @@ const (
|
|||||||
keyRotatorPort = "rotator.port"
|
keyRotatorPort = "rotator.port"
|
||||||
keyRotatorHasElevation = "rotator.has_elevation"
|
keyRotatorHasElevation = "rotator.has_elevation"
|
||||||
|
|
||||||
// Ultrabeam antenna (TCP, e.g. via an RS232↔Ethernet adapter) — Hardware → Antenna.
|
// 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"
|
keyUltrabeamEnabled = "ultrabeam.enabled"
|
||||||
keyUltrabeamHost = "ultrabeam.host"
|
keyUltrabeamHost = "ultrabeam.host"
|
||||||
keyUltrabeamPort = "ultrabeam.port"
|
keyUltrabeamPort = "ultrabeam.port"
|
||||||
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
|
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
|
||||||
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
|
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
|
// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
|
||||||
// port is fixed at 9007, so only the IP is configurable.
|
// port is fixed at 9007, so only the IP is configurable.
|
||||||
@@ -413,6 +422,14 @@ type App struct {
|
|||||||
cat *cat.Manager
|
cat *cat.Manager
|
||||||
dxcc *dxcc.Manager
|
dxcc *dxcc.Manager
|
||||||
cluster *cluster.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
|
pota *pota.Cache
|
||||||
awardRefs *awardref.Repo
|
awardRefs *awardref.Repo
|
||||||
qslTemplates *qslcard.Repo
|
qslTemplates *qslcard.Repo
|
||||||
@@ -422,7 +439,7 @@ type App struct {
|
|||||||
extsvc *extsvc.Manager
|
extsvc *extsvc.Manager
|
||||||
winkeyer *winkeyer.Manager
|
winkeyer *winkeyer.Manager
|
||||||
clublog *clublog.Manager
|
clublog *clublog.Manager
|
||||||
ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled
|
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
|
||||||
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
||||||
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
||||||
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
||||||
@@ -478,6 +495,7 @@ type App struct {
|
|||||||
// blocking the window close; the subsequent programmatic Quit() call
|
// blocking the window close; the subsequent programmatic Quit() call
|
||||||
// must be allowed through.
|
// must be allowed through.
|
||||||
shuttingDown bool
|
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
|
// Cached operator location used to compute distance/bearing for
|
||||||
// cluster spots. Refreshed on profile activation; zero means
|
// cluster spots. Refreshed on profile activation; zero means
|
||||||
@@ -862,52 +880,17 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// with country + continent via cty.dat BEFORE emitting it, so the UI
|
// with country + continent via cty.dat BEFORE emitting it, so the UI
|
||||||
// renders the row with all metadata already filled (no flicker of
|
// renders the row with all metadata already filled (no flicker of
|
||||||
// empty Country / Cont columns while the batch status fetch runs).
|
// 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(
|
a.cluster = cluster.NewManager(
|
||||||
func(s cluster.Spot) {
|
// onSpot / onLine run on the session's socket-read goroutine, so they must
|
||||||
if a.dxcc != nil {
|
// do nothing slow: just enqueue and return, keeping the TCP socket drained.
|
||||||
if m, ok := a.dxcc.Lookup(s.DXCall); ok && m.Entity != nil {
|
func(s cluster.Spot) { a.enqueueClusterEvent(clusterEvent{spot: &s}) },
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
func() {
|
func() {
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status())
|
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,
|
// 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
|
// 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.
|
// they were dropped on the floor and the command box looked inert.
|
||||||
func(l cluster.Line) {
|
func(l cluster.Line) { a.enqueueClusterEvent(clusterEvent{line: &l}) },
|
||||||
if a.ctx != nil {
|
|
||||||
wruntime.EventsEmit(a.ctx, "cluster:line", l)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
a.refreshOperatorGrid()
|
a.refreshOperatorGrid()
|
||||||
if cs, _ := a.clusterAutoConnect(); cs {
|
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.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL
|
||||||
go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence
|
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)
|
fmt.Println("OpsLog: db ready at", a.dbPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1063,6 +1045,10 @@ func (a *App) beforeClose(ctx context.Context) bool {
|
|||||||
}
|
}
|
||||||
a.shuttingDown = true
|
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()
|
steps := a.plannedShutdownSteps()
|
||||||
if len(steps) == 0 {
|
if len(steps) == 0 {
|
||||||
// Nothing to do — exit immediately, no need to flash a modal.
|
// 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") }
|
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
|
// readBootstrap returns the full bootstrap config (DB path + MySQL), or a zero
|
||||||
// value if the file is missing/unreadable.
|
// value if the file is missing/unreadable.
|
||||||
func readBootstrap(dataDir string) dbPointer {
|
func readBootstrap(dataDir string) dbPointer {
|
||||||
@@ -4674,6 +4737,7 @@ func (a *App) SetCompactMode(on bool) {
|
|||||||
if a.ctx == nil {
|
if a.ctx == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.compact = on
|
||||||
if on {
|
if on {
|
||||||
// Lock the window to the compact size by pinning min == max. Without
|
// Lock the window to the compact size by pinning min == max. Without
|
||||||
// the max pin, dragging the frameless window (esp. across monitors /
|
// the max pin, dragging the frameless window (esp. across monitors /
|
||||||
@@ -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
|
// 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
|
// rules exist; the worked-before check only runs for rules that opt into it and
|
||||||
// already matched, so it's off the hot path.
|
// 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) {
|
func (a *App) evaluateAlerts(s cluster.Spot) {
|
||||||
if a.alertStore == nil {
|
if a.alertStore == nil {
|
||||||
return
|
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
|
// 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.
|
// loop. Called from SetCATFrequency so a spot click moves the antenna instantly.
|
||||||
func (a *App) ultrabeamFollowNow(freqHz int64) {
|
func (a *App) ultrabeamFollowNow(freqHz int64) {
|
||||||
c := a.ultrabeam
|
c := a.motorAnt
|
||||||
if c == nil || freqHz <= 0 {
|
if c == nil || freqHz <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -9051,13 +9200,13 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
|
|||||||
if err != nil || !s.Enabled || !s.Follow {
|
if err != nil || !s.Enabled || !s.Follow {
|
||||||
return
|
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
|
step := s.StepKHz
|
||||||
if step <= 0 {
|
if step <= 0 {
|
||||||
step = 50
|
step = 50
|
||||||
}
|
}
|
||||||
st, err := c.GetStatus()
|
st := c.Status()
|
||||||
if err != nil || st == nil || !st.Connected {
|
if !st.Connected {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if st.FreqMin > 0 && st.FreqMax > 0 {
|
if st.FreqMin > 0 && st.FreqMax > 0 {
|
||||||
@@ -10357,32 +10506,109 @@ func boolStr(b bool) string {
|
|||||||
return "0"
|
return "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ultrabeam antenna (TCP) ────────────────────────────────────────────
|
// ── Motorized antenna (Ultrabeam / SteppIR) ────────────────────────────
|
||||||
|
|
||||||
// UltrabeamSettings is the JSON shape for the Hardware → Antenna panel.
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
type UltrabeamSettings struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Host string `json:"host"`
|
Type string `json:"type"` // "ultrabeam" | "steppir"
|
||||||
Port int `json:"port"`
|
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
|
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)
|
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUltrabeamSettings returns the persisted Ultrabeam config with defaults.
|
// 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) {
|
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 {
|
if a.settings == nil {
|
||||||
return out, fmt.Errorf("db not initialized")
|
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 {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
out.Enabled = m[keyUltrabeamEnabled] == "1"
|
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]
|
out.Host = m[keyUltrabeamHost]
|
||||||
if p, _ := strconv.Atoi(m[keyUltrabeamPort]); p > 0 && p <= 65535 {
|
if p, _ := strconv.Atoi(m[keyUltrabeamPort]); p > 0 && p <= 65535 {
|
||||||
out.Port = p
|
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"
|
out.Follow = m[keyUltrabeamFollow] == "1"
|
||||||
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
|
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
|
||||||
out.StepKHz = st
|
out.StepKHz = st
|
||||||
@@ -10402,12 +10628,25 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
|
|||||||
if s.StepKHz != 25 && s.StepKHz != 50 && s.StepKHz != 100 {
|
if s.StepKHz != 25 && s.StepKHz != 50 && s.StepKHz != 100 {
|
||||||
s.StepKHz = 50
|
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{
|
for k, v := range map[string]string{
|
||||||
keyUltrabeamEnabled: boolStr(s.Enabled),
|
keyUltrabeamEnabled: boolStr(s.Enabled),
|
||||||
keyUltrabeamHost: strings.TrimSpace(s.Host),
|
keyUltrabeamHost: strings.TrimSpace(s.Host),
|
||||||
keyUltrabeamPort: strconv.Itoa(s.Port),
|
keyUltrabeamPort: strconv.Itoa(s.Port),
|
||||||
keyUltrabeamFollow: boolStr(s.Follow),
|
keyUltrabeamFollow: boolStr(s.Follow),
|
||||||
keyUltrabeamStep: strconv.Itoa(s.StepKHz),
|
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 {
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -10417,6 +10656,27 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
|
|||||||
return nil
|
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
|
// startUltrabeam stops any existing client and starts a fresh one if the
|
||||||
// antenna is enabled and configured. Safe to call repeatedly (on startup and
|
// antenna is enabled and configured. Safe to call repeatedly (on startup and
|
||||||
// after a settings save).
|
// after a settings save).
|
||||||
@@ -10426,22 +10686,26 @@ func (a *App) startUltrabeam() {
|
|||||||
close(a.ubFollowStop)
|
close(a.ubFollowStop)
|
||||||
a.ubFollowStop = nil
|
a.ubFollowStop = nil
|
||||||
}
|
}
|
||||||
if a.ultrabeam != nil {
|
if a.motorAnt != nil {
|
||||||
// Background teardown so saving Settings doesn't block on an in-progress
|
// Background teardown so saving Settings doesn't block on an in-progress
|
||||||
// connect (Stop waits for the dial timeout).
|
// connect (Stop waits for the dial timeout).
|
||||||
go a.ultrabeam.Stop()
|
go a.motorAnt.Stop()
|
||||||
a.ultrabeam = nil
|
a.motorAnt = nil
|
||||||
}
|
}
|
||||||
s, err := a.GetUltrabeamSettings()
|
s, err := a.GetUltrabeamSettings()
|
||||||
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
|
if err != nil || !s.Enabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.ultrabeam = ultrabeam.New(s.Host, s.Port)
|
c := newMotorClient(s)
|
||||||
_ = a.ultrabeam.Start()
|
if c == nil {
|
||||||
|
return // not configured (missing host/COM)
|
||||||
|
}
|
||||||
|
a.motorAnt = c
|
||||||
|
_ = a.motorAnt.Start()
|
||||||
if s.Follow {
|
if s.Follow {
|
||||||
stop := make(chan struct{})
|
stop := make(chan struct{})
|
||||||
a.ubFollowStop = stop
|
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
|
// 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
|
// elements track the band without the motors chasing every small QSY. Runs
|
||||||
// until stop is closed (a settings change or shutdown).
|
// 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 {
|
if stepKHz <= 0 {
|
||||||
stepKHz = 50
|
stepKHz = 50
|
||||||
}
|
}
|
||||||
@@ -10468,8 +10732,8 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan
|
|||||||
if !rs.Connected || rs.FreqHz <= 0 {
|
if !rs.Connected || rs.FreqHz <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
st, err := c.GetStatus()
|
st := c.Status()
|
||||||
if err != nil || st == nil || !st.Connected {
|
if !st.Connected {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rigKHz := int(rs.FreqHz / 1000)
|
rigKHz := int(rs.FreqHz / 1000)
|
||||||
@@ -10529,74 +10793,81 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
|
|||||||
out := UltrabeamStatusInfo{}
|
out := UltrabeamStatusInfo{}
|
||||||
s, _ := a.GetUltrabeamSettings()
|
s, _ := a.GetUltrabeamSettings()
|
||||||
out.Enabled = s.Enabled
|
out.Enabled = s.Enabled
|
||||||
if a.ultrabeam == nil {
|
if a.motorAnt == nil {
|
||||||
return out
|
|
||||||
}
|
|
||||||
st, err := a.ultrabeam.GetStatus()
|
|
||||||
if err != nil || st == nil {
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
st := a.motorAnt.Status()
|
||||||
out.Connected = st.Connected
|
out.Connected = st.Connected
|
||||||
out.Direction = st.Direction
|
out.Direction = st.Direction
|
||||||
out.Frequency = st.Frequency
|
out.Frequency = st.Frequency
|
||||||
out.Band = st.Band
|
out.Band = st.Band
|
||||||
out.Moving = st.MotorsMoving != 0
|
out.Moving = st.Moving
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetUltrabeamDirection switches the antenna pattern: 0=normal, 1=180°,
|
// SetUltrabeamDirection switches the antenna pattern: 0=normal, 1=180°,
|
||||||
// 2=bidirectional (re-issues the current frequency with the new direction).
|
// 2=bidirectional (re-issues the current frequency with the new direction).
|
||||||
func (a *App) SetUltrabeamDirection(direction int) error {
|
func (a *App) SetUltrabeamDirection(direction int) error {
|
||||||
if a.ultrabeam == nil {
|
if a.motorAnt == nil {
|
||||||
return fmt.Errorf("Ultrabeam not connected — enable it in Settings → Antenna")
|
return fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
|
||||||
}
|
}
|
||||||
if direction < 0 || direction > 2 {
|
if direction < 0 || direction > 2 {
|
||||||
return fmt.Errorf("invalid direction %d", direction)
|
return fmt.Errorf("invalid direction %d", direction)
|
||||||
}
|
}
|
||||||
// The device has no standalone direction command: it re-issues the current
|
// Neither controller has a standalone direction command: both re-issue the
|
||||||
// frequency with the new direction byte. If the antenna hasn't reported a
|
// current frequency with the new direction byte. If the antenna hasn't reported
|
||||||
// frequency yet (just connected / remote link still settling), fall back to
|
// a frequency yet (just connected / link settling), fall back to the rig's CAT
|
||||||
// the rig's current CAT frequency so the control still works.
|
// frequency so the control still works.
|
||||||
st, _ := a.ultrabeam.GetStatus()
|
st := a.motorAnt.Status()
|
||||||
if (st == nil || st.Frequency <= 0) && a.cat != nil {
|
if st.Frequency <= 0 && a.cat != nil {
|
||||||
if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 {
|
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).
|
// UltrabeamRetract retracts all elements (storage / safe position).
|
||||||
func (a *App) UltrabeamRetract() error {
|
func (a *App) UltrabeamRetract() error {
|
||||||
if a.ultrabeam == nil {
|
if a.motorAnt == nil {
|
||||||
return fmt.Errorf("Ultrabeam not connected")
|
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
|
// TestUltrabeam opens a one-shot connection and reads one status frame to verify
|
||||||
// verify host/port without disturbing the running poller.
|
// the transport (TCP host/port or serial COM) without disturbing the running poller.
|
||||||
func (a *App) TestUltrabeam(s UltrabeamSettings) error {
|
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")
|
return fmt.Errorf("host required")
|
||||||
}
|
}
|
||||||
if s.Port <= 0 || s.Port > 65535 {
|
if s.Port <= 0 || s.Port > 65535 {
|
||||||
s.Port = 23
|
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 {
|
if err := c.Start(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer c.Stop()
|
defer c.Stop()
|
||||||
// The poller connects + reads status on its 2s tick; give it a couple of
|
// The poller connects + reads status on its tick; give it a few cycles to come
|
||||||
// cycles to come up, then check we got a live status frame.
|
// up, then check we got a live status frame.
|
||||||
deadline := time.Now().Add(6 * time.Second)
|
deadline := time.Now().Add(8 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
if st, err := c.GetStatus(); err == nil && st != nil && st.Connected {
|
if st := c.Status(); st.Connected {
|
||||||
return nil
|
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)
|
return fmt.Errorf("no response from %s:%d", s.Host, s.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
{ubStatus.enabled && (
|
||||||
<div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
|
<div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
|
||||||
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam pattern') : 'Ultrabeam: connecting…'}>
|
title={ubStatus.connected ? (ubStatus.moving ? 'Antenna: moving…' : 'Antenna pattern') : 'Antenna: connecting…'}>
|
||||||
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
|
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
|
||||||
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
|
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'PstRotator',
|
rotator: 'PstRotator',
|
||||||
winkeyer: 'CW Keyer',
|
winkeyer: 'CW Keyer',
|
||||||
antenna: 'UltraBeam',
|
antenna: 'Ultrabeam / Steppir',
|
||||||
antgenius: 'Antenna Genius',
|
antgenius: 'Antenna Genius',
|
||||||
pgxl: 'Power Genius',
|
pgxl: 'Power Genius',
|
||||||
flex: 'FlexRadio',
|
flex: 'FlexRadio',
|
||||||
@@ -826,9 +826,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
// Ultrabeam antenna (TCP) settings.
|
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
||||||
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: number; follow: boolean; step_khz: number }>({
|
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, host: '', port: 23, follow: false, step_khz: 50,
|
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50,
|
||||||
});
|
});
|
||||||
const [ubTesting, setUbTesting] = useState(false);
|
const [ubTesting, setUbTesting] = useState(false);
|
||||||
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
@@ -2267,16 +2267,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UltrabeamPanel() {
|
function UltrabeamPanel() {
|
||||||
|
const isSteppir = ultrabeam.type === 'steppir';
|
||||||
|
const isSerial = isSteppir && ultrabeam.transport === 'serial';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={t('hw.ultrabeam')}
|
title={t('hw.motorAntenna')}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
|
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
|
||||||
Enable Ultrabeam control
|
{t('hw.motorEnable')}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('hw.motorType')}</Label>
|
||||||
|
{/* Ultrabeam is TCP only; picking it forces the transport back to TCP
|
||||||
|
so the serial fields never apply to it. */}
|
||||||
|
<Select value={ultrabeam.type ?? 'ultrabeam'}
|
||||||
|
onValueChange={(v) => setUltrabeam((s) => ({ ...s, type: v, transport: v === 'ultrabeam' ? 'tcp' : s.transport }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ultrabeam">Ultrabeam</SelectItem>
|
||||||
|
<SelectItem value="steppir">SteppIR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{isSteppir && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('hw.motorTransport')}</Label>
|
||||||
|
<Select value={ultrabeam.transport ?? 'tcp'}
|
||||||
|
onValueChange={(v) => setUltrabeam((s) => ({ ...s, transport: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tcp">{t('hw.motorTcp')}</SelectItem>
|
||||||
|
<SelectItem value="serial">{t('hw.motorSerial')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isSerial ? (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>{t('hw.motorCom')}</Label>
|
||||||
|
<Input
|
||||||
|
value={ultrabeam.com ?? ''}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, com: e.target.value }))}
|
||||||
|
placeholder="COM3"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('hw.motorBaud')}</Label>
|
||||||
|
<Select value={String(ultrabeam.baud || 9600)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, baud: parseInt(v, 10) || 9600 }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[1200, 4800, 9600, 19200].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Host / IP</Label>
|
<Label>Host / IP</Label>
|
||||||
@@ -2297,6 +2349,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{isSteppir && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('hw.steppirHint')}</p>
|
||||||
|
)}
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
|
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
|
||||||
@@ -2318,7 +2374,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || !ultrabeam.host.trim()}>
|
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || (isSerial ? !ultrabeam.com.trim() : !ultrabeam.host.trim())}>
|
||||||
{ubTesting ? t('hw.connecting') : t('hw.testConn')}
|
{ubTesting ? t('hw.connecting') : t('hw.testConn')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const en: Dict = {
|
|||||||
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
|
'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.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.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
|
// General panel
|
||||||
'gen.hint': 'App behaviour (saved instantly).',
|
'gen.hint': 'App behaviour (saved instantly).',
|
||||||
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
|
'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.',
|
'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.",
|
'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).',
|
'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 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.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',
|
'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.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.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.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.hint': 'Comportement de l\'application (enregistré immédiatement).',
|
||||||
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
||||||
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
'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.",
|
'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.",
|
'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).",
|
'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.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.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.",
|
'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.",
|
||||||
|
|||||||
@@ -2465,8 +2465,12 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
export class UltrabeamSettings {
|
export class UltrabeamSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
type: string;
|
||||||
|
transport: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
com: string;
|
||||||
|
baud: number;
|
||||||
follow: boolean;
|
follow: boolean;
|
||||||
step_khz: number;
|
step_khz: number;
|
||||||
|
|
||||||
@@ -2477,8 +2481,12 @@ export namespace main {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.transport = source["transport"];
|
||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
|
this.com = source["com"];
|
||||||
|
this.baud = source["baud"];
|
||||||
this.follow = source["follow"];
|
this.follow = source["follow"];
|
||||||
this.step_khz = source["step_khz"];
|
this.step_khz = source["step_khz"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" <freq> 00 <dir> <cmd> 00 0x0D (11 bytes)
|
||||||
|
// <freq> = int32 big-endian of (Hz / 10)
|
||||||
|
// <dir> = 0x00 normal · 0x40 180° · 0x80 bidirectional · 0x20 3/4-wave
|
||||||
|
// <cmd> = '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" <freq be32 of Hz/10> 00 <dir> <cmd> 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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user