feat(ezcoo): serial driver for EZCOO HDMI matrix
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
package ezcoo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string `yaml:"port"`
|
||||||
|
Baud int `yaml:"baud"`
|
||||||
|
PollInterval time.Duration `yaml:"poll_interval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) SetDefaults() {
|
||||||
|
if c.Port == "" {
|
||||||
|
c.Port = "/dev/ttyACM0"
|
||||||
|
}
|
||||||
|
if c.Baud == 0 {
|
||||||
|
c.Baud = 57600
|
||||||
|
}
|
||||||
|
if c.PollInterval == 0 {
|
||||||
|
c.PollInterval = 15 * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if c.Port == "" {
|
||||||
|
return errors.New("device.port is required")
|
||||||
|
}
|
||||||
|
if c.Baud <= 0 {
|
||||||
|
return fmt.Errorf("device.baud must be positive, got %d", c.Baud)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ezcoo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const pollCmd = "EZG OUT0 VS"
|
||||||
|
|
||||||
|
var reOutVS = regexp.MustCompile(`(?i)OUT(\d)\s+VS\s+IN(\d)`)
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package ezcoo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (d *Device) GetStatus() State {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
return d.current
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) SetOutput(out, in int) {
|
||||||
|
cmd := fmt.Sprintf("EZS OUT%d VS IN%d", out, in)
|
||||||
|
select {
|
||||||
|
case d.cmds <- cmd:
|
||||||
|
default:
|
||||||
|
slog.Warn("command queue full, dropping", "cmd", cmd)
|
||||||
|
}
|
||||||
|
d.poll()
|
||||||
|
time.AfterFunc(time.Second, d.poll)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) poll() {
|
||||||
|
select {
|
||||||
|
case d.cmds <- pollCmd:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) Run(onState func(State), onAvailable func(bool), done <-chan struct{}) {
|
||||||
|
if d.cfg.PollInterval > 0 {
|
||||||
|
go d.pollLoop(done)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
port, err := Open(d.cfg)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("open serial", "err", err, "retry_in", "5s")
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("serial connected", "port", d.cfg.Port)
|
||||||
|
onAvailable(true)
|
||||||
|
d.poll()
|
||||||
|
|
||||||
|
d.runPort(port, onState, done)
|
||||||
|
onAvailable(false)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
slog.Warn("serial disconnected, reconnecting in 5s")
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) runPort(port serial.Port, onState func(State), done <-chan struct{}) {
|
||||||
|
defer port.Close()
|
||||||
|
|
||||||
|
reader := bufio.NewReader(port)
|
||||||
|
lineCh := make(chan string, 32)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("serial read", "err", err)
|
||||||
|
close(lineCh)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case lineCh <- line:
|
||||||
|
case <-done:
|
||||||
|
close(lineCh)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
collectUntil := time.Time{}
|
||||||
|
|
||||||
|
for {
|
||||||
|
var collectCh <-chan time.Time
|
||||||
|
if !collectUntil.IsZero() {
|
||||||
|
collectCh = time.After(time.Until(collectUntil))
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
|
||||||
|
case cmd, ok := <-d.cmds:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Debug("serial tx", "cmd", cmd)
|
||||||
|
if _, err := port.Write([]byte(cmd + "\r\n")); err != nil {
|
||||||
|
slog.Error("serial write", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(cmd, "EZG OUT") {
|
||||||
|
collectUntil = time.Now().Add(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
case line, ok := <-lineCh:
|
||||||
|
if !ok {
|
||||||
|
slog.Error("serial reader closed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.applyLine(line)
|
||||||
|
|
||||||
|
case <-collectCh:
|
||||||
|
collectUntil = time.Time{}
|
||||||
|
onState(d.GetStatus())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) applyLine(line string) {
|
||||||
|
slog.Debug("serial rx", "line", line)
|
||||||
|
m := reOutVS.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, _ := parseInt(m[1])
|
||||||
|
in, _ := parseInt(m[2])
|
||||||
|
if out >= 1 && out <= 2 && in >= 1 && in <= 4 {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.current[out-1] = in
|
||||||
|
d.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) pollLoop(done <-chan struct{}) {
|
||||||
|
t := time.NewTicker(d.cfg.PollInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
d.poll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package ezcoo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Open(cfg Config) (serial.Port, error) {
|
||||||
|
mode := &serial.Mode{BaudRate: cfg.Baud}
|
||||||
|
p, err := serial.Open(cfg.Port, mode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open serial %s: %w", cfg.Port, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the device time to initialise after USB-serial reset.
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package ezcoo
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type State [2]int
|
||||||
|
|
||||||
|
type Device struct {
|
||||||
|
cfg Config
|
||||||
|
cmds chan string
|
||||||
|
mu sync.RWMutex
|
||||||
|
current State
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDevice(cfg Config) *Device {
|
||||||
|
return &Device{
|
||||||
|
cfg: cfg,
|
||||||
|
cmds: make(chan string, 8),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package ezcoo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseInt(s string) (int, error) {
|
||||||
|
var n int
|
||||||
|
_, err := fmt.Sscanf(s, "%d", &n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user