docs: add README and Home Assistant integration guide
Build deb packages on release / build-deb (release) Successful in 3m29s

This commit is contained in:
2026-05-24 18:53:37 +03:00
parent caaeef7341
commit e97475ed17
2 changed files with 417 additions and 0 deletions
+268
View File
@@ -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_<version>_<arch>.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>` | 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.
+149
View File
@@ -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
`<base_topic>/availability`.
## Discovery
On startup the bridge publishes a retained device-level discovery payload to:
```
<discovery_prefix>/device/ezcoo_matrix/config
```
With defaults that resolves to `homeassistant/device/ezcoo_matrix/config`.
You can inspect it with:
```sh
mosquitto_sub -h <broker> -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<N>/set`; the bridge then
issues the corresponding `EZS OUT<N> VS IN<M>` 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<N>/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).