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
+78 -4
View File
@@ -490,6 +490,8 @@ type App struct {
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change
relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off
relayDrvMu sync.Mutex // guards the cached relay drivers below
relayDrv map[string]cachedRelay // deviceID → live driver (reused across polls; stateful boards can't be reopened per call)
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
@@ -11433,8 +11435,15 @@ func relayCountFor(typ string) int {
}
}
// deviceDriver builds the wire driver for a configured device.
func deviceDriver(d StationDevice) relaydev.Device {
// cachedRelay is a live driver plus the config signature it was built from, so a
// changed device (new COM port, serial or channel count) rebuilds it.
type cachedRelay struct {
key string
dev relaydev.Device
}
// buildDeviceDriver builds a fresh wire driver for a configured device.
func buildDeviceDriver(d StationDevice) relaydev.Device {
switch d.Type {
case "kmtronic":
return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
@@ -11449,12 +11458,74 @@ func deviceDriver(d StationDevice) relaydev.Device {
}
}
// deviceKey is the config signature that, when unchanged, lets us reuse a device's
// open driver (and its OS handle) instead of rebuilding it every poll.
func deviceKey(d StationDevice) string {
return fmt.Sprintf("%s|%s|%s|%s|%d", d.Type, d.Host, d.User, d.Pass, deviceRelayCount(d))
}
// driverFor returns the cached, still-open driver for a device, building it once
// and reusing it thereafter. Stateful boards (Denkovi FTDI, USB-serial) hold an OS
// handle that only one opener can own, so rebuilding a driver every call — as the
// old code did — leaked the handle and made every poll after the first fail with
// "device in use", greying the relays out. Reuse fixes that.
func (a *App) driverFor(d StationDevice) relaydev.Device {
key := deviceKey(d)
a.relayDrvMu.Lock()
defer a.relayDrvMu.Unlock()
if a.relayDrv == nil {
a.relayDrv = map[string]cachedRelay{}
}
if c, ok := a.relayDrv[d.ID]; ok {
if c.key == key {
return c.dev
}
_ = c.dev.Close() // config changed → release the old handle before rebuilding
delete(a.relayDrv, d.ID)
}
dev := buildDeviceDriver(d)
a.relayDrv[d.ID] = cachedRelay{key: key, dev: dev}
return dev
}
// closeRelayDrivers closes and drops every cached driver (e.g. after the device
// list changes) so stale handles are released and rebuilt fresh on next use.
func (a *App) closeRelayDrivers() {
a.relayDrvMu.Lock()
defer a.relayDrvMu.Unlock()
for id, c := range a.relayDrv {
_ = c.dev.Close()
delete(a.relayDrv, id)
}
}
// ListDenkoviDevices returns the FTDI serial numbers of connected Denkovi/FTDI
// boards, for the settings picker. Windows-only (FTDI D2XX).
func (a *App) ListDenkoviDevices() ([]string, error) {
return relaydev.ListDenkovi()
}
// StationTestResult is the outcome of probing one relay board, for the settings
// dialog's Connect/Test button so the user gets a clear connected/failed message.
type StationTestResult struct {
OK bool `json:"ok"`
Relays int `json:"relays"`
Error string `json:"error,omitempty"`
}
// TestStationDevice opens the given board and reads its state, returning a clear
// success/failure so the settings UI can tell the user whether it's reachable.
// Reuses the cached driver (no double-open conflict with the live poll).
func (a *App) TestStationDevice(d StationDevice) StationTestResult {
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
defer cancel()
states, err := a.driverFor(d).Status(ctx)
if err != nil {
return StationTestResult{OK: false, Error: err.Error()}
}
return StationTestResult{OK: true, Relays: len(states)}
}
// GetStationDevices returns the configured relay boards (without live state).
func (a *App) GetStationDevices() []StationDevice {
out := []StationDevice{}
@@ -11497,6 +11568,9 @@ func (a *App) SaveStationDevices(devs []StationDevice) error {
if err != nil {
return err
}
// Release any open board handles so the new config reopens cleanly (a changed
// COM port / FTDI serial must not stay held by a stale driver).
a.closeRelayDrivers()
return a.settings.SetGlobal(a.ctx, keyStationDevices, string(b))
}
@@ -11532,7 +11606,7 @@ func (a *App) GetStationStatus() []StationDeviceStatus {
ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type}
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
defer cancel()
states, err := deviceDriver(d).Status(ctx)
states, err := a.driverFor(d).Status(ctx)
if err != nil {
ds.Error = err.Error()
} else {
@@ -11563,7 +11637,7 @@ func (a *App) StationSetRelay(id string, relay int, on bool) error {
if d.ID == id {
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
defer cancel()
return deviceDriver(d).Set(ctx, relay, on)
return a.driverFor(d).Set(ctx, relay, on)
}
}
return fmt.Errorf("station device %q not found", id)