import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import { MotorAntennaWidget, type AntStatus } from '@/components/MotorAntennaWidget'; import { useI18n } from '@/lib/i18n'; import { writeUiPref } from '@/lib/uiPref'; import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading'; import { RotorCompass } from '@/components/RotorCompass'; import { AmpCard } from '@/components/AmpCard'; import { TunerCard } from '@/components/TunerCard'; import type { TGStatus } from '@/components/TunerGeniusPanel'; import { GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, RotatorGoTo, RotatorStop, SetActiveRotor, GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, MotorTuneKHz, MotorNudgeKHz, SetMotorFollow, ListDenkoviDevices, ListSerialPorts, TestStationDevice, GetAmpStatuses, GetFlexState, GetTunerGeniusStatus, GetTunerGeniusSettings, GetPSUStatus, GetPSUSettings, SetPSUOutput, } from '../../wailsjs/go/main/App'; type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; type PSUState = { connected: boolean; on: boolean; volts: number; amps: number; watts: number; set_volts: number; set_amps: number; protected: number; error?: string }; // The bench supply. One button — the output — and the three numbers that say // what it is actually delivering. // // The SET points are shown beside them, small, because "13.8 V set" next to // "0.02 A out" is how an operator sees at a glance that the supply is on but // the radio is not drawing. They are not editable: OpsLog reads them and never // writes them, which is the whole safety story of this device. function PSUCard({ st, busy, onToggle, t }: { st: PSUState; busy: boolean; onToggle: (on: boolean) => void; t: (k: string, v?: any) => string; }) { const tripped = (st.protected ?? 0) !== 0; return (
{t('psu.title')}
{(st.volts ?? 0).toFixed(2)} V {(st.amps ?? 0).toFixed(3)} A
{(st.watts ?? 0).toFixed(1)} W {t('psu.setTo')} {(st.set_volts ?? 0).toFixed(2)} V / {(st.set_amps ?? 0).toFixed(3)} A
{tripped && (
{t('psu.tripped')} (0x{(st.protected ?? 0).toString(16).toUpperCase()})
)}
); } type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; channels?: number; labels: string[]; // Generic HTTP board only. The per-relay URLs win over the patterns. on_urls?: string[]; off_urls?: string[]; on_pattern?: string; off_pattern?: string; insecure_tls?: boolean; }; type Relay = { number: number; label: string; on: boolean }; type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] }; // Dashboard geometry: a grid of EQUAL columns whose cards also share a common // HEIGHT per row — every card stretches to the tallest one beside it. Masonry // packed tighter but left the row edges ragged, which read as untidy on a wide // screen; aligned rows are what an operator expects from a dashboard. const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column const GRID_GAP = 16; // px between cards, both axes const RELAY_COUNT: Record = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2, httpgen: 4 }; const TYPE_LABEL: Record = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT relay', httpgen: 'HTTP relay' }; // Relay count for a configured device: fixed by type, except Denkovi (4/8) and // the generic USB-serial board, whose channel count the user picks. // chanCount is the relay count for a device. It MUST agree with // deviceRelayCount in app.go: the two decide how many labels and URL rows the // editor shows and how many relays the driver is built for, and a type known to // one and not the other silently pins the count to its fixed default — which is // exactly what happened when the HTTP board was added here and not below. const chanCount = (d: Device): number => (d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2) : d.type === 'httpgen' ? (d.channels && d.channels >= 1 ? d.channels : 4) : (RELAY_COUNT[d.type] ?? 5); function blankDevice(): Device { return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') }; } type Heading = { enabled: boolean; ok: boolean; azimuth: number; rotors?: string[]; active?: 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')}
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the app carries it rather than each caller drawing its own. */} { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }} onGoto={(az) => 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 }); 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); // Max columns per row (persisted). "Auto" fills the window; a number caps the // row so cards wrap onto further lines even when there's horizontal room. const [cols, setCols] = useState(() => localStorage.getItem('opslog.stationCols') || 'auto'); // How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide // cards as the window allows; an explicit count is honoured as asked, even // past that width — the operator can see his own screen. const gridRef = useRef(null); const [colCount, setColCount] = useState(1); useLayoutEffect(() => { if (cols !== 'auto') { setColCount(Number(cols) || 1); return; } const el = gridRef.current; if (!el) return; const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP)))); measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, [cols]); // Amplifiers (Settings → Amplifier): shown here so operators without a // FlexRadio panel still get the controls. EVERY configured amp gets its own // card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the // FlexRadio meters read live; the Flex state rides along because a PGXL's // OPERATE/meters come from the radio, not the direct GSCP link. const [amps, setAmps] = useState([]); const [flexState, setFlexState] = useState(null); useEffect(() => { let alive = true; const load = () => Promise.all([ GetAmpStatuses().catch(() => []), GetFlexState().catch(() => null), ]).then(([l, fx]: any[]) => { if (alive) { setAmps((l ?? []) as any[]); setFlexState(fx); } }); load(); const id = window.setInterval(load, 1500); return () => { alive = false; window.clearInterval(id); }; }, []); // Tuner Genius XL (4O3A): a card here too, so operators without a FlexRadio // panel still get the controls. Re-read the enabled flag so it appears/hides // without a restart. const [tg, setTg] = useState({ connected: false }); const [tgEnabled, setTgEnabled] = useState(false); useEffect(() => { let alive = true; const load = async () => { try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {} try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {} }; load(); const id = window.setInterval(load, 500); // fast so meters track TX (see App.tsx) return () => { alive = false; window.clearInterval(id); }; }, []); // Bench power supply. Polled slower than the tuner: it has no meters that // track a transmission, and every poll is three Modbus exchanges on a 9600 // baud line the operator may also be using to switch the output. const [psu, setPsu] = useState({ connected: false, on: false, volts: 0, amps: 0, watts: 0, set_volts: 0, set_amps: 0, protected: 0 }); const [psuEnabled, setPsuEnabled] = useState(false); const [psuBusy, setPsuBusy] = useState(false); useEffect(() => { let alive = true; const load = async () => { try { const en: any = await GetPSUSettings(); if (alive) setPsuEnabled(!!en?.enabled); } catch {} try { const st: any = await GetPSUStatus(); if (alive && st) setPsu(st as PSUState); } catch {} }; load(); const id = window.setInterval(load, 1500); return () => { alive = false; window.clearInterval(id); }; }, []); const togglePSU = useCallback(async (on: boolean) => { setPsuBusy(true); // No optimistic flip here, unlike the relays: the supply echoes the value it // actually set, so showing ON before it confirms would be showing something // OpsLog does not know. A power switch is the wrong place to guess. try { await SetPSUOutput(on); const st: any = await GetPSUStatus(); if (st) setPsu(st as PSUState); } catch { /* the poll will tell the truth */ } finally { setPsuBusy(false); } }, []); 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 pollAnt = useCallback(async () => { try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ } }, []); useEffect(() => { loadDevices(); }, [loadDevices]); // The heading comes from the shared poller: one loop for the whole app, fast // while the antenna turns and slow while it is parked. Every poll opens and // closes the controller's port, and this panel used to run its own alongside // the status bar's — the same controller asked twice over. useEffect(() => subscribeRotorHeading((h) => setRot(h as any)), []); useEffect(() => { poll(); pollAnt(); const id = window.setInterval(() => { poll(); pollAnt(); }, 3000); return () => window.clearInterval(id); }, [poll, 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 })); // The generic HTTP board has no address of its own and nothing to poll: its // relays can each live on a different box, and no status endpoint is read // back. So no host under the name, no online dot, and the buttons are never // greyed out waiting for a connection that is never made. const fireAndForget = dev.type === 'httpgen'; return (
{dev.name || TYPE_LABEL[dev.type]}
{TYPE_LABEL[dev.type]}{fireAndForget || !dev.host ? '' : ` · ${dev.host}`}
{fireAndForget ? : ( )}
{/* Compact one-line relay buttons at a FIXED width so they don't stretch across the whole card — they wrap to fill the available width instead. */}
{relays.map((r) => { const key = `${dev.id}:${r.number}`; const label = r.label || `${t('station.relay')} ${r.number}`; return ( ); })}
); }; // `wide` cards take two grid columns instead of one. The amplifier and tuner // carry meter rows and a channel selector that are unreadable squeezed into a // single ~430px column — they are the same cards the FlexRadio panel shows // full-width, and they need that room here too. const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = []; if (rot.enabled) { widgets.push({ id: 'rotator', node: }); } if (ant.enabled) { widgets.push({ id: 'antenna', node: }); } // One card per configured amplifier (identical to the Flex panel's card). for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: , wide: true }); // Tuner Genius XL card (identical to the Flex panel's). if (tgEnabled) widgets.push({ id: 'tuner', node: , wide: true }); if (psuEnabled) widgets.push({ id: 'psu', 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 && amps.length === 0 && !tgEnabled; return (

