792 lines
43 KiB
TypeScript
792 lines
43 KiB
TypeScript
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 (
|
||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||
<Power className="size-4 text-primary" />
|
||
<div className="text-sm font-semibold truncate">{t('psu.title')}</div>
|
||
<span className={cn('ml-auto size-2 rounded-full shrink-0', st.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||
title={st.connected ? t('station.online') : (st.error || t('psu.offline'))} />
|
||
</div>
|
||
<div className="p-3 space-y-2">
|
||
<div className="flex items-center gap-3">
|
||
<button type="button" disabled={!st.connected || busy}
|
||
onClick={() => onToggle(!st.on)}
|
||
className={cn('flex items-center gap-2 rounded-md border px-3 py-1.5 transition-colors disabled:opacity-40',
|
||
st.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||
<span className={cn('flex items-center justify-center size-6 rounded shrink-0',
|
||
st.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
|
||
</span>
|
||
<span className="text-xs font-semibold">{t('psu.output')}</span>
|
||
<span className={cn('text-[10px] font-bold', st.on ? 'text-success' : 'text-muted-foreground/60')}>
|
||
{st.on ? t('station.on') : t('station.off')}
|
||
</span>
|
||
</button>
|
||
<div className="flex-1 min-w-0 font-mono tabular-nums text-right">
|
||
<span className="text-lg font-bold">{(st.volts ?? 0).toFixed(2)}</span><span className="text-xs text-muted-foreground"> V</span>
|
||
<span className="text-lg font-bold ml-3">{(st.amps ?? 0).toFixed(3)}</span><span className="text-xs text-muted-foreground"> A</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between text-[11px] text-muted-foreground font-mono">
|
||
<span>{(st.watts ?? 0).toFixed(1)} W</span>
|
||
<span>{t('psu.setTo')} {(st.set_volts ?? 0).toFixed(2)} V / {(st.set_amps ?? 0).toFixed(3)} A</span>
|
||
</div>
|
||
{tripped && (
|
||
<div className="text-[11px] font-bold text-danger">{t('psu.tripped')} (0x{(st.protected ?? 0).toString(16).toUpperCase()})</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2, httpgen: 4 };
|
||
const TYPE_LABEL: Record<string, string> = { 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 (
|
||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||
<Compass className="size-4 text-primary" />
|
||
<div className="text-sm font-semibold">{t('station.rotator')}</div>
|
||
<span className={cn('ml-auto size-2 rounded-full', hd.ok ? 'bg-success' : 'bg-warning')}
|
||
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||
</div>
|
||
<div className="p-3 flex gap-4 items-start">
|
||
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
|
||
app carries it rather than each caller drawing its own. */}
|
||
<RotorCompass
|
||
bearing={bearing ?? null}
|
||
headings={hd.ok ? [hd.azimuth] : []}
|
||
centerLat={centerLat ?? null}
|
||
centerLon={centerLon ?? null}
|
||
rotorEnabled={hd.ok}
|
||
rotors={hd.rotors}
|
||
activeRotor={hd.active}
|
||
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||
onGoto={(az) => turn(az)}
|
||
/>
|
||
<div className="flex-1 min-w-0 space-y-2">
|
||
<div className="font-mono">
|
||
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||
</div>
|
||
<div className="flex gap-1">
|
||
{presets.map(([lbl, az]) => (
|
||
<button key={lbl} type="button" onClick={() => turn(az)}
|
||
className="flex-1 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:bg-muted">
|
||
{lbl}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex gap-1.5">
|
||
<Input value={goto} onChange={(e) => 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" />
|
||
<Button size="sm" className="h-8 shrink-0" disabled={!goto} onClick={() => turn(parseInt(goto, 10))}>{t('station.go')}</Button>
|
||
</div>
|
||
<Button size="sm" variant="outline" className="h-8 w-full" onClick={() => RotatorStop().then(poll).catch((e) => setErr(String(e?.message ?? e)))}>
|
||
<Square className="size-3 mr-1" /> {t('station.stop')}
|
||
</Button>
|
||
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||
const { t } = useI18n();
|
||
const [devices, setDevices] = useState<Device[]>([]);
|
||
const [status, setStatus] = useState<Record<string, DevStatus>>({});
|
||
const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
|
||
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
|
||
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
|
||
const [ant, setAnt] = useState<AntStatus>({ 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<string[]>(() => {
|
||
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
||
});
|
||
const dragId = useRef<string | null>(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<string>(() => 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<HTMLDivElement | null>(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<any[]>([]);
|
||
const [flexState, setFlexState] = useState<any>(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<TGStatus>({ 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<PSUState>({ 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 (
|
||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||
<PlugZap className="size-4 text-primary" />
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
|
||
<div className="text-[10px] text-muted-foreground font-mono truncate">
|
||
{TYPE_LABEL[dev.type]}{fireAndForget || !dev.host ? '' : ` · ${dev.host}`}
|
||
</div>
|
||
</div>
|
||
{fireAndForget ? <span className="ml-auto" /> : (
|
||
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
|
||
)}
|
||
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
|
||
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
|
||
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
||
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
|
||
</div>
|
||
{/* 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. */}
|
||
<div className="p-2 flex flex-wrap gap-1.5">
|
||
{relays.map((r) => {
|
||
const key = `${dev.id}:${r.number}`;
|
||
const label = r.label || `${t('station.relay')} ${r.number}`;
|
||
return (
|
||
<button key={r.number} type="button" disabled={!fireAndForget && !st?.connected}
|
||
title={label}
|
||
onClick={() => toggle(dev, r.number, !r.on)}
|
||
className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors disabled:opacity-40',
|
||
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||
<span className={cn('flex items-center justify-center size-5 rounded shrink-0',
|
||
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||
{busy[key] ? <Loader2 className="size-3 animate-spin" /> : <Power className="size-3" />}
|
||
</span>
|
||
<span className="flex-1 min-w-0 text-xs font-medium truncate">{label}</span>
|
||
<span className={cn('text-[9px] font-bold shrink-0', r.on ? 'text-success' : 'text-muted-foreground/50')}>
|
||
{r.on ? t('station.on') : t('station.off')}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// `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: <RotatorWidget hd={rot} refetch={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||
}
|
||
if (ant.enabled) {
|
||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||
}
|
||
// One card per configured amplifier (identical to the Flex panel's card).
|
||
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
|
||
// Tuner Genius XL card (identical to the Flex panel's).
|
||
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
|
||
if (psuEnabled) widgets.push({ id: 'psu', node: <PSUCard st={psu} busy={psuBusy} onToggle={togglePSU} t={t} /> });
|
||
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 (
|
||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
|
||
<div className="flex items-center gap-2">
|
||
{/* 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. */}
|
||
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||
{(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => (
|
||
<button key={c} type="button"
|
||
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||
{c === 'auto' ? t('station.colsAuto') : c}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
|
||
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{noDevices && !editing && (
|
||
<div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||
{t('station.empty')}
|
||
</div>
|
||
)}
|
||
|
||
{/* 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. */}
|
||
<div ref={gridRef} style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
|
||
gap: `${GRID_GAP}px`,
|
||
}}>
|
||
{ordered.map((w) => (
|
||
<div key={w.id}
|
||
// A wide card takes two columns — but never more than there are, or
|
||
// the grid would grow a phantom column on a narrow window.
|
||
style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
|
||
onDragOver={(e) => { 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. */}
|
||
<div className="flex items-stretch h-full">
|
||
<div draggable
|
||
onDragStart={(e) => { 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')}>
|
||
<GripVertical className="size-4" />
|
||
</div>
|
||
<div className="flex-1 min-w-0 [&>*]:h-full">{w.node}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{!noDevices && (
|
||
<p className="text-[11px] text-muted-foreground mt-2">{t('station.dragHint')}</p>
|
||
)}
|
||
|
||
{editing && (
|
||
<DeviceEditor device={editing} onChange={setEditing} onSave={saveEdit} onCancel={() => setEditing(null)} t={t} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<HTMLDivElement>(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<string[]>([]);
|
||
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<string[]>([]);
|
||
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 (
|
||
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
|
||
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('station.type')}</Label>
|
||
<Select value={device.type} onValueChange={setType}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||
<SelectItem value="dingtian">Dingtian IOT relay (LAN / WiFi)</SelectItem>
|
||
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
||
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
|
||
<SelectItem value="httpgen">{t('station.typeHttpGen')}</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.name')}</Label>
|
||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
{(isDenkovi || isUsbRelay || isDingtian || isHTTPGen) && (
|
||
<div className="space-y-1 max-w-[10rem]">
|
||
<Label>{t('station.channels')}</Label>
|
||
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{(isDenkovi ? [4, 8] : isDingtian ? [2, 4, 8, 16, 24, 32] : [1, 2, 4, 8, 16]).map((n) => (
|
||
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
{isDenkovi ? (
|
||
<div className="space-y-1 max-w-md">
|
||
<Label>{t('station.ftdiSerial')}</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Input className="font-mono flex-1" value={device.host} placeholder="DAE0006K"
|
||
list="denkovi-serials"
|
||
onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||
<datalist id="denkovi-serials">
|
||
{denkoviSerials.map((s) => <option key={s} value={s} />)}
|
||
</datalist>
|
||
<Button size="sm" variant="outline" onClick={detectDenkovi} disabled={detecting}>
|
||
{detecting ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||
{t('station.detect')}
|
||
</Button>
|
||
</div>
|
||
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
|
||
{detectMsg && <p className="text-[11px] font-medium text-muted-foreground">{detectMsg}</p>}
|
||
</div>
|
||
) : isUsbRelay ? (
|
||
<div className="space-y-1 max-w-md">
|
||
<Label>{t('station.comPort')}</Label>
|
||
<div className="flex items-center gap-2">
|
||
<Select value={device.host || '_'} onValueChange={(v) => onChange({ ...device, host: v === '_' ? '' : v })}>
|
||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||
<SelectContent>
|
||
{serialPorts.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||
{serialPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => {})}>
|
||
<RefreshCw className="size-3.5" />
|
||
</Button>
|
||
</div>
|
||
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||
</div>
|
||
) : 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. */
|
||
<div className={cn('grid gap-3', (isKM || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
|
||
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
|
||
<Label>{t('station.host')}</Label>
|
||
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||
<span className="text-[10px] text-muted-foreground">{t('station.hostHint')}</span>
|
||
</div>
|
||
{/* 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 && (
|
||
<>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.dtSession')}</Label>
|
||
<Input className="font-mono" value={device.user ?? ''} placeholder={t('station.optional')}
|
||
onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.dtPwd')}</Label>
|
||
<Input className="font-mono" value={device.pass ?? ''} placeholder="0"
|
||
onChange={(e) => onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
|
||
</div>
|
||
</>
|
||
)}
|
||
{isKM && (
|
||
<>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.user')}</Label>
|
||
<Input value={device.user ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.pass')}</Label>
|
||
<Input type="password" value={device.pass ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, pass: e.target.value })} />
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* 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 && (
|
||
<div className="space-y-3 rounded-md border border-border/60 p-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('station.onPattern')}</Label>
|
||
<Input className="font-mono text-xs" placeholder="http://192.168.1.9/relay?n={relay}&state=on"
|
||
value={device.on_pattern ?? ''}
|
||
onChange={(e) => onChange({ ...device, on_pattern: e.target.value })} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('station.offPattern')}</Label>
|
||
<Input className="font-mono text-xs" placeholder="http://192.168.1.9/relay?n={relay}&state=off"
|
||
value={device.off_pattern ?? ''}
|
||
onChange={(e) => onChange({ ...device, off_pattern: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground">{t('station.patternHint')}</div>
|
||
{/* 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 && (
|
||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||
<Checkbox className="mt-0.5" checked={!!device.insecure_tls}
|
||
onCheckedChange={(c) => onChange({ ...device, insecure_tls: !!c })} />
|
||
<span>
|
||
{t('station.insecureTls')}
|
||
<span className="block text-[10px] text-muted-foreground">{t('station.insecureTlsHint')}</span>
|
||
</span>
|
||
</label>
|
||
)}
|
||
<div className="space-y-1">
|
||
<Label>{t('station.perRelayUrls')}</Label>
|
||
<div className="space-y-1">
|
||
{device.labels.map((_, i) => (
|
||
<div key={i} className="grid grid-cols-[2.5rem_1fr_1fr] items-center gap-2">
|
||
<span className="text-[11px] text-muted-foreground">{i + 1}</span>
|
||
<Input className="h-8 font-mono text-[11px]" placeholder={t('station.onUrlPh')}
|
||
value={device.on_urls?.[i] ?? ''}
|
||
onChange={(e) => {
|
||
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 });
|
||
}} />
|
||
<Input className="h-8 font-mono text-[11px]" placeholder={t('station.offUrlPh')}
|
||
value={device.off_urls?.[i] ?? ''}
|
||
onChange={(e) => {
|
||
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 });
|
||
}} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground">{t('station.perRelayHint')}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-1">
|
||
<Label>{t('station.labels')}</Label>
|
||
{/* {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 && <p className="text-[10px] text-warning">{t('station.valueNeedsLabels')}</p>}
|
||
<div className="grid grid-cols-4 gap-2">
|
||
{device.labels.map((lab, i) => (
|
||
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
|
||
onChange={(e) => { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{testMsg && (
|
||
<span className={cn('inline-flex items-center gap-1.5 text-xs font-medium', testMsg.ok ? 'text-success' : 'text-danger')}>
|
||
<span className={cn('size-2 rounded-full', testMsg.ok ? 'bg-success' : 'bg-danger')} />
|
||
{testMsg.text}
|
||
</span>
|
||
)}
|
||
<div className="ml-auto flex gap-2">
|
||
{/* 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 && (
|
||
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
||
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||
{t('station.test')}
|
||
</Button>
|
||
)}
|
||
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||
<Button size="sm" onClick={onSave} disabled={!isHTTPGen && !device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|