diff --git a/app.go b/app.go index 57f9319..379500f 100644 --- a/app.go +++ b/app.go @@ -10747,6 +10747,11 @@ type motorAntenna interface { Retract() error LastSetKHz() int Status() motorStatus + // Elements returns the per-element lengths (mm) when the antenna exposes them + // (Ultrabeam), or nil when it doesn't (SteppIR tunes its elements from the + // frequency and has no individual-element command). + Elements() []int + SetElement(num, lengthMm int) error } // ubAdapter / steppirAdapter wrap each concrete client to the shared interface, @@ -10760,6 +10765,13 @@ 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) Elements() []int { + if st, err := a.c.GetStatus(); err == nil && st != nil { + return st.ElementLengths + } + return nil +} func (a ubAdapter) Status() motorStatus { st, err := a.c.GetStatus() if err != nil || st == nil { @@ -10776,6 +10788,10 @@ func (a steppirAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k func (a steppirAdapter) SetDirection(d int) error { return a.c.SetDirection(d) } func (a steppirAdapter) Retract() error { return a.c.Retract() } func (a steppirAdapter) LastSetKHz() int { return a.c.LastSetKHz() } +func (a steppirAdapter) Elements() []int { return nil } // SteppIR: no per-element control +func (a steppirAdapter) SetElement(_, _ int) error { + return 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 { @@ -11062,19 +11078,22 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struc // direction control). Enabled mirrors the setting; the rest comes from the // device's most recent status poll. type UltrabeamStatusInfo struct { - Enabled bool `json:"enabled"` - Connected bool `json:"connected"` - Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional - Frequency int `json:"frequency"` // KHz - Band int `json:"band"` - Moving bool `json:"moving"` + Enabled bool `json:"enabled"` + Type string `json:"type"` // "ultrabeam" | "steppir" + Connected bool `json:"connected"` + Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional + Frequency int `json:"frequency"` // KHz + Band int `json:"band"` + Moving bool `json:"moving"` + Elements []int `json:"elements"` // per-element lengths (mm); empty when unsupported } // GetUltrabeamStatus returns the antenna's current state for the UI poll. func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo { - out := UltrabeamStatusInfo{} + out := UltrabeamStatusInfo{Elements: []int{}} s, _ := a.GetUltrabeamSettings() out.Enabled = s.Enabled + out.Type = s.Type if a.motorAnt == nil { return out } @@ -11084,9 +11103,22 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo { out.Frequency = st.Frequency out.Band = st.Band out.Moving = st.Moving + if el := a.motorAnt.Elements(); el != nil { + out.Elements = el + } return out } +// MotorSetElement sets one element's length (mm) on an Ultrabeam. Returns an +// error on the SteppIR, which has no per-element command. +func (a *App) MotorSetElement(num, lengthMm int) error { + if a.motorAnt == nil { + return fmt.Errorf("antenna not connected — enable it in Settings → Antenna") + } + a.noteMotorMoveCommanded() + return a.motorAnt.SetElement(num, lengthMm) +} + // 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 a3f36ad..8d33537 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 } from 'lucide-react'; +import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -11,6 +11,7 @@ import { RotorCompass } from '@/components/RotorCompass'; import { GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetRotatorHeading, RotatorGoTo, RotatorStop, + GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, } from '../../wailsjs/go/main/App'; type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; @@ -91,6 +92,74 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato ); } +type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[] }; + +// 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. +function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) { + const [err, setErr] = useState(''); + const [elDraft, setElDraft] = useState>({}); + const run = (p: Promise) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e))); + const isUB = ant.type !== 'steppir'; + const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]]; + return ( +
+
+ +
+
{isUB ? 'Ultrabeam' : 'SteppIR'}
+ {ant.frequency > 0 &&
{(ant.frequency / 1000).toFixed(3)} MHz
} +
+ {ant.moving && {t('station.moving')}} + +
+
+
+
{t('station.pattern')}
+
+ {dirs.map(([d, lbl]) => ( + + ))} +
+
+ + + {isUB && ant.elements.length > 0 && ( +
+
{t('station.elements')}
+
+ {ant.elements.map((mm, i) => ( +
+ {t('station.element')} {i + 1} + {mm} mm + setElDraft((d) => ({ ...d, [i]: e.target.value.replace(/[^0-9]/g, '') }))} + onKeyDown={(e) => { if (e.key === 'Enter' && elDraft[i]) run(MotorSetElement(i, parseInt(elDraft[i], 10))); }} /> + +
+ ))} +
+

{t('station.elementsHint')}

+
+ )} + {err &&
{err}
} +
+
+ ); +} + export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) { const { t } = useI18n(); const [devices, setDevices] = useState([]); @@ -98,6 +167,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr 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 }); + const [ant, setAnt] = useState({ enabled: false, type: '', connected: false, direction: 0, frequency: 0, moving: false, elements: [] }); // 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 []; } @@ -117,13 +187,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr const pollRot = useCallback(async () => { try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ } }, []); + const pollAnt = useCallback(async () => { + try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ } + }, []); useEffect(() => { loadDevices(); }, [loadDevices]); useEffect(() => { - poll(); pollRot(); - const id = window.setInterval(() => { poll(); pollRot(); }, 3000); + poll(); pollRot(); pollAnt(); + const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000); return () => window.clearInterval(id); - }, [poll, pollRot, devices.length]); + }, [poll, pollRot, pollAnt, devices.length]); const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); }; // Reorder so `dragged` lands just before `target`. @@ -217,13 +290,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr if (rot.enabled) { widgets.push({ id: 'rotator', node: }); } + if (ant.enabled) { + widgets.push({ id: 'antenna', 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; + const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled; return (
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 98d9c94..e711068 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.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.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.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.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.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.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 194b432..56f1afa 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 MotorSetElement(arg1:number,arg2:number):Promise; + export function MoveDatabase(arg1:string):Promise; export function NetActivate(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index f77b836..51d0724 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 MotorSetElement(arg1, arg2) { + return window['go']['main']['App']['MotorSetElement'](arg1, arg2); +} + export function MoveDatabase(arg1) { return window['go']['main']['App']['MoveDatabase'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 85dc46e..6438a7a 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2580,11 +2580,13 @@ export namespace main { } export class UltrabeamStatusInfo { enabled: boolean; + type: string; connected: boolean; direction: number; frequency: number; band: number; moving: boolean; + elements: number[]; static createFrom(source: any = {}) { return new UltrabeamStatusInfo(source); @@ -2593,11 +2595,13 @@ export namespace main { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.enabled = source["enabled"]; + this.type = source["type"]; this.connected = source["connected"]; this.direction = source["direction"]; this.frequency = source["frequency"]; this.band = source["band"]; this.moving = source["moving"]; + this.elements = source["elements"]; } } export class UpdateInfo {