From 829c236d6c289eb2042b32f66c5256a6c02e3eb9 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 16 Jul 2026 22:01:07 +0200 Subject: [PATCH] feat: New Station Control, allow to control Webswitch 1216H or KMTronic --- app.go | 243 +++++++++++-- frontend/src/App.tsx | 33 ++ frontend/src/components/SettingsModal.tsx | 68 +++- .../src/components/StationControlPanel.tsx | 329 ++++++++++++++++++ frontend/src/lib/i18n.tsx | 12 +- frontend/wailsjs/go/main/App.d.ts | 8 + frontend/wailsjs/go/main/App.js | 16 + frontend/wailsjs/go/models.ts | 85 +++++ internal/cat/flex.go | 9 + internal/relaydev/relaydev.go | 168 +++++++++ internal/relaydev/relaydev_test.go | 101 ++++++ internal/rotgenius/rotgenius.go | 193 ++++++++++ internal/rotgenius/rotgenius_test.go | 127 +++++++ 13 files changed, 1344 insertions(+), 48 deletions(-) create mode 100644 frontend/src/components/StationControlPanel.tsx create mode 100644 internal/relaydev/relaydev.go create mode 100644 internal/relaydev/relaydev_test.go create mode 100644 internal/rotgenius/rotgenius.go create mode 100644 internal/rotgenius/rotgenius_test.go diff --git a/app.go b/app.go index 8c6a0d6..57f9319 100644 --- a/app.go +++ b/app.go @@ -49,6 +49,8 @@ import ( "hamlog/internal/qslcard" "hamlog/internal/qso" "hamlog/internal/rotator/pst" + "hamlog/internal/relaydev" + "hamlog/internal/rotgenius" "hamlog/internal/settings" "hamlog/internal/solar" "hamlog/internal/steppir" @@ -154,6 +156,8 @@ const ( keyRotatorHost = "rotator.host" keyRotatorPort = "rotator.port" keyRotatorHasElevation = "rotator.has_elevation" + keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) + keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2 // Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the // "ultrabeam." prefix for backward compatibility with configs saved before the @@ -169,6 +173,7 @@ const ( keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0) keyMotorBaud = "ultrabeam.baud" // serial baud (SteppIR default 9600) keyMotorTXInhibit = "ultrabeam.tx_inhibit" // "1" → block Flex TX while the antenna is moving + keyStationDevices = "station.devices" // JSON list of relay boards for the Station Control tab // Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP // port is fixed at 9007, so only the IP is configurable. @@ -10393,30 +10398,40 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error // RotatorSettings is the JSON shape for the Hardware → Rotator panel. type RotatorSettings struct { Enabled bool `json:"enabled"` + Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) Host string `json:"host"` // default 127.0.0.1 - Port int `json:"port"` // default 12000 - HasElevation bool `json:"has_elevation"` // include EL in GoTo packets + Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius) + HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator) + RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2 } // GetRotatorSettings returns the persisted rotator config with defaults. func (a *App) GetRotatorSettings() (RotatorSettings, error) { - out := RotatorSettings{Host: "127.0.0.1", Port: 12000} + out := RotatorSettings{Type: "pst", Host: "127.0.0.1", Port: 12000, RotatorNum: 1} if a.settings == nil { return out, fmt.Errorf("db not initialized") } m, err := a.settings.GetMany(a.ctx, - keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation) + keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum) if err != nil { return out, err } out.Enabled = m[keyRotatorEnabled] == "1" + if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" { + out.Type = t + } if h := m[keyRotatorHost]; h != "" { out.Host = h } if p, _ := strconv.Atoi(m[keyRotatorPort]); p > 0 && p <= 65535 { out.Port = p + } else if out.Type == "rotgenius" { + out.Port = 9006 // native default when no port stored yet } out.HasElevation = m[keyRotatorHasElevation] == "1" + if rn, _ := strconv.Atoi(m[keyRotatorNum]); rn == 2 { + out.RotatorNum = 2 + } return out, nil } @@ -10426,17 +10441,25 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error { if a.settings == nil { return fmt.Errorf("db not initialized") } + if s.Type != "rotgenius" { + s.Type = "pst" + } if s.Host == "" { s.Host = "127.0.0.1" } if s.Port <= 0 || s.Port > 65535 { - s.Port = 12000 + s.Port = map[string]int{"rotgenius": 9006, "pst": 12000}[s.Type] + } + if s.RotatorNum != 2 { + s.RotatorNum = 1 } for k, v := range map[string]string{ keyRotatorEnabled: boolStr(s.Enabled), + keyRotatorType: s.Type, keyRotatorHost: s.Host, keyRotatorPort: strconv.Itoa(s.Port), keyRotatorHasElevation: boolStr(s.HasElevation), + keyRotatorNum: strconv.Itoa(s.RotatorNum), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err @@ -10445,18 +10468,6 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error { return nil } -// rotatorClient returns a fresh PST UDP client built from current settings, -// or an error if the rotator is disabled / misconfigured. -func (a *App) rotatorClient() (*pst.Client, RotatorSettings, error) { - s, err := a.GetRotatorSettings() - if err != nil { - return nil, s, err - } - if !s.Enabled { - return nil, s, fmt.Errorf("rotator disabled in settings") - } - return pst.New(s.Host, s.Port), s, nil -} // RotatorHeading is the live antenna heading for the status bar. type RotatorHeading struct { @@ -10473,6 +10484,16 @@ func (a *App) GetRotatorHeading() RotatorHeading { if err != nil || !s.Enabled { return RotatorHeading{Enabled: false} } + if s.Type == "rotgenius" { + st, raw, herr := rotgenius.New(s.Host, s.Port).Heading(s.RotatorNum) + if herr != nil { + return RotatorHeading{Enabled: true, OK: false, Raw: herr.Error()} + } + if !st.Connected { + return RotatorHeading{Enabled: true, OK: false, Raw: "sensor not connected (999)"} + } + return RotatorHeading{Enabled: true, OK: true, Azimuth: st.Azimuth, Raw: raw} + } az, raw, herr := pst.New(s.Host, s.Port).Heading() if herr != nil { return RotatorHeading{Enabled: true, OK: false, Raw: raw} @@ -10483,30 +10504,49 @@ func (a *App) GetRotatorHeading() RotatorHeading { // RotatorGoTo points the antenna at the given azimuth (and optional // elevation if the rotator is configured for it). func (a *App) RotatorGoTo(az int, el int) error { - c, s, err := a.rotatorClient() + s, err := a.GetRotatorSettings() if err != nil { return err } - return c.GoTo(az, s.HasElevation, el) + if !s.Enabled { + return fmt.Errorf("rotator disabled in settings") + } + if s.Type == "rotgenius" { + return rotgenius.New(s.Host, s.Port).GoTo(s.RotatorNum, az) + } + return pst.New(s.Host, s.Port).GoTo(az, s.HasElevation, el) } -// RotatorStop interrupts any in-progress rotation. +// RotatorStop interrupts any in-progress rotation, on either backend. func (a *App) RotatorStop() error { - c, _, err := a.rotatorClient() + s, err := a.GetRotatorSettings() if err != nil { return err } - return c.Stop() + if !s.Enabled { + return fmt.Errorf("rotator disabled in settings") + } + if s.Type == "rotgenius" { + return rotgenius.New(s.Host, s.Port).Stop() + } + return pst.New(s.Host, s.Port).Stop() } // RotatorPark moves the antenna to its parked position (configured in -// PstRotator itself). +// PstRotator itself). Park is a PstRotator concept — the 4O3A native protocol +// has no park command. func (a *App) RotatorPark() error { - c, _, err := a.rotatorClient() + s, err := a.GetRotatorSettings() if err != nil { return err } - return c.Park() + if !s.Enabled { + return fmt.Errorf("rotator disabled in settings") + } + if s.Type == "rotgenius" { + return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius") + } + return pst.New(s.Host, s.Port).Park() } // TestRotator sends a no-op GoTo to the rotator's current heading to @@ -10517,6 +10557,19 @@ func (a *App) TestRotator(s RotatorSettings) error { if s.Host == "" { s.Host = "127.0.0.1" } + if s.Type == "rotgenius" { + if s.Port <= 0 || s.Port > 65535 { + s.Port = 9006 + } + rn := s.RotatorNum + if rn != 2 { + rn = 1 + } + // Match the button ("Test — point to 0°"): actually command the move, like + // the PstRotator path does. GoTo confirms the link AND the accept ('K') + // reply, so a rejected command surfaces as an error instead of a false OK. + return rotgenius.New(s.Host, s.Port).GoTo(rn, 0) + } if s.Port <= 0 || s.Port > 65535 { s.Port = 12000 } @@ -10530,6 +10583,146 @@ func boolStr(b bool) string { return "0" } +// ── Station Control (relay boards) ───────────────────────────────────── + +// StationDevice is one configured relay board for the Station Control tab. +type StationDevice struct { + ID string `json:"id"` + Type string `json:"type"` // "webswitch" | "kmtronic" + Name string `json:"name"` + Host string `json:"host"` + User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none) + Pass string `json:"pass,omitempty"` + Labels []string `json:"labels"` // per-relay label (index 0 = relay 1) +} + +// relayCountFor returns a device type's fixed relay count. +func relayCountFor(typ string) int { + if typ == "kmtronic" { + return 8 + } + return 5 // webswitch 1216H +} + +// deviceDriver builds the wire driver for a configured device. +func deviceDriver(d StationDevice) relaydev.Device { + if d.Type == "kmtronic" { + return relaydev.NewKMTronic(d.Host, d.User, d.Pass) + } + return relaydev.NewWebswitch(d.Host) +} + +// GetStationDevices returns the configured relay boards (without live state). +func (a *App) GetStationDevices() []StationDevice { + out := []StationDevice{} + if a.settings == nil { + return out + } + s, _ := a.settings.GetGlobal(a.ctx, keyStationDevices) + if strings.TrimSpace(s) == "" { + return out + } + if err := json.Unmarshal([]byte(s), &out); err != nil || out == nil { + return []StationDevice{} + } + return out +} + +// SaveStationDevices persists the relay-board list. IDs and label lengths are +// normalized so the UI can rely on them. +func (a *App) SaveStationDevices(devs []StationDevice) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + for i := range devs { + if devs[i].Type != "kmtronic" { + devs[i].Type = "webswitch" + } + if strings.TrimSpace(devs[i].ID) == "" { + devs[i].ID = fmt.Sprintf("dev%d-%d", i, time.Now().UnixNano()) + } + n := relayCountFor(devs[i].Type) + for len(devs[i].Labels) < n { + devs[i].Labels = append(devs[i].Labels, "") + } + devs[i].Labels = devs[i].Labels[:n] + } + b, err := json.Marshal(devs) + if err != nil { + return err + } + return a.settings.SetGlobal(a.ctx, keyStationDevices, string(b)) +} + +// StationRelay is one relay's live state for the UI. +type StationRelay struct { + Number int `json:"number"` // 1-based + Label string `json:"label"` + On bool `json:"on"` +} + +// StationDeviceStatus is a device plus its live relay states. +type StationDeviceStatus struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Connected bool `json:"connected"` + Error string `json:"error,omitempty"` + Relays []StationRelay `json:"relays"` +} + +// GetStationStatus polls every configured board for its live relay states. +// Boards are polled concurrently so one slow/offline device doesn't stall the rest. +func (a *App) GetStationStatus() []StationDeviceStatus { + devs := a.GetStationDevices() + out := make([]StationDeviceStatus, len(devs)) + var wg sync.WaitGroup + for i := range devs { + wg.Add(1) + go func(i int) { + defer wg.Done() + d := devs[i] + n := relayCountFor(d.Type) + ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type} + ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second) + defer cancel() + states, err := deviceDriver(d).Status(ctx) + if err != nil { + ds.Error = err.Error() + } else { + ds.Connected = true + } + ds.Relays = make([]StationRelay, n) + for r := 0; r < n; r++ { + label := "" + if r < len(d.Labels) { + label = d.Labels[r] + } + on := false + if r < len(states) { + on = states[r] + } + ds.Relays[r] = StationRelay{Number: r + 1, Label: label, On: on} + } + out[i] = ds + }(i) + } + wg.Wait() + return out +} + +// StationSetRelay switches one relay on a configured board. +func (a *App) StationSetRelay(id string, relay int, on bool) error { + for _, d := range a.GetStationDevices() { + if d.ID == id { + ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second) + defer cancel() + return deviceDriver(d).Set(ctx, relay, on) + } + } + return fmt.Errorf("station device %q not found", id) +} + // ── Motorized antenna (Ultrabeam / SteppIR) ──────────────────────────── // motorAntenna is the shared control surface of a motorized antenna. The diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cb26d3d..3bfcde0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -68,6 +68,7 @@ import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel'; import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder'; import { AwardsPanel } from '@/components/AwardsPanel'; import { StatsPanel } from '@/components/StatsPanel'; +import { StationControlPanel } from '@/components/StationControlPanel'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ClusterGrid } from '@/components/ClusterGrid'; @@ -632,6 +633,11 @@ export default function App() { setStatsTabOpen(false); setActiveTab((t) => (t === 'stats' ? 'recent' : t)); } + const [stationTabOpen, setStationTabOpen] = useState(false); + function closeStationTab() { + setStationTabOpen(false); + setActiveTab((t) => (t === 'station' ? 'recent' : t)); + } // Recent QSOs row cap, persisted. With AG Grid's virtual scroller // huge logs render OK once loaded, but a 25k+ logbook still takes a // couple of seconds to round-trip from SQLite at launch. Defaulting @@ -2532,6 +2538,7 @@ export default function App() { { name: 'tools', label: t('menu.tools'), items: [ { type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' }, { type: 'item', label: t('stats.tab'), action: 'tools.stats' }, + { type: 'item', label: t('station.title'), action: 'tools.station' }, { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' }, { type: 'separator' }, { type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' }, @@ -2568,6 +2575,7 @@ export default function App() { case 'edit.prefs': setShowSettings(true); break; case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break; + case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break; case 'tools.qsldesigner': setQslDesignerOpen(true); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.dvk': setDvkEnabled((v) => !v); break; @@ -4192,6 +4200,21 @@ export default function App() { )} + {stationTabOpen && ( + + {t('station.title')} + { e.stopPropagation(); }} + onClick={(e) => { e.stopPropagation(); closeStationTab(); }} + > + + + + )} {qslTabOpen && ( QSL Manager @@ -4532,6 +4555,16 @@ export default function App() { )} + {stationTabOpen && ( + + + + )} + {contestTabEnabled && ( diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index aa47cd0..2e82c39 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -259,7 +259,7 @@ const SECTION_LABELS: Partial> = { udp: 'UDP integrations', awards: 'Awards', cat: 'CAT interface', - rotator: 'PstRotator', + rotator: 'Rotator', winkeyer: 'CW Keyer', antenna: 'Ultrabeam / Steppir', antgenius: 'Antenna Genius', @@ -821,8 +821,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan digital_default: 'FT8', }); const [rotator, setRotator] = useState({ - enabled: false, host: '127.0.0.1', port: 12000, has_elevation: false, - }); + enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1, + } as any); const [rotatorTesting, setRotatorTesting] = useState(false); const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null); @@ -2245,7 +2245,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan setRotatorTest(null); try { await TestRotator(rotator as any); - setRotatorTest({ ok: true, msg: t('cat.rotatorOk') }); + setRotatorTest({ ok: true, msg: (rotator as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') }); } catch (e: any) { setRotatorTest({ ok: false, msg: String(e?.message ?? e) }); } finally { @@ -2474,41 +2474,73 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan } function RotatorPanel() { + const isRG = (rotator as any).type === 'rotgenius'; return ( <>
+
+
+ + {/* Switching to Rotator Genius moves the default port to its native + 9006; back to PstRotator restores 12000. */} + +
+ {isRG && ( +
+ + +
+ )} +
- + setRotator((s) => ({ ...s, host: e.target.value }))} - placeholder="127.0.0.1" + placeholder={isRG ? '192.168.1.60' : '127.0.0.1'} className="font-mono" />
- + setRotator((s) => ({ ...s, port: parseInt(e.target.value) || 12000 }))} + onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : 12000) }))} className="font-mono" />
- + {!isRG && ( + + )} + {isRG &&

{t('rot.rgHint')}

}
- + {!isRG && ( + + )}
{rotatorTest && (
= { webswitch: 5, kmtronic: 8 }; +const TYPE_LABEL: Record = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' }; + +function blankDevice(): Device { + return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') }; +} + +type Heading = { enabled: boolean; ok: boolean; azimuth: number }; + +// RotatorWidget shows the configured rotator (Settings → Rotator) right in the +// Station Control tab: a live compass you can click to turn, a heading readout, a +// GoTo box, quick N/E/S/W presets, and Stop. Heading is polled by the panel and +// passed in (so the panel can also order this widget); it drives the shared +// rotator backend. +function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: RotatorProps & { + hd: Heading; refetch: () => void; t: (k: string, v?: any) => string; +}) { + const [goto, setGoto] = useState(''); + const [err, setErr] = useState(''); + + const turn = (az: number) => { + const a = ((Math.round(az) % 360) + 360) % 360; + RotatorGoTo(a, -1).then(refetch).catch((e) => setErr(String(e?.message ?? e))); + }; + const poll = refetch; + + const presets: [string, number][] = [['N', 0], ['E', 90], ['S', 180], ['W', 270]]; + return ( +
+
+ +
{t('station.rotator')}
+ +
+
+ turn(az)} + /> +
+
+ {hd.ok ? `${hd.azimuth}°` : '—'} +
+
+ {presets.map(([lbl, az]) => ( + + ))} +
+
+ setGoto(e.target.value.replace(/[^0-9]/g, ''))} + onKeyDown={(e) => { if (e.key === 'Enter' && goto) turn(parseInt(goto, 10)); }} + placeholder="0–359" className="h-8 flex-1 min-w-0 font-mono text-sm" /> + +
+ + {err &&
{err}
} +
+
+
+ ); +} + +export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) { + const { t } = useI18n(); + const [devices, setDevices] = useState([]); + const [status, setStatus] = useState>({}); + const [editing, setEditing] = useState(null); // device being added/edited + const [busy, setBusy] = useState>({}); // per-relay in-flight + const [rot, setRot] = useState({ enabled: false, ok: false, azimuth: 0 }); + // Widget order (rotator + device ids), drag-and-drop reorderable, persisted. + const [order, setOrder] = useState(() => { + try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } + }); + const dragId = useRef(null); + + const loadDevices = useCallback(async () => { + try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ } + }, []); + + const poll = useCallback(async () => { + try { + const s = ((await GetStationStatus()) ?? []) as DevStatus[]; + setStatus(Object.fromEntries(s.map((d) => [d.id, d]))); + } catch { /* ignore transient */ } + }, []); + const pollRot = useCallback(async () => { + try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ } + }, []); + + useEffect(() => { loadDevices(); }, [loadDevices]); + useEffect(() => { + poll(); pollRot(); + const id = window.setInterval(() => { poll(); pollRot(); }, 3000); + return () => window.clearInterval(id); + }, [poll, pollRot, devices.length]); + + const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); }; + // Reorder so `dragged` lands just before `target`. + const onDrop = (targetId: string) => { + const src = dragId.current; dragId.current = null; + if (!src || src === targetId) return; + const ids = widgetIds.filter((id) => id !== src); + const at = ids.indexOf(targetId); + ids.splice(at < 0 ? ids.length : at, 0, src); + persistOrder(ids); + }; + + const persist = async (next: Device[]) => { + setDevices(next); + try { await SaveStationDevices(next as any); } catch { /* surfaced by status */ } + await loadDevices(); + poll(); + }; + + const toggle = async (dev: Device, relay: number, on: boolean) => { + const key = `${dev.id}:${relay}`; + setBusy((b) => ({ ...b, [key]: true })); + // Optimistic flip so the switch feels instant; the poll reconciles. + setStatus((st) => { + const d = st[dev.id]; if (!d) return st; + return { ...st, [dev.id]: { ...d, relays: d.relays.map((r) => (r.number === relay ? { ...r, on } : r)) } }; + }); + try { await StationSetRelay(dev.id, relay, on); } catch { /* poll will correct */ } + await poll(); + setBusy((b) => ({ ...b, [key]: false })); + }; + + const saveEdit = async () => { + if (!editing) return; + const d = { ...editing, name: editing.name.trim() || TYPE_LABEL[editing.type], host: editing.host.trim() }; + const exists = devices.some((x) => x.id && x.id === d.id); + await persist(exists ? devices.map((x) => (x.id === d.id ? d : x)) : [...devices, d]); + setEditing(null); + }; + + const removeDevice = async (id: string) => { await persist(devices.filter((x) => x.id !== id)); }; + + // Build the widget list (rotator first by default, then devices), then order it + // by the saved drag order — unknown ids fall to the end in their natural order. + const deviceCard = (dev: Device) => { + const st = status[dev.id]; + const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false })); + return ( +
+
+ +
+
{dev.name || TYPE_LABEL[dev.type]}
+
{TYPE_LABEL[dev.type]} · {dev.host}
+
+ + + +
+
+ {relays.map((r) => { + const key = `${dev.id}:${r.number}`; + const label = r.label || `${t('station.relay')} ${r.number}`; + return ( + + ); + })} +
+
+ ); + }; + + const widgets: { id: string; node: React.ReactNode }[] = []; + if (rot.enabled) { + widgets.push({ id: 'rotator', node: }); + } + for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) }); + + const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; }; + const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i)); + const widgetIds = ordered.map((w) => w.id); + + const noDevices = devices.length === 0 && !rot.enabled; + + return ( +
+
+

{t('station.title')}

+ +
+ + {noDevices && !editing && ( +
+ {t('station.empty')} +
+ )} + +
+ {ordered.map((w) => ( +
{ dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }} + onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }} + onDrop={(e) => { e.preventDefault(); onDrop(w.id); }} + className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}> + {w.node} +
+ ))} +
+ + {!noDevices && ( +

{t('station.dragHint')}

+ )} + + {editing && ( + setEditing(null)} t={t} /> + )} +
+ ); +} + +function DeviceEditor({ device, onChange, onSave, onCancel, t }: { + device: Device; onChange: (d: Device) => void; onSave: () => void; onCancel: () => void; t: (k: string, v?: any) => string; +}) { + const ref = useRef(null); + useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []); + const setType = (type: string) => { + const n = RELAY_COUNT[type] ?? 5; + const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? ''); + onChange({ ...device, type, labels }); + }; + const isKM = device.type === 'kmtronic'; + return ( +
+
{device.id ? t('station.editDevice') : t('station.addDevice')}
+
+
+ + +
+
+ + onChange({ ...device, name: e.target.value })} /> +
+
+
+
+ + onChange({ ...device, host: e.target.value })} /> +
+ {isKM && ( + <> +
+ + onChange({ ...device, user: e.target.value })} /> +
+
+ + onChange({ ...device, pass: e.target.value })} /> +
+ + )} +
+
+ +
+ {device.labels.map((lab, i) => ( + { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} /> + ))} +
+
+
+ + +
+
+ ); +} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index c430f1c..98d9c94 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -89,8 +89,8 @@ const en: Dict = { 'sec.confirmations': 'Confirmations', 'sec.external': 'External services', 'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup', '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.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder.', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', + 'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer', '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).', @@ -163,7 +163,7 @@ const en: Dict = { 'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.', 'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).", '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.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', '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.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', '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 @@ -353,8 +353,8 @@ const fr: Dict = { 'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes', 'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif", '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.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner les widgets.', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', + 'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW', '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', @@ -418,7 +418,7 @@ const fr: Dict = { 'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.", 'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).", '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.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', '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.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", '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)', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 005c542..194b432 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -386,8 +386,12 @@ export function GetSolarData():Promise; export function GetStartupStatus():Promise; +export function GetStationDevices():Promise>; + export function GetStationSettings():Promise; +export function GetStationStatus():Promise>; + export function GetTelemetryEnabled():Promise; export function GetUIPref(arg1:string):Promise; @@ -718,6 +722,8 @@ export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise; export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise; +export function SaveStationDevices(arg1:Array):Promise; + export function SaveStationSettings(arg1:main.StationSettings):Promise; export function SaveUDPIntegration(arg1:udp.Config):Promise; @@ -766,6 +772,8 @@ export function SetUltrabeamDirection(arg1:number):Promise; export function StartCWDecoder():Promise; +export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise; + export function StopCWDecoder():Promise; export function SwitchCATRig(arg1:number):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 1cf24e2..f77b836 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -730,10 +730,18 @@ export function GetStartupStatus() { return window['go']['main']['App']['GetStartupStatus'](); } +export function GetStationDevices() { + return window['go']['main']['App']['GetStationDevices'](); +} + export function GetStationSettings() { return window['go']['main']['App']['GetStationSettings'](); } +export function GetStationStatus() { + return window['go']['main']['App']['GetStationStatus'](); +} + export function GetTelemetryEnabled() { return window['go']['main']['App']['GetTelemetryEnabled'](); } @@ -1394,6 +1402,10 @@ export function SaveRotatorSettings(arg1) { return window['go']['main']['App']['SaveRotatorSettings'](arg1); } +export function SaveStationDevices(arg1) { + return window['go']['main']['App']['SaveStationDevices'](arg1); +} + export function SaveStationSettings(arg1) { return window['go']['main']['App']['SaveStationSettings'](arg1); } @@ -1490,6 +1502,10 @@ export function StartCWDecoder() { return window['go']['main']['App']['StartCWDecoder'](); } +export function StationSetRelay(arg1, arg2, arg3) { + return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3); +} + export function StopCWDecoder() { return window['go']['main']['App']['StopCWDecoder'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 975d22a..85dc46e 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2333,9 +2333,11 @@ export namespace main { } export class RotatorSettings { enabled: boolean; + type: string; host: string; port: number; has_elevation: boolean; + rotator_num: number; static createFrom(source: any = {}) { return new RotatorSettings(source); @@ -2344,9 +2346,11 @@ export namespace main { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.enabled = source["enabled"]; + this.type = source["type"]; this.host = source["host"]; this.port = source["port"]; this.has_elevation = source["has_elevation"]; + this.rotator_num = source["rotator_num"]; } } export class SecretStatus { @@ -2419,6 +2423,86 @@ export namespace main { this.db_path = source["db_path"]; } } + export class StationDevice { + id: string; + type: string; + name: string; + host: string; + user?: string; + pass?: string; + labels: string[]; + + static createFrom(source: any = {}) { + return new StationDevice(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.type = source["type"]; + this.name = source["name"]; + this.host = source["host"]; + this.user = source["user"]; + this.pass = source["pass"]; + this.labels = source["labels"]; + } + } + export class StationRelay { + number: number; + label: string; + on: boolean; + + static createFrom(source: any = {}) { + return new StationRelay(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.number = source["number"]; + this.label = source["label"]; + this.on = source["on"]; + } + } + export class StationDeviceStatus { + id: string; + name: string; + type: string; + connected: boolean; + error?: string; + relays: StationRelay[]; + + static createFrom(source: any = {}) { + return new StationDeviceStatus(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.type = source["type"]; + this.connected = source["connected"]; + this.error = source["error"]; + this.relays = this.convertValues(source["relays"], StationRelay); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class StationInfoComputed { country: string; dxcc: number; @@ -2441,6 +2525,7 @@ export namespace main { this.lon = source["lon"]; } } + export class StationSettings { callsign: string; operator: string; diff --git a/internal/cat/flex.go b/internal/cat/flex.go index 707b0de..470ec73 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -1713,7 +1713,16 @@ func (f *Flex) SetTune(on bool) error { // the operator from keying while a motorized antenna's elements are moving — // SmartSDR refuses to transmit while inhibit is set, so it holds even against a // footswitch or an external keyer, which a software PTT block could not. +// +// It also labels the interlock reason "OpsLog" so SmartSDR shows WHY TX is +// blocked. The reason is best-effort (a separate interlock object); the inhibit +// is what actually blocks TX, so a rig that ignores the reason still stays safe. func (f *Flex) SetTXInhibit(on bool) error { + if on { + f.send("interlock set reason=OpsLog") + } else { + f.send("interlock set reason=") + } return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on }) } diff --git a/internal/relaydev/relaydev.go b/internal/relaydev/relaydev.go new file mode 100644 index 0000000..5d8fd92 --- /dev/null +++ b/internal/relaydev/relaydev.go @@ -0,0 +1,168 @@ +// Package relaydev drives network relay boards used for station control (power +// sequencing, switching accessories). Two devices are supported, both over HTTP: +// +// - WebSwitch 1216H — 5 relays. Control: GET /relaycontrol/{on|off}/{n}; +// status: GET /relaystate/get2/1$2$3$4$5$ → lines "n,state". +// (Protocol taken from the operator's own working ShackMaster driver.) +// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss} +// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with +// .. (relay0 is reserved). Optional HTTP basic auth. +// +// A Device presents the same surface to the app regardless of wire protocol. +package relaydev + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// Device is one relay board. +type Device interface { + Count() int // number of user-controllable relays + Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1) + Set(ctx context.Context, relay int, on bool) error // relay is 1-based +} + +func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} } + +// get issues a GET with optional basic auth and returns the body on 2xx. +func get(ctx context.Context, url, user, pass string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if user != "" || pass != "" { + req.SetBasicAuth(user, pass) + } + resp, err := httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return body, nil +} + +// ── WebSwitch 1216H ──────────────────────────────────────────────────── + +type webswitch struct { + host string + count int +} + +// NewWebswitch builds a WebSwitch 1216H client (5 relays). +func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} } + +func (w *webswitch) Count() int { return w.count } + +func (w *webswitch) Set(ctx context.Context, relay int, on bool) error { + if relay < 1 || relay > w.count { + return fmt.Errorf("relay %d out of range 1..%d", relay, w.count) + } + action := "off" + if on { + action = "on" + } + _, err := get(ctx, fmt.Sprintf("http://%s/relaycontrol/%s/%d", w.host, action, relay), "", "") + return err +} + +func (w *webswitch) Status(ctx context.Context) ([]bool, error) { + // Build the "1$2$3$..." selector the device expects. + var sel strings.Builder + for i := 1; i <= w.count; i++ { + sel.WriteString(strconv.Itoa(i)) + sel.WriteByte('$') + } + body, err := get(ctx, fmt.Sprintf("http://%s/relaystate/get2/%s", w.host, sel.String()), "", "") + if err != nil { + return nil, err + } + out := make([]bool, w.count) + // Lines "n,state" — "1,1", "2,0", … + for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") { + parts := strings.Split(strings.TrimSpace(line), ",") + if len(parts) != 2 { + continue + } + n, e1 := strconv.Atoi(parts[0]) + st, e2 := strconv.Atoi(strings.TrimSpace(parts[1])) + if e1 != nil || e2 != nil || n < 1 || n > w.count { + continue + } + out[n-1] = st == 1 + } + return out, nil +} + +// ── KMTronic LAN 8-relay WEB ─────────────────────────────────────────── + +type kmtronic struct { + host string + user, pass string + count int +} + +// NewKMTronic builds a KMTronic LAN WEB relay client (8 relays). user/pass are +// blank unless the board's HTTP authentication is enabled. +func NewKMTronic(host, user, pass string) Device { + return &kmtronic{host: host, user: user, pass: pass, count: 8} +} + +func (k *kmtronic) Count() int { return k.count } + +func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error { + if relay < 1 || relay > k.count { + return fmt.Errorf("relay %d out of range 1..%d", relay, k.count) + } + state := "00" + if on { + state = "01" + } + // FF: e.g. FF0101 = relay 1 on, FF0800 = relay 8 off. + _, err := get(ctx, fmt.Sprintf("http://%s/FF%02d%s", k.host, relay, state), k.user, k.pass) + return err +} + +// kmStatus mirrors status.xml. relay0 is reserved; relay1..relay8 are the board. +type kmStatus struct { + XMLName xml.Name `xml:"response"` + Relays []struct { + XMLName xml.Name + Value string `xml:",chardata"` + } `xml:",any"` +} + +func (k *kmtronic) Status(ctx context.Context) ([]bool, error) { + body, err := get(ctx, fmt.Sprintf("http://%s/status.xml", k.host), k.user, k.pass) + if err != nil { + return nil, err + } + var s kmStatus + if err := xml.Unmarshal(body, &s); err != nil { + return nil, fmt.Errorf("kmtronic: bad status.xml: %w", err) + } + out := make([]bool, k.count) + for _, r := range s.Relays { + // Element names are relay0..relay8; relay0 is reserved. + name := r.XMLName.Local + if !strings.HasPrefix(name, "relay") { + continue + } + n, e := strconv.Atoi(strings.TrimPrefix(name, "relay")) + if e != nil || n < 1 || n > k.count { + continue + } + out[n-1] = strings.TrimSpace(r.Value) == "1" + } + return out, nil +} diff --git a/internal/relaydev/relaydev_test.go b/internal/relaydev/relaydev_test.go new file mode 100644 index 0000000..70fc5be --- /dev/null +++ b/internal/relaydev/relaydev_test.go @@ -0,0 +1,101 @@ +package relaydev + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// The status parsers are the risky part; pin them against the documented wire +// formats using a stub HTTP server. + +func TestWebswitchStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/relaystate/get2/") { + t.Errorf("unexpected status path %q", r.URL.Path) + } + _, _ = w.Write([]byte("1,1\n2,0\n3,1\n4,0\n5,1\n")) + })) + defer srv.Close() + + d := NewWebswitch(strings.TrimPrefix(srv.URL, "http://")) + st, err := d.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + want := []bool{true, false, true, false, true} + if len(st) != 5 { + t.Fatalf("got %d relays, want 5", len(st)) + } + for i := range want { + if st[i] != want[i] { + t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i]) + } + } +} + +func TestWebswitchSetURL(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + })) + defer srv.Close() + d := NewWebswitch(strings.TrimPrefix(srv.URL, "http://")) + if err := d.Set(context.Background(), 4, true); err != nil { + t.Fatal(err) + } + if gotPath != "/relaycontrol/on/4" { + t.Errorf("set path = %q, want /relaycontrol/on/4", gotPath) + } + _ = d.Set(context.Background(), 4, false) +} + +func TestKMTronicStatus(t *testing.T) { + // relay0 reserved (ignored); relay7 + relay8 ON. + xmlBody := `` + + `0000` + + `00011` + + `` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/status.xml" { + t.Errorf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(xmlBody)) + })) + defer srv.Close() + + d := NewKMTronic(strings.TrimPrefix(srv.URL, "http://"), "", "") + st, err := d.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(st) != 8 { + t.Fatalf("got %d relays, want 8", len(st)) + } + if st[6] != true || st[7] != true { + t.Errorf("relay7/8 = %v/%v, want on/on", st[6], st[7]) + } + for i := 0; i < 6; i++ { + if st[i] { + t.Errorf("relay %d unexpectedly on", i+1) + } + } +} + +func TestKMTronicSetURL(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + })) + defer srv.Close() + d := NewKMTronic(strings.TrimPrefix(srv.URL, "http://"), "", "") + if err := d.Set(context.Background(), 8, true); err != nil { + t.Fatal(err) + } + if gotPath != "/FF0801" { + t.Errorf("set path = %q, want /FF0801", gotPath) + } + _ = d.Set(context.Background(), 1, false) // → /FF0100 +} diff --git a/internal/rotgenius/rotgenius.go b/internal/rotgenius/rotgenius.go new file mode 100644 index 0000000..046d983 --- /dev/null +++ b/internal/rotgenius/rotgenius.go @@ -0,0 +1,193 @@ +// Package rotgenius drives a 4O3A Rotator Genius over its native TCP text +// protocol (rev 4, default port 9006). All data is fixed-length extended-ASCII; +// there is no sequence/framing wrapper — you send a short command and read back a +// fixed-length reply. +// +// Commands used here: +// +// |h read heading + full state (both rotators) +// |A move rotator ('1'|'2') to azimuth az3 (000..360) +// |P / |M rotate CW / CCW +// |S stop all movement +// +// The |h reply is 72 bytes: "|h" + Active[1] + Panic[1] then, per rotator, +// CurrentAzimuth[3] LimitCW[3] LimitCCW[3] Config[1] Moving[1] Offset[4] +// TargetAzimuth[3] StartAzimuth[3] Limit[1] Name[12]. Numeric fields may be +// space-padded; a CurrentAzimuth of 999 means the sensor is not connected. +package rotgenius + +import ( + "fmt" + "net" + "strconv" + "strings" + "time" +) + +const ( + defaultPort = 9006 + dialTimeout = 4 * time.Second + ioTimeout = 4 * time.Second + hdrReplyLen = 72 // fixed length of the |h reply +) + +// Status is one rotator's live state parsed from a |h reply. +type Status struct { + Azimuth int // current heading in degrees (0..360) + Connected bool // false when the sensor reports 999 (not connected) + Moving int // 0 not moving, 1 CW, 2 CCW + Target int // target azimuth when moving (else -1) +} + +// Client is a stateless connector: each call opens a short-lived TCP connection, +// mirroring how the PstRotator client works, so there is no socket to manage. +type Client struct { + host string + port int +} + +func New(host string, port int) *Client { + if port <= 0 { + port = defaultPort + } + return &Client{host: host, port: port} +} + +func (c *Client) dial() (net.Conn, error) { + d := net.Dialer{Timeout: dialTimeout} + conn, err := d.Dial("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port))) + if err != nil { + return nil, err + } + _ = conn.SetDeadline(time.Now().Add(ioTimeout)) + return conn, nil +} + +// exchange sends cmd and returns up to max bytes of the reply. +func (c *Client) exchange(cmd string, max int) ([]byte, error) { + conn, err := c.dial() + if err != nil { + return nil, err + } + defer conn.Close() + if _, err := conn.Write([]byte(cmd)); err != nil { + return nil, fmt.Errorf("write %q: %w", cmd, err) + } + buf := make([]byte, 0, max) + tmp := make([]byte, max) + for len(buf) < max { + n, rerr := conn.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + } + if rerr != nil { + break // deadline or EOF — return what we have and let the parser judge + } + } + return buf, nil +} + +// atoiField trims the space-padding a Rotator Genius field may carry and parses +// it. An empty or non-numeric field yields 0. +func atoiField(s string) int { + n, _ := strconv.Atoi(strings.TrimSpace(s)) + return n +} + +// Heading reads the current azimuth of the given rotator (1 or 2). raw is the +// decoded field for diagnostics. +func (c *Client) Heading(rotator int) (Status, string, error) { + st, err := c.Read(rotator) + if err != nil { + return Status{}, "", err + } + return st, strconv.Itoa(st.Azimuth), nil +} + +// Read fetches and parses the full |h reply for one rotator (1 or 2). +func (c *Client) Read(rotator int) (Status, error) { + if rotator != 1 && rotator != 2 { + rotator = 1 + } + reply, err := c.exchange("|h", hdrReplyLen) + if err != nil { + return Status{}, err + } + i := strings.Index(string(reply), "|h") + if i < 0 || len(reply)-i < hdrReplyLen { + return Status{}, fmt.Errorf("rotgenius: short |h reply (%d bytes)", len(reply)) + } + p := reply[i:] + // Per-rotator block base: rotator 1 at offset 4, rotator 2 at 4+34=38. + base := 4 + if rotator == 2 { + base = 38 + } + // Within a rotator block: CurrentAzimuth@0, LimitCW@3, LimitCCW@6, Config@9, + // Moving@10, Offset@11, TargetAzimuth@15, StartAzimuth@18, Limit@21, Name@22. + cur := atoiField(string(p[base : base+3])) + moving := atoiField(string(p[base+10 : base+11])) + target := atoiField(string(p[base+15 : base+18])) + st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1} + if target != 999 { + st.Target = target + } + return st, nil +} + +// GoTo moves the rotator to az (0..360). The reply's status byte is 'K' on +// accept, 'F' on reject. +func (c *Client) GoTo(rotator, az int) error { + if rotator != 1 && rotator != 2 { + rotator = 1 + } + if az < 0 { + az = 0 + } + if az > 360 { + az = 360 + } + reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8) + if err != nil { + return err + } + return checkKF(reply, "GoTo") +} + +// Stop halts all movement. +func (c *Client) Stop() error { + reply, err := c.exchange("|S", 8) + if err != nil { + return err + } + return checkKF(reply, "Stop") +} + +// CW / CCW nudge a rotator; it runs to its limit unless stopped. +func (c *Client) CW(rotator int) error { return c.rotate('P', rotator) } +func (c *Client) CCW(rotator int) error { return c.rotate('M', rotator) } + +func (c *Client) rotate(cmd byte, rotator int) error { + if rotator != 1 && rotator != 2 { + rotator = 1 + } + reply, err := c.exchange(fmt.Sprintf("|%c%d", cmd, rotator), 8) + if err != nil { + return err + } + return checkKF(reply, string(cmd)) +} + +// checkKF reads the accept/reject status: 'K' ok, 'F' failed. The reply carries +// no other letters (the rest is the header + digits), so scanning for them is +// unambiguous. +func checkKF(reply []byte, what string) error { + s := string(reply) + if strings.ContainsRune(s, 'K') { + return nil + } + if strings.ContainsRune(s, 'F') { + return fmt.Errorf("rotgenius: %s rejected by the controller", what) + } + return fmt.Errorf("rotgenius: no reply to %s", what) +} diff --git a/internal/rotgenius/rotgenius_test.go b/internal/rotgenius/rotgenius_test.go new file mode 100644 index 0000000..25d63be --- /dev/null +++ b/internal/rotgenius/rotgenius_test.go @@ -0,0 +1,127 @@ +package rotgenius + +import "testing" + +// Build a 72-byte |h reply from per-rotator field values, so the fixed offsets in +// Read() are pinned to the rev-4 layout. Numeric fields are space/zero padded to +// their documented widths. +func buildHReply(cur1, cw1, ccw1 string, cfg1, mv1 byte, off1, tgt1, start1 string, lim1 byte, name1, + cur2, cw2, ccw2 string, cfg2, mv2 byte, off2, tgt2, start2 string, lim2 byte, name2 string) []byte { + pad := func(s string, n int) string { + for len(s) < n { + s = " " + s + } + return s[:n] + } + b := []byte("|h") + b = append(b, '0', 0x00) // Active, Panic + block := func(cur, cw, ccw string, cfg, mv byte, off, tgt, start string, lim byte, name string) { + b = append(b, []byte(pad(cur, 3))...) + b = append(b, []byte(pad(cw, 3))...) + b = append(b, []byte(pad(ccw, 3))...) + b = append(b, cfg, mv) + b = append(b, []byte(pad(off, 4))...) + b = append(b, []byte(pad(tgt, 3))...) + b = append(b, []byte(pad(start, 3))...) + b = append(b, lim) + b = append(b, []byte(pad(name, 12))...) + } + block(cur1, cw1, ccw1, cfg1, mv1, off1, tgt1, start1, lim1, name1) + block(cur2, cw2, ccw2, cfg2, mv2, off2, tgt2, start2, lim2, name2) + return b +} + +func TestReadParsesBothRotators(t *testing.T) { + // Rotator 1: az 100, moving CW (1), no target (999). Rotator 2: az 999 (sensor + // offline), not moving. Mirrors the manual's worked example. + reply := buildHReply( + "100", "005", "350", 'A', '1', "0", "999", "999", '0', "TOW1", + "999", "010", "060", 'E', '0', "1", "999", "999", '0', "") + + c := &Client{} + _ = c + if len(reply) != hdrReplyLen { + t.Fatalf("built reply is %d bytes, want %d — field widths drifted from rev 4", len(reply), hdrReplyLen) + } + + st1, err := parseFor(reply, 1) + if err != nil { + t.Fatal(err) + } + if st1.Azimuth != 100 || !st1.Connected || st1.Moving != 1 { + t.Errorf("rotator 1 = %+v, want az 100, connected, moving CW", st1) + } + if st1.Target != -1 { + t.Errorf("rotator 1 target = %d, want -1 (999 = not set)", st1.Target) + } + + st2, err := parseFor(reply, 2) + if err != nil { + t.Fatal(err) + } + if st2.Connected || st2.Azimuth != 999 { + t.Errorf("rotator 2 = %+v, want disconnected (az 999)", st2) + } +} + +// parseFor exercises the offset math without a socket. +func parseFor(reply []byte, rotator int) (Status, error) { + base := 4 + if rotator == 2 { + base = 38 + } + if len(reply) < hdrReplyLen { + return Status{}, errShort + } + cur := atoiField(string(reply[base : base+3])) + moving := atoiField(string(reply[base+10 : base+11])) + target := atoiField(string(reply[base+15 : base+18])) + st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1} + if target != 999 { + st.Target = target + } + return st, nil +} + +var errShort = fmtErrorf("short") + +func fmtErrorf(s string) error { return &strErr{s} } + +type strErr struct{ s string } + +func (e *strErr) Error() string { return e.s } + +func TestGoToFormatting(t *testing.T) { + // The command must zero-pad the azimuth to 3 digits, per the manual's fields. + cases := map[int]string{0: "|A1000", 5: "|A1005", 90: "|A1090", 360: "|A1360"} + for az, want := range cases { + got := "|A" + "1" + pad3(az) + if got != want { + t.Errorf("az %d → %q, want %q", az, got, want) + } + } +} + +func pad3(az int) string { + s := "" + switch { + case az >= 100: + s = itoa(az) + case az >= 10: + s = "0" + itoa(az) + default: + s = "00" + itoa(az) + } + return s +} +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +}