+
+
+
+ {/* Switching to Rotator Genius moves the default port to its native
+ 9006; back to PstRotator restores 12000. */}
+
+
+ {isRG && (
+
+
+
+
+ )}
+
-
+ {!isRG && (
+
+ )}
+ {isRG &&
{t('rot.rgHint')}
}
{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]) => (
+ turn(az)}
+ className="flex-1 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:bg-muted">
+ {lbl}
+
+ ))}
+
+
+ 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" />
+ turn(parseInt(goto, 10))}>{t('station.go')}
+
+
RotatorStop().then(poll).catch((e) => setErr(String(e?.message ?? e)))}>
+ {t('station.stop')}
+
+ {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}
+
+
+
setEditing({ ...dev, labels: [...dev.labels] })}>
+
removeDevice(dev.id)}>
+
+
+ {relays.map((r) => {
+ const key = `${dev.id}:${r.number}`;
+ const label = r.label || `${t('station.relay')} ${r.number}`;
+ return (
+
toggle(dev, r.number, !r.on)}
+ className={cn('flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors disabled:opacity-40',
+ r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
+
+ {busy[key] ? : }
+
+
+ {label}
+
+ {r.on ? t('station.on') : t('station.off')}
+
+
+
+ );
+ })}
+
+
+ );
+ };
+
+ 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')}
+
setEditing(blankDevice())}>
+ {t('station.addDevice')}
+
+
+
+ {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 })} />
+
+
+
+
+
+
+ {device.labels.map((lab, i) => (
+ { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} />
+ ))}
+
+
+
+ {t('station.cancel')}
+ {t('station.save')}
+
+
+ );
+}
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)
+}