From e97475ed1767025549504f4f3235da7a056d98ac Mon Sep 17 00:00:00 2001 From: Oleksandr Berezovskyi Date: Sun, 24 May 2026 18:53:37 +0300 Subject: [PATCH] docs: add README and Home Assistant integration guide --- README.md | 268 +++++++++++++++++++++++++++++++++++++++++ docs/home-assistant.md | 149 +++++++++++++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 README.md create mode 100644 docs/home-assistant.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f9a644 --- /dev/null +++ b/README.md @@ -0,0 +1,268 @@ +# ezcoo-usb-control + +> MQTT bridge for the **EZCOO EZ-MX42HAS-ARC** 4×2 HDMI matrix, with Home Assistant auto-discovery. + +`ezcoo-usb-control` is a small Go daemon that talks to an EZCOO HDMI matrix +over USB-UART using its ASCII command set and exposes the routing state to +an MQTT broker. It publishes Home Assistant MQTT discovery payloads so the +matrix shows up automatically as two `select` entities — one per output — +ready to be used in dashboards and automations. + +Built for, and running on, a Raspberry Pi 4, but the binary is plain Go and +works on any Linux/arm64, Linux/armhf or Linux/amd64 host that can see the +matrix as a serial device. + +--- + +## Table of contents + +- [Features](#features) +- [Hardware](#hardware) +- [Quick start](#quick-start) +- [Installation](#installation) + - [Debian / Raspberry Pi OS (.deb)](#debian--raspberry-pi-os-deb) + - [From source](#from-source) +- [Configuration](#configuration) +- [Running](#running) +- [MQTT topics](#mqtt-topics) +- [Home Assistant](#home-assistant) +- [Verifying the serial protocol](#verifying-the-serial-protocol) +- [Troubleshooting](#troubleshooting) +- [Project layout](#project-layout) +- [Development](#development) +- [Contributing](#contributing) +- [License](#license) +- [Acknowledgements](#acknowledgements) + +--- + +## Features + +- Two-way control of a 4-in / 2-out EZCOO HDMI matrix over USB-UART +- Home Assistant MQTT auto-discovery — zero manual entity wiring +- Periodic polling so external changes (front panel, IR remote) are reflected + in MQTT +- LWT-based availability (`online` / `offline`) +- Structured logging via `log/slog`, with `--debug` flag +- Hardened `systemd` unit and `.deb` packages for `arm64`, `armhf`, `amd64` + +## Hardware + +Tested with: + +- **EZCOO EZ-MX42HAS-ARC** (4 HDMI inputs, 2 HDMI outputs, ARC) — exposed + as a USB CDC-ACM serial port (typically `/dev/ttyACM0`, 57600 8N1) +- Raspberry Pi 4 (Raspberry Pi OS, 64-bit) as the host + +Other EZCOO matrices that share the same `EZG OUT0 VS` / `EZS OUTx VS INy` +ASCII protocol should work; if your unit speaks a slightly different dialect +see [Verifying the serial protocol](#verifying-the-serial-protocol). + +## Quick start + +```sh +# 1. Build a Raspberry Pi 4 (arm64) .deb on any Linux host with Go installed +make deb-arm64 VERSION=0.1.0 + +# 2. Copy and install on the Pi +scp dist/ezcoo-usb-control_0.1.0_arm64.deb pi@raspberrypi:~ +ssh pi@raspberrypi 'sudo apt install ./ezcoo-usb-control_0.1.0_arm64.deb' + +# 3. Configure and start +ssh pi@raspberrypi 'sudoedit /etc/ezcoo-usb-control/config.yaml' +ssh pi@raspberrypi 'sudo systemctl restart ezcoo-usb-control' +ssh pi@raspberrypi 'sudo journalctl -u ezcoo-usb-control -f' +``` + +## Installation + +### Debian / Raspberry Pi OS (.deb) + +Pre-built `.deb` packages are produced for each release; download the +matching architecture from the project's releases page and install: + +```sh +sudo apt install ./ezcoo-usb-control__.deb +``` + +The package installs: + +| Path | Purpose | +|---|---| +| `/usr/bin/ezcoo-usb-control` | Binary | +| `/etc/ezcoo-usb-control/config.yaml` | Default config (marked `conffile`) | +| `/lib/systemd/system/ezcoo-usb-control.service` | systemd unit | + +A dedicated `ezcoo` system user is created with `dialout` as a supplementary +group so the daemon can open the serial device without root. + +To build a `.deb` yourself for any supported architecture: + +```sh +make deb-arm64 VERSION=0.1.0 # Raspberry Pi 4 / 5 (64-bit) +make deb-armhf VERSION=0.1.0 # Raspberry Pi 2/3 / Zero 2 (32-bit) +make deb-amd64 VERSION=0.1.0 # x86_64 +make deb-all VERSION=0.1.0 # all three +``` + +Requires `dpkg-dev` on the build host (`sudo apt install dpkg-dev`). + +### From source + +```sh +# native build +make build +./build/ezcoo-usb-control --config cmd/ezcoo-usb-control/config.example.yaml + +# or directly with go +go build -o ezcoo-usb-control ./cmd/ezcoo-usb-control +``` + +Requires Go ≥ 1.26 (see `go.mod`). + +## Configuration + +Copy `cmd/ezcoo-usb-control/config.example.yaml` to `/etc/ezcoo-usb-control/config.yaml` +(the `.deb` package does this for you) and edit: + +```yaml +device: + port: /dev/ttyACM0 # serial device exposed by the matrix + baud: 57600 # EZCOO default + poll_interval: 15s # how often to query routing state + +mqtt: + broker: tcp://192.168.1.10:1883 + username: "" + password: "" + client_id: ezcoo-usb-control + base_topic: ezcoo # all state/set topics live under this + discovery_prefix: homeassistant # Home Assistant discovery root +``` + +All fields have sensible defaults — the minimum viable config is just +`mqtt.broker`. The config file path is passed via `--config`; if omitted, +defaults are used as-is. + +## Running + +```sh +ezcoo-usb-control --config /etc/ezcoo-usb-control/config.yaml +``` + +Flags: + +| Flag | Description | +|---|---| +| `--config ` | Path to YAML config (optional) | +| `--debug` | Enable debug-level logging | + +Under `systemd` the service is started with: + +```sh +sudo systemctl enable --now ezcoo-usb-control +sudo journalctl -u ezcoo-usb-control -f +``` + +To observe what the bridge publishes while running: + +```sh +mosquitto_sub -h localhost -t 'homeassistant/#' -v +mosquitto_sub -h localhost -t 'ezcoo/#' -v +``` + +## MQTT topics + +Topic names assume the default `base_topic: ezcoo` and `discovery_prefix: homeassistant`. + +| Topic | Direction | Payload | Description | +|---|---|---|---| +| `ezcoo/availability` | publish (retained, LWT) | `online` / `offline` | Bridge liveness | +| `ezcoo/output1/state` | publish (retained) | `IN1`..`IN4` | Current input routed to OUT1 | +| `ezcoo/output2/state` | publish (retained) | `IN1`..`IN4` | Current input routed to OUT2 | +| `ezcoo/output1/set` | subscribe | `IN1`..`IN4` | Switch OUT1 to the given input | +| `ezcoo/output2/set` | subscribe | `IN1`..`IN4` | Switch OUT2 to the given input | +| `homeassistant/device/ezcoo_matrix/config` | publish (retained) | JSON | HA device discovery payload | + +## Home Assistant + +If Home Assistant is connected to the same MQTT broker, the matrix appears +automatically as a single device with two `select` entities — no YAML +required. See [`docs/home-assistant.md`](docs/home-assistant.md) for +discovery details, example automations (including a "mirror both outputs" +recipe), and a Lovelace card snippet. + +## Verifying the serial protocol + +Before first deployment, probe the exact command set of your unit: + +```sh +picocom -b 57600 /dev/ttyACM0 +# then type: EZH (dumps all supported commands) +# then type: EZG OUT0 VS (shows current routing of all outputs) +``` + +The bridge expects responses in the form `OUT1 VS IN3`. If your unit +replies differently, adjust the `reOutVS` regular expression in +[`pkg/ezcoo/utils.go`](pkg/ezcoo/utils.go). + +## Troubleshooting + +- **`permission denied: /dev/ttyACM0`** — the `ezcoo` user must be in the + group that owns the device (`dialout` on Debian/RPi OS). The `.deb` adds + this automatically; for custom installs, do it manually. +- **Entities don't appear in Home Assistant** — confirm the discovery prefix + matches HA's MQTT integration setting (default `homeassistant`), and that + the broker shows a retained payload on `homeassistant/device/ezcoo_matrix/config`. +- **State never changes** — run with `--debug` and watch for `state update` + log lines. If polling never returns matches, the regex likely needs + adjusting (see above). +- **Bridge reports `offline` after connecting** — check that the systemd + unit has access to the serial char device; the unit ships with + `DeviceAllow=char-ttyACM rw` and `DeviceAllow=char-ttyUSB rw`. + +## Project layout + +``` +. +├── cmd/ezcoo-usb-control/ # main entrypoint, config loader, example config +├── pkg/ +│ ├── bridge/ # MQTT side: connect, subscribe, publish, HA discovery +│ └── ezcoo/ # serial side: device driver, protocol, polling loop +├── packaging/ +│ ├── DEBIAN/ # control, postinst, prerm, postrm, conffiles +│ └── systemd/ # hardened service unit +├── .gitea/workflows/ # CI: build .deb packages on release +└── Makefile # build + multi-arch .deb targets +``` + +## Development + +```sh +go build ./... +go vet ./... +go test ./... +``` + +## Contributing + +Issues and pull requests are welcome. For non-trivial changes please open +an issue first so the design can be discussed. When submitting a PR: + +1. Run `go vet ./...` and `go test ./...`. +2. Keep changes focused and the commit history clean. +3. Update this README if you add a flag, change a topic, or alter the + config schema. + +## License + +Released under the [MIT License](LICENSE.md) + +## Acknowledgements + +- The EZCOO ASCII command set documented in the EZ-MX42HAS-ARC manual. +- [`paho.mqtt.golang`](https://github.com/eclipse/paho.mqtt.golang) for the MQTT client. +- [`go.bug.st/serial`](https://github.com/bugst/go-serial) for cross-platform serial I/O. +- Home Assistant's + [MQTT discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery) + schema. diff --git a/docs/home-assistant.md b/docs/home-assistant.md new file mode 100644 index 0000000..8912a8d --- /dev/null +++ b/docs/home-assistant.md @@ -0,0 +1,149 @@ +# Home Assistant integration + +`ezcoo-usb-control` publishes an MQTT discovery payload, so the matrix +appears in Home Assistant as a single device with two `select` entities — +one per HDMI output — without any YAML. + +- [Prerequisites](#prerequisites) +- [Discovery](#discovery) +- [Entities](#entities) +- [Example automations](#example-automations) + - [Route Apple TV to the living-room TV](#route-apple-tv-to-the-living-room-tv) + - [Mirror both outputs to the same input](#mirror-both-outputs-to-the-same-input) +- [Lovelace card](#lovelace-card) +- [Troubleshooting](#troubleshooting) + +## Prerequisites + +- Home Assistant is connected to the **same MQTT broker** that the bridge + is configured to use. +- The + [MQTT integration](https://www.home-assistant.io/integrations/mqtt/) is + enabled and its **Discovery prefix** matches `mqtt.discovery_prefix` in + the bridge config (default: `homeassistant`). +- `ezcoo-usb-control` is running and reports `online` on + `/availability`. + +## Discovery + +On startup the bridge publishes a retained device-level discovery payload to: + +``` +/device/ezcoo_matrix/config +``` + +With defaults that resolves to `homeassistant/device/ezcoo_matrix/config`. +You can inspect it with: + +```sh +mosquitto_sub -h -t 'homeassistant/device/ezcoo_matrix/config' -v +``` + +Home Assistant picks the payload up automatically and creates the device. +Removing the retained message (or stopping the bridge with the broker +configured to drop retained messages) makes the entities disappear. + +## Entities + +By default the bridge registers a single device, **EZCOO HDMI Matrix**, with +two entities: + +| Entity ID | Type | Options | Source topic | +|---|---|---|---| +| `select.ezcoo_hdmi_matrix_output_1` | `select` | `IN1`, `IN2`, `IN3`, `IN4` | `ezcoo/output1/state` | +| `select.ezcoo_hdmi_matrix_output_2` | `select` | `IN1`, `IN2`, `IN3`, `IN4` | `ezcoo/output2/state` | + +Setting an option publishes to `ezcoo/output/set`; the bridge then +issues the corresponding `EZS OUT VS IN` command to the matrix and +re-publishes the confirmed state. + +> Entity IDs can be renamed in the Home Assistant UI. The examples below +> use the default slugs — adjust if you renamed yours. + +## Example automations + +### Route Apple TV to the living-room TV + +Switch output 1 to input 2 whenever the Apple TV starts playing: + +```yaml +- alias: "Route Apple TV to living-room TV" + trigger: + - platform: state + entity_id: media_player.apple_tv + to: "playing" + action: + - service: select.select_option + target: + entity_id: select.ezcoo_hdmi_matrix_output_1 + data: + option: "IN2" +``` + +### Mirror both outputs to the same input + +Whenever either output is changed (via Home Assistant, the front panel, or +the IR remote), this automation routes the *other* output to the same +input — so both displays always show the same source. The template +condition prevents a feedback loop when the second `select` is already in +sync. + +```yaml +alias: EZCOO HDMI Matrix — sync outputs +description: "" +triggers: + - trigger: state + entity_id: + - select.ezcoo_hdmi_matrix_output_1 + - select.ezcoo_hdmi_matrix_output_2 + not_to: + - unknown + - unavailable +conditions: [] +actions: + - variables: + target: |- + {% if trigger.entity_id == 'select.ezcoo_hdmi_matrix_output_1' %} + select.ezcoo_hdmi_matrix_output_2 + {% else %} + select.ezcoo_hdmi_matrix_output_1 + {% endif %} + - condition: template + value_template: "{{ states(target) != trigger.to_state.state }}" + - action: select.select_option + target: + entity_id: "{{ target }}" + data: + option: "{{ trigger.to_state.state }}" +mode: single +max_exceeded: silent +``` + +## Lovelace card + +A minimal Lovelace entities card: + +```yaml +type: entities +title: HDMI Matrix +entities: + - entity: select.ezcoo_hdmi_matrix_output_1 + name: Output 1 + - entity: select.ezcoo_hdmi_matrix_output_2 + name: Output 2 +``` + +## Troubleshooting + +- **Entities don't appear.** Confirm the broker has a retained payload on + `homeassistant/device/ezcoo_matrix/config`, and that Home Assistant's + MQTT integration uses the same discovery prefix. +- **Entities are stuck on `unavailable`.** The bridge publishes `offline` + to `ezcoo/availability` as its Last Will. Check `journalctl -u ezcoo-usb-control` + for connection errors. +- **Changing a `select` does nothing.** Watch `ezcoo/output/set` with + `mosquitto_sub` — if the message arrives, the failure is on the serial + side; run the bridge with `--debug` to see the command exchange. +- **Automations fire in a loop.** When reacting to `select` state changes, + always guard with a template condition that compares current vs target + state (see the *sync outputs* example above).