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 } 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 { AmpCard } from '@/components/AmpCard'; import { TunerCard } from '@/components/TunerCard'; import type { TGStatus } from '@/components/TunerGeniusPanel'; import { GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetRotatorHeading, RotatorGoTo, RotatorStop, GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, ListDenkoviDevices, ListSerialPorts, TestStationDevice, GetAmpStatuses, GetFlexState, GetTunerGeniusStatus, GetTunerGeniusSettings, } 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; channels?: number; labels: string[] }; 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 }; const TYPE_LABEL: Record = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT 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. 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) : (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 }; // 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 [editingEl, setEditingEl] = useState(null); // element whose mm is being typed const [editVal, setEditVal] = useState(''); 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]); // Send an absolute length to one element and remember it as the new baseline. const setLen = async (i: number, mm: number) => { const next = Math.max(0, Math.round(mm)); const prev = lengths[i] ?? 0; setBusyEl(i); setErr(''); setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic try { await MotorSetElement(i, next); refetch(); } catch (e: any) { // Rejected by the controller — most often the element is at its travel // limit for this band (extending on a low band). Revert the optimistic // value so the display stays truthful, and explain the likely cause when // the failed move was an extension. setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; }); setErr(next > prev ? t('station.atMax') : String(e?.message ?? e)); } finally { setBusyEl(null); } }; // Nudge one element by ±2 mm from its current known length. const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta); // Commit a typed exact length (click on the mm value). Lets the operator fix // the baseline when the auto-read is off, so +/- then work reliably. const commitEdit = (i: number) => { const v = parseInt(editVal, 10); setEditingEl(null); if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v); }; 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')}

) : (
{/* The READ_BANDS reply is undocumented and its 16-bit parse picks up structural bytes past the real data (varying by band), which made 6+ bogus "elements" appear. Ultrabeam beams are 3-element, and ModifyElement addresses elements 0..2 — so show just those. */} {lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
{elementName(i, t)} {editingEl === i ? ( setEditVal(e.target.value)} onBlur={() => commitEdit(i)} onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }} className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" /> ) : ( { setEditingEl(i); setEditVal(String(mm)); }}> {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); // 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); }; }, []); 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}
{/* 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 }); 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'; // 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) && (
)} {isDenkovi ? (
onChange({ ...device, host: e.target.value })} /> {denkoviSerials.map((s) =>

{t('station.ftdiHint')}

{detectMsg &&

{detectMsg}

}
) : isUsbRelay ? (

{t('station.usbRelayHint')}

) : (
onChange({ ...device, host: e.target.value })} />
{/* 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 })} />
)}
)}
{device.labels.map((lab, i) => ( { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} /> ))}
{testMsg && ( {testMsg.text} )}
); }