{t('station.title')}

{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a card can sit on a further line even with horizontal room to spare. */}
{(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => ( ))}
{noDevices && !editing && (
{t('station.empty')}
)} {/* Dashboard grid: equal columns, and every card stretches to the height of the tallest one on its row, so the rows line up instead of ending ragged. One column each, two for the amplifier/tuner whose meter rows need the room. Each card has a grip handle (left rail) as the drag initiator, since the body is full of buttons. */}
{ordered.map((w) => (
{ if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }} onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}> {/* h-full down the chain is what makes the card fill the row height rather than sit at its natural size in a taller cell. */}
{ dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }} onDragEnd={() => { dragId.current = null; }} title={t('station.dragMove')} className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors', 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 channels = (type === 'denkovi' || type === 'usbrelay') ? (device.channels || 8) : undefined; const n = chanCount({ ...device, type, channels }); const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? ''); onChange({ ...device, type, channels, labels }); }; const setChannels = (channels: number) => { const n = chanCount({ ...device, channels }); const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? ''); onChange({ ...device, channels, labels }); }; const isKM = device.type === 'kmtronic'; const isDingtian = device.type === 'dingtian'; const isDenkovi = device.type === 'denkovi'; const isUsbRelay = device.type === 'usbrelay'; const isHTTPGen = device.type === 'httpgen'; // {value} sends a relay's label, so a URL using it on an unnamed relay would // go out with an empty parameter. Warn while it is being typed rather than at // the moment an antenna fails to switch. const valueNeedsLabels = isHTTPGen && [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? ''] .some((s) => (s ?? '').includes('{value}')) && device.labels.some((l) => !l.trim()); // Any https:// among this board's URLs. A relay box on the LAN signs its own // certificate, so HTTPS to one cannot be verified — the operator has to say // whether to accept that, and the question only arises once they type https. const usesHTTPS = isHTTPGen && [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? ''] .some((u) => (u ?? '').trim().toLowerCase().startsWith('https://')); // COM ports for the generic USB-serial relay picker. const [serialPorts, setSerialPorts] = useState([]); useEffect(() => { if (!isUsbRelay) return; ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => setSerialPorts([])); }, [isUsbRelay]); // Detected FTDI serials for the Denkovi picker. const [denkoviSerials, setDenkoviSerials] = useState([]); const [detecting, setDetecting] = useState(false); const [detectMsg, setDetectMsg] = useState(''); const detectDenkovi = async () => { setDetecting(true); setDetectMsg(''); try { const list = ((await ListDenkoviDevices()) ?? []) as string[]; setDenkoviSerials(list); // Auto-fill when there's exactly one and nothing chosen yet. if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] }); setDetectMsg(list.length === 0 ? t('station.detectNone') : t('station.detectFound', { n: list.length })); } catch (e: any) { setDenkoviSerials([]); setDetectMsg(String(e?.message || e || t('station.detectNone'))); } finally { setDetecting(false); } }; // Connection test result for the current device config. const [testing, setTesting] = useState(false); const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null); const testDevice = async () => { setTesting(true); setTestMsg(null); try { const r: any = await TestStationDevice(device as any); setTestMsg(r?.ok ? { ok: true, text: t('station.testOk', { n: r.relays }) } : { ok: false, text: r?.error || t('station.testFail') }); } catch (e: any) { setTestMsg({ ok: false, text: String(e?.message || e || t('station.testFail')) }); } finally { setTesting(false); } }; useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]); return (
{device.id ? t('station.editDevice') : t('station.addDevice')}
onChange({ ...device, name: e.target.value })} />
{(isDenkovi || isUsbRelay || isDingtian || isHTTPGen) && (
)} {isDenkovi ? (
onChange({ ...device, host: e.target.value })} /> {denkoviSerials.map((s) =>

{t('station.ftdiHint')}

{detectMsg &&

{detectMsg}

}
) : isUsbRelay ? (

{t('station.usbRelayHint')}

) : isHTTPGen ? null : ( /* No Host for the generic board: its driver never reads one. Each URL below carries its own address, and they need not even share it — one relay can sit on a different box from the next. A field that changes nothing is worse than no field: it reads as the thing to fill in first, and then the URLs look like they should be relative to it. */
onChange({ ...device, host: e.target.value })} /> {t('station.hostHint')}
{/* Dingtian: both are OFF on a factory board — the session ID only when "HTTP Session" is enabled in its web page, the password only when a relay password is set. */} {isDingtian && ( <>
onChange({ ...device, user: e.target.value })} />
onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
)} {isKM && ( <>
onChange({ ...device, user: e.target.value })} />
onChange({ ...device, pass: e.target.value })} />
)}
)} {/* Generic HTTP board: a URL to switch each relay on and one to switch it off. Two ways to fill it, and the per-relay one is why this type exists — a hand-made switch often has URLs with nothing in common between channels, which no pattern can express. */} {isHTTPGen && (
onChange({ ...device, on_pattern: e.target.value })} />
onChange({ ...device, off_pattern: e.target.value })} />
{t('station.patternHint')}
{/* Shown only once an https:// URL is actually in use. A board on plain HTTP has no certificate to argue about, and an option that cannot matter yet is one more thing to wonder about. */} {usesHTTPS && ( )}
{device.labels.map((_, i) => (
{i + 1} { const on_urls = [...(device.on_urls ?? [])]; while (on_urls.length < device.labels.length) on_urls.push(''); on_urls[i] = e.target.value; onChange({ ...device, on_urls }); }} /> { const off_urls = [...(device.off_urls ?? [])]; while (off_urls.length < device.labels.length) off_urls.push(''); off_urls[i] = e.target.value; onChange({ ...device, off_urls }); }} />
))}
{t('station.perRelayHint')}
)}
{/* {value} sends the label, so an unnamed relay would go out as "?on=". Said here, beside the empty box, rather than when the antenna fails to switch and the log is the only place that explains why. */} {valueNeedsLabels &&

{t('station.valueNeedsLabels')}

}
{device.labels.map((lab, i) => ( { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} /> ))}
{testMsg && ( {testMsg.text} )}
{/* No connection test for the generic board, and no host required to save it. There is nothing to test: it has no address of its own and no status to read — its URLs are fired and forgotten. A button that can only ever say "OK, 4 relays" tests nothing, and a Save greyed out for a missing host made a perfectly complete configuration — four full URLs — impossible to store. */} {!isHTTPGen && ( )}
); }