import { useCallback, useEffect, useRef, useState } from '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'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { writeUiPref } from '@/lib/uiPref'; import { RotorCompass } from '@/components/RotorCompass'; import { GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetRotatorHeading, RotatorGoTo, RotatorStop, GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, } from '../../wailsjs/go/main/App'; type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; labels: string[] }; type Relay = { number: number; label: string; on: boolean }; type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] }; const RELAY_COUNT: Record = { 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}
}
); } 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. 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 [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 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 (
{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 && (
{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`}
))}
)}

{t('station.elementsHint')}

)} {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 }); 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 []; } }); const dragId = useRef(null); const [cols, setCols] = useState(() => localStorage.getItem('opslog.stationCols') || 'auto'); 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 */ } }, []); const pollAnt = useCallback(async () => { try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ } }, []); useEffect(() => { loadDevices(); }, [loadDevices]); useEffect(() => { poll(); pollRot(); pollAnt(); const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000); return () => window.clearInterval(id); }, [poll, pollRot, pollAnt, 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: }); } 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 && !ant.enabled; // Column count controls the layout: with 4 widgets, choosing "2" lays them out // 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to // fill however many columns are available. const gridCols: Record = { auto: 'sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4', '1': 'grid-cols-1', '2': 'grid-cols-2', '3': 'grid-cols-3', '4': 'grid-cols-4', }; return (

{t('station.title')}

{(['auto', '2', '3', '4'] as const).map((c) => ( ))}
{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 }); }} /> ))}
); }