Files
OpsLog/internal/relaydev/serialrelay.go
T
rouggy fe69bc308c feat: Denkovi USB 4/8 (selectable) + generic USB-serial relay board (CH340/LCUS)
- Denkovi (FT245 D2XX) now selectable as 4 or 8 relays (a 4-relay board just uses
  the low nibble); NewDenkovi takes a count, driven by the device's Channels.
- New 'usbrelay' backend: cheap CH340/LCUS USB-serial boards over a COM port
  using the common A0 protocol ([0xA0][relay][state][checksum]); write-only, so
  Status is a shadow. Channel count configurable (1/2/4/8/16).
- StationDevice gains Channels; deviceRelayCount() drives label/relay counts for
  the variable-size types. Device editor: channel selector + COM-port picker
  (usbrelay) / FTDI-serial (denkovi).

Both untested on hardware; the A0 command set / Denkovi bit order may need a tweak
after a live test.
2026-07-20 17:23:38 +02:00

90 lines
2.3 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 }
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
}