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
+2 -1
View File
@@ -20,7 +20,8 @@ func NewDenkovi(serial string, count int) Device {
return denkoviStub{count: count}
}
func (s denkoviStub) Count() int { return s.count }
func (s denkoviStub) Count() int { return s.count }
func (denkoviStub) Close() error { return nil }
func (denkoviStub) Status(context.Context) ([]bool, error) {
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
}
+13
View File
@@ -121,6 +121,19 @@ func (d *denkovi) Set(ctx context.Context, relay int, on bool) error {
return d.writeLocked()
}
// Close releases the FTDI handle so the board can be reopened later (only one
// handle may hold a D2XX device at a time).
func (d *denkovi) Close() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.opened {
procClose.Call(d.h)
d.opened = false
d.h = 0
}
return nil
}
func (d *denkovi) Status(ctx context.Context) ([]bool, error) {
d.mu.Lock()
defer d.mu.Unlock()
+9 -2
View File
@@ -27,6 +27,11 @@ type Device interface {
Count() int // number of user-controllable relays
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
Set(ctx context.Context, relay int, on bool) error // relay is 1-based
// Close releases any OS handle the driver holds (serial port, FTDI handle).
// Network boards hold nothing and no-op. MUST be called when a cached driver is
// discarded so the port/handle is freed for the next open — stateful boards
// (Denkovi, USB-serial) can only be opened by one handle at a time.
Close() error
}
func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} }
@@ -62,7 +67,8 @@ type webswitch struct {
// NewWebswitch builds a WebSwitch 1216H client (5 relays).
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }
func (w *webswitch) Count() int { return w.count }
func (w *webswitch) Count() int { return w.count }
func (w *webswitch) Close() error { return nil } // stateless HTTP, nothing to release
func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > w.count {
@@ -118,7 +124,8 @@ func NewKMTronic(host, user, pass string) Device {
return &kmtronic{host: host, user: user, pass: pass, count: 8}
}
func (k *kmtronic) Count() int { return k.count }
func (k *kmtronic) Count() int { return k.count }
func (k *kmtronic) Close() error { return nil } // stateless HTTP, nothing to release
func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > k.count {
+13
View File
@@ -37,6 +37,19 @@ func NewSerialRelay(port string, count int) Device {
func (s *serialRelay) Count() int { return s.count }
// Close releases the COM port so it can be reopened later (only one handle may
// hold a serial port at a time).
func (s *serialRelay) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.port != nil {
err := s.port.Close()
s.port = nil
return err
}
return nil
}
func (s *serialRelay) ensureOpen() error {
if s.port != nil {
return nil