From e99616a13527755bc86d0d8e29d8bb73172fcefc Mon Sep 17 00:00:00 2001 From: Oleksandr Berezovskyi Date: Sun, 24 May 2026 18:52:52 +0300 Subject: [PATCH] feat(ezcoo): serial driver for EZCOO HDMI matrix --- pkg/ezcoo/config.go | 35 ++++++++++ pkg/ezcoo/const.go | 9 +++ pkg/ezcoo/device.go | 162 ++++++++++++++++++++++++++++++++++++++++++++ pkg/ezcoo/ports.go | 20 ++++++ pkg/ezcoo/types.go | 19 ++++++ pkg/ezcoo/utils.go | 11 +++ 6 files changed, 256 insertions(+) create mode 100644 pkg/ezcoo/config.go create mode 100644 pkg/ezcoo/const.go create mode 100644 pkg/ezcoo/device.go create mode 100644 pkg/ezcoo/ports.go create mode 100644 pkg/ezcoo/types.go create mode 100644 pkg/ezcoo/utils.go diff --git a/pkg/ezcoo/config.go b/pkg/ezcoo/config.go new file mode 100644 index 0000000..d52cb72 --- /dev/null +++ b/pkg/ezcoo/config.go @@ -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 +} diff --git a/pkg/ezcoo/const.go b/pkg/ezcoo/const.go new file mode 100644 index 0000000..99d0cfd --- /dev/null +++ b/pkg/ezcoo/const.go @@ -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)`) diff --git a/pkg/ezcoo/device.go b/pkg/ezcoo/device.go new file mode 100644 index 0000000..bb5e14b --- /dev/null +++ b/pkg/ezcoo/device.go @@ -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() + } + } +} diff --git a/pkg/ezcoo/ports.go b/pkg/ezcoo/ports.go new file mode 100644 index 0000000..8f2e9a8 --- /dev/null +++ b/pkg/ezcoo/ports.go @@ -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 +} diff --git a/pkg/ezcoo/types.go b/pkg/ezcoo/types.go new file mode 100644 index 0000000..0ed441a --- /dev/null +++ b/pkg/ezcoo/types.go @@ -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), + } +} diff --git a/pkg/ezcoo/utils.go b/pkg/ezcoo/utils.go new file mode 100644 index 0000000..03cf941 --- /dev/null +++ b/pkg/ezcoo/utils.go @@ -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 +}