diff --git a/app.go b/app.go index 379500f..8650a37 100644 --- a/app.go +++ b/app.go @@ -10752,6 +10752,9 @@ type motorAntenna interface { // frequency and has no individual-element command). Elements() []int SetElement(num, lengthMm int) error + // ReadElements queries the controller for the current element lengths on demand + // (Ultrabeam CMD_READ_BANDS); SteppIR returns an error. + ReadElements() ([]int, error) } // ubAdapter / steppirAdapter wrap each concrete client to the shared interface, @@ -10765,7 +10768,8 @@ func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) } func (a ubAdapter) Retract() error { return a.c.Retract() } func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() } -func (a ubAdapter) SetElement(n, mm int) error { return a.c.ModifyElement(n, mm) } +func (a ubAdapter) SetElement(n, mm int) error { return a.c.ModifyElement(n, mm) } +func (a ubAdapter) ReadElements() ([]int, error) { return a.c.ReadElements() } func (a ubAdapter) Elements() []int { if st, err := a.c.GetStatus(); err == nil && st != nil { return st.ElementLengths @@ -10792,6 +10796,9 @@ func (a steppirAdapter) Elements() []int { return nil } // SteppIR: func (a steppirAdapter) SetElement(_, _ int) error { return fmt.Errorf("individual element control is not available on the SteppIR") } +func (a steppirAdapter) ReadElements() ([]int, error) { + return nil, fmt.Errorf("individual element control is not available on the SteppIR") +} func (a steppirAdapter) Status() motorStatus { st, err := a.c.GetStatus() if err != nil || st == nil { @@ -11119,6 +11126,15 @@ func (a *App) MotorSetElement(num, lengthMm int) error { return a.motorAnt.SetElement(num, lengthMm) } +// MotorReadElements queries the controller for the current element lengths (mm), +// so the operator adjusts from the real values instead of blind. Ultrabeam only. +func (a *App) MotorReadElements() ([]int, error) { + if a.motorAnt == nil { + return nil, fmt.Errorf("antenna not connected — enable it in Settings → Antenna") + } + return a.motorAnt.ReadElements() +} + // SetUltrabeamDirection switches the antenna pattern: 0=normal, 1=180°, // 2=bidirectional (re-issues the current frequency with the new direction). func (a *App) SetUltrabeamDirection(direction int) error { diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index f29d5ec..07ff86d 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine } from 'lucide-react'; +import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -11,7 +11,7 @@ import { RotorCompass } from '@/components/RotorCompass'; import { GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetRotatorHeading, RotatorGoTo, RotatorStop, - GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, + GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, } from '../../wailsjs/go/main/App'; type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; @@ -97,17 +97,44 @@ type AntStatus = { enabled: boolean; type: string; connected: boolean; direction // MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the // Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam // only — per-element length adjustment. Heading/state is polled by the panel. +const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press + +// elementName maps an element index to a ham-radio name: 0 = reflector, +// 1 = driven element, then Director 1, 2, 3… +function elementName(i: number, t: (k: string, v?: any) => string): string { + if (i === 0) return t('station.reflector'); + if (i === 1) return t('station.driven'); + return `${t('station.director')} ${i - 1}`; +} + function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) { const [err, setErr] = useState(''); - const [elNum, setElNum] = useState('1'); // element to adjust (1..6) - const [elLen, setElLen] = useState(''); // target length (mm) + const [lengths, setLengths] = useState([]); // current element lengths (mm), from ReadElements + const [reading, setReading] = useState(false); + const [busyEl, setBusyEl] = useState(null); const run = (p: Promise) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e))); - const setElement = () => { - const n = parseInt(elNum, 10), mm = parseInt(elLen, 10); - if (!Number.isFinite(n) || !Number.isFinite(mm)) return; - run(MotorSetElement(n - 1, mm)); // protocol elements are 0-based (0..5) - }; const isUB = ant.type !== 'steppir'; + + const readLengths = useCallback(async () => { + setReading(true); setErr(''); + try { setLengths(((await MotorReadElements()) ?? []) as number[]); } + catch (e: any) { setErr(String(e?.message ?? e)); } + finally { setReading(false); } + }, []); + // Read the current lengths once when the Ultrabeam widget mounts/connects, so + // +/- starts from the real values rather than blind. + useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]); + + // Nudge one element by ±2 mm from its current known length. + const nudge = async (i: number, delta: number) => { + const cur = lengths[i] ?? 0; + const next = Math.max(0, cur + delta); + setBusyEl(i); setErr(''); + setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic + try { await MotorSetElement(i, next); refetch(); } + catch (e: any) { setErr(String(e?.message ?? e)); } + finally { setBusyEl(null); } + }; const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]]; return (
@@ -143,26 +170,39 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () = {isUB && (
-
{t('station.elements')}
- {ant.elements.length > 0 && ( -
- {ant.elements.map((mm, i) => ( - {t('station.element')} {i + 1}: {mm}mm +
+ {t('station.elements')} + +
+ {lengths.length === 0 ? ( +

{t('station.noLengths')}

+ ) : ( +
+ {/* Hide 0-length elements — an Ultrabeam reports 6 slots but a + 3-element beam only uses the first few. */} + {lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => ( +
+ {elementName(i, t)} + + + {busyEl === i ? : `${mm} mm`} + + +
))}
)} -
- - setElLen(e.target.value.replace(/[^0-9]/g, ''))} - onKeyDown={(e) => { if (e.key === 'Enter') setElement(); }} /> - -

{t('station.elementsHint')}

)} @@ -315,7 +355,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr return (
-
+

{t('station.title')}

)} -
+
{ordered.map((w) => (
{ dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }} @@ -341,7 +381,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
{!noDevices && ( -

{t('station.dragHint')}

+

{t('station.dragHint')}

)} {editing && ( diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index e711068..d771f60 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -89,7 +89,7 @@ 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', 'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.element': 'Element', 'station.set': 'Set', 'station.elementsHint': 'Set an individual element length in millimetres. Element 1 is the reflector side; verify which element responds on your antenna.', '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.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.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', '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 @@ -353,7 +353,7 @@ 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', 'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.element': 'Élément', 'station.set': 'Régler', 'station.elementsHint': "Règle la longueur d'un élément en millimètres. L'élément 1 est côté réflecteur ; vérifie quel élément répond sur ton antenne.", '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.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.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", '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).', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 56f1afa..46bd3df 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -538,6 +538,8 @@ export function LogUDPLoggedADIF(arg1:string):Promise; export function LookupCallsign(arg1:string):Promise; +export function MotorReadElements():Promise>; + export function MotorSetElement(arg1:number,arg2:number):Promise; export function MoveDatabase(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 51d0724..620b730 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1034,6 +1034,10 @@ export function LookupCallsign(arg1) { return window['go']['main']['App']['LookupCallsign'](arg1); } +export function MotorReadElements() { + return window['go']['main']['App']['MotorReadElements'](); +} + export function MotorSetElement(arg1, arg2) { return window['go']['main']['App']['MotorSetElement'](arg1, arg2); } diff --git a/internal/ultrabeam/ultrabeam.go b/internal/ultrabeam/ultrabeam.go index d3cad72..9cf37ba 100644 --- a/internal/ultrabeam/ultrabeam.go +++ b/internal/ultrabeam/ultrabeam.go @@ -470,6 +470,27 @@ func (c *Client) queryProgress() ([]int, error) { return []int{total, current}, nil } +// ReadElements reads the current per-element lengths for the active band +// (CMD_READ_BANDS). The controller is write-only for ModifyElement, so this is +// the only way to see the current lengths — needed so the operator isn't +// adjusting blind. The reply payload layout is not documented in the code, so we +// LOG it verbatim (once) and parse a best guess: element lengths as 16-bit +// little-endian values, matching how ModifyElement WRITES a length. Confirm the +// format from the logged bytes on real hardware, then tighten the parse. +func (c *Client) ReadElements() ([]int, error) { + payload, err := c.sendCommand(CMD_READ_BANDS, nil) + if err != nil { + return nil, err + } + log.Printf("Ultrabeam: READ_BANDS payload (% X) — %d bytes", payload, len(payload)) + // Best-guess parse: consecutive 16-bit LE values = element lengths in mm. + out := make([]int, 0, len(payload)/2) + for i := 0; i+1 < len(payload); i += 2 { + out = append(out, int(payload[i])|int(payload[i+1])<<8) + } + return out, nil +} + // SetFrequency changes frequency and optional direction (command 3) func (c *Client) SetFrequency(freqKhz int, direction int) error { // Trace WHO asked for the change — the caller's function + line — so an