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).
103 lines
2.5 KiB
Go
103 lines
2.5 KiB
Go
package relaydev
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
"go.bug.st/serial"
|
|
)
|
|
|
|
// serialRelay drives the common cheap USB-serial relay boards (CH340 / LCUS-1
|
|
// style) that use the "A0" command protocol over a virtual COM port:
|
|
//
|
|
// [0xA0] [relay 1-based] [state 0|1] [checksum] checksum = (0xA0+relay+state) & 0xFF
|
|
//
|
|
// e.g. relay 1 ON = A0 01 01 A2, relay 1 OFF = A0 01 00 A1. These boards are
|
|
// write-only (no state readback), so Status reflects a shadow of what we sent.
|
|
// Channel count varies by board (1/2/4/8/16), so it is configurable.
|
|
type serialRelay struct {
|
|
portName string
|
|
count int
|
|
baud int
|
|
mu sync.Mutex
|
|
port serial.Port
|
|
shadow []bool
|
|
}
|
|
|
|
// NewSerialRelay builds a driver for a CH340/LCUS-style USB-serial relay board on
|
|
// the given COM port with `count` channels (defaults to 8).
|
|
func NewSerialRelay(port string, count int) Device {
|
|
if count < 1 {
|
|
count = 8
|
|
}
|
|
return &serialRelay{portName: strings.TrimSpace(port), count: count, baud: 9600, shadow: make([]bool, count)}
|
|
}
|
|
|
|
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
|
|
}
|
|
if s.portName == "" {
|
|
return fmt.Errorf("no COM port set for the USB relay board")
|
|
}
|
|
p, err := serial.Open(s.portName, &serial.Mode{BaudRate: s.baud})
|
|
if err != nil {
|
|
return fmt.Errorf("open %s: %w", s.portName, err)
|
|
}
|
|
s.port = p
|
|
return nil
|
|
}
|
|
|
|
func (s *serialRelay) Set(ctx context.Context, relay int, on bool) error {
|
|
if relay < 1 || relay > s.count {
|
|
return fmt.Errorf("relay %d out of range 1..%d", relay, s.count)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if err := s.ensureOpen(); err != nil {
|
|
return err
|
|
}
|
|
st := byte(0)
|
|
if on {
|
|
st = 1
|
|
}
|
|
r := byte(relay)
|
|
frame := []byte{0xA0, r, st, byte(0xA0) + r + st} // last byte = checksum
|
|
if _, err := s.port.Write(frame); err != nil {
|
|
// Drop the handle so the next call reopens (USB unplugged / port reset).
|
|
s.port.Close()
|
|
s.port = nil
|
|
return fmt.Errorf("write to %s: %w", s.portName, err)
|
|
}
|
|
s.shadow[relay-1] = on
|
|
return nil
|
|
}
|
|
|
|
func (s *serialRelay) Status(ctx context.Context) ([]bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if err := s.ensureOpen(); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]bool, s.count)
|
|
copy(out, s.shadow)
|
|
return out, nil
|
|
}
|