feat(psu): switch a Modbus RTU bench supply from OpsLog

The manufacturer's document arrived, so this is no longer guesswork: 9600 8N1,
function codes 03 and 06 only, and a register map with the output on/off at
0x0001, the measurements at 0x0010…0x0013 and the set points at 0x0030/0x0031.

ONE REGISTER IS WRITTEN — 0x0001, the output. The map also exposes the voltage
and current set points and the three protection trip levels as writable, and
none of them belong to a logbook: a wrong value there is 30 V where a radio
expected 13.8, or a trip level lifted on a supply feeding an amplifier. They are
read and displayed instead, next to the measured values, which is also how an
operator sees at a glance that the supply is on and the radio is drawing nothing.

The wire layer is tested where it can be. CRC-16/MODBUS is pinned against its
published check value — the CRC of "123456789" is 0x4B37 — which fixes the
polynomial, the initial value, the reflection and the absence of a final xor all
at once; the rest of the protocol is checked frame by frame against the manual,
including the byte order of the CRC, an exception reply told apart from a broken
line, and a reply from another slave on the bus refused. The write echo must
match the value sent: it is the only confirmation the output really switched,
and accepting the frame without it is how a radio ends up dark behind a green
light.

Framing follows the manual's own rules: 3.5 character times of silence between
frames (4 ms at 9600), and a frame is over when the line falls quiet — Modbus
RTU has no terminator, and a serial read that times out returns (0, nil) here,
so the reader is built on a deadline and a quiet-time rather than on an error
that never comes.

Untested against hardware — nobody here has the supply.
This commit is contained in:
2026-08-16 13:15:55 +02:00
parent 9f8e3c73d9
commit 8683a450a7
12 changed files with 1038 additions and 4 deletions
@@ -19,10 +19,64 @@ import {
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[];
@@ -457,6 +511,32 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
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 */ }
}, []);
@@ -594,6 +674,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
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; };