fix: cache relay-board drivers so Denkovi/USB-serial boards stay connected

The station-control code rebuilt a fresh driver on every status poll and every
relay set. Stateful boards (Denkovi FTDI D2XX, USB-serial) hold an OS handle only
one opener can own, so the first poll opened the board and leaked the handle, and
every poll after failed with 'device in use' — the relays greyed out a second
after Save and auto-control never switched. Drivers are now cached per device and
reused, closed on config change. Adds a Test-connection button + detect feedback
in the device editor (reported by VK4MA).
This commit is contained in:
2026-07-21 09:31:08 +02:00
parent 615df0dc10
commit 880ecdbbb5
10 changed files with 174 additions and 14 deletions
@@ -12,7 +12,7 @@ import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts,
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -473,16 +473,33 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
// 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] });
} catch { setDenkoviSerials([]); }
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">
@@ -534,6 +551,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
</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">
@@ -581,9 +599,21 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
<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">
<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={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
</div>
</div>
</div>
);