First commit
This commit is contained in:
commit
a61031f5ef
21 changed files with 5377 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Local config holds secrets (MQTT password); never commit it.
|
||||
config.yaml
|
||||
|
||||
# Built binary.
|
||||
/lutron-control
|
||||
|
||||
# Logs.
|
||||
*.log
|
||||
*.log.gz
|
||||
19
LICENSE
Normal file
19
LICENSE
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
Copyright (c) 2026 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
445
README.md
Normal file
445
README.md
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
# lutron-control
|
||||
|
||||
Control a **Lutron GRAFIK Eye QS** lighting unit from two independent worlds at once:
|
||||
|
||||
- **A lighting board / theatrical software** — run the GRAFIK Eye's zones as part
|
||||
of a larger light show.
|
||||
- **Home Assistant** — control and automate the lights like any other smart light
|
||||
in your house.
|
||||
|
||||
It runs as a small always-on program on a computer wired (or networked) to the
|
||||
GRAFIK Eye: it listens for commands and translates them into the GRAFIK Eye's own
|
||||
integration language. A **Raspberry Pi** is the cheap, common choice this guide
|
||||
walks through, but any Linux/macOS/Windows machine works. Every control method is
|
||||
independent and optional — enable only the ones you need.
|
||||
|
||||
> **New to the jargon?** Here's the short version:
|
||||
> - **GRAFIK Eye QS** — the Lutron lighting control unit this drives.
|
||||
> - **QSE-CI-NWK-E** — the add-on module on the GRAFIK Eye that gives it a serial
|
||||
> port (and a network/telnet interface) we can talk to.
|
||||
> - **DMX / sACN (E1.31) / Art-Net** — the standard "languages" lighting boards and
|
||||
> stage software use. sACN and Art-Net are just DMX sent over your normal network
|
||||
> instead of a cable. Only needed if you want lighting-board control.
|
||||
> - **MQTT** — the messaging system Home Assistant uses to talk to devices. Only
|
||||
> needed if you want Home Assistant control.
|
||||
> - **OSC (Open Sound Control)** — a simple UDP message format used by show
|
||||
> controllers and touch-panel apps (e.g. TouchOSC). Only needed if you want to
|
||||
> drive the unit from one of those.
|
||||
|
||||
## Contents
|
||||
|
||||
- [How it works](#how-it-works) — the concepts behind devices, sources, and control
|
||||
- [What it does](#what-it-does)
|
||||
- [Zones and scenes](#zones-and-scenes)
|
||||
- [Arbitration](#arbitration)
|
||||
- [OSC control](#osc-control)
|
||||
- [Monitoring feedback](#monitoring-feedback)
|
||||
- [What you'll need](#what-youll-need) — hardware and prerequisites
|
||||
- [Installation](#installation) — build, install, configure, and run
|
||||
- [Home Assistant & MQTT (Docker)](#home-assistant--mqtt-docker)
|
||||
- [Recommended: hardware watchdog](#recommended-hardware-watchdog)
|
||||
|
||||
# How it works
|
||||
|
||||
## What it does
|
||||
|
||||
You define one or more Lutron **devices** (each reached over serial or telnet),
|
||||
then attach **sources** that drive a device's zones:
|
||||
|
||||
- **sACN (E1.31)** — receive network DMX from a lighting console or software.
|
||||
- **Art-Net** — receive Art-Net DMX.
|
||||
- **MQTT** — expose a device as one or more dimmable lights, each driving its own
|
||||
set of zones, with optional Home Assistant discovery.
|
||||
- **OSC (Open Sound Control)** — expose the full control surface over UDP, driven by
|
||||
the message address (so a show controller or a TouchOSC layout can run zones,
|
||||
scenes, shades, and more).
|
||||
|
||||
Serial talks to the QSE-CI-NWK-E directly; telnet performs the integration login
|
||||
automatically (TCP keepalive guards against idle-connection drops).
|
||||
|
||||
## Zones and scenes
|
||||
|
||||
DMX sources map a universe's channels to a device's **zones** (live level control)
|
||||
and/or **scenes** (the GRAFIK Eye's 16 presets). Zones are dimmable — the channel
|
||||
value sets the level. Scenes are not levels: a single **scene-select channel**
|
||||
triggers a scene by value (`0` = no action, `1` = scene 1, … `16` = scene 16), and
|
||||
it fires when the value *changes* to a scene number — so it behaves as a momentary
|
||||
trigger, not a fader. Set it to a scene number deliberately (from a cue or a button);
|
||||
sweeping it through a range will fire each scene it passes.
|
||||
|
||||
A source only drives the zones it maps — zones left out of the map are untouched, so
|
||||
they can be owned by another source (e.g. an MQTT light). Use the sequential layout —
|
||||
zones laid out from `start_address`, then one scene-select channel — or an explicit
|
||||
`channels` map for full control:
|
||||
|
||||
```yaml
|
||||
sacn:
|
||||
universe: 3
|
||||
start_address: 0
|
||||
zones: 6 # channels 0..5 -> zones 1..6
|
||||
scenes: 4 # channel 6 -> scene select (value 1..4 triggers a scene, 0 = none)
|
||||
# channels: # or map explicitly (channel = 1-indexed DMX address)
|
||||
# - { channel: 1, type: zone, zone: 1 }
|
||||
# - { channel: 7, type: scene } # value selects the scene to trigger
|
||||
```
|
||||
|
||||
Over MQTT, define `mqtt.lights` to expose one or more Home Assistant lights, each
|
||||
with its own base `topic` and the `zones` it controls. A light's brightness drives
|
||||
every zone in its set, and the first zone is mirrored back as the aggregate state;
|
||||
all of a source's lights are grouped under one Home Assistant device. Omit the
|
||||
block to fall back to a single light over every zone using the base topic.
|
||||
|
||||
The bridge publishes its liveness to `<topic>/availability` (`online`/`offline`),
|
||||
registered as the MQTT Last Will so the broker flips it to `offline` if the bridge
|
||||
drops, and the discovery configs reference it — so Home Assistant shows the lights
|
||||
and scene selector as unavailable whenever the bridge is down rather than leaving
|
||||
stale state behind.
|
||||
|
||||
```yaml
|
||||
mqtt:
|
||||
topic: lutron/qse-nwk
|
||||
lights:
|
||||
- name: All Zones
|
||||
topic: lutron/qse-nwk/all
|
||||
zones: [1, 2, 3, 4, 5, 6] # one light controlling zones 1-6
|
||||
```
|
||||
|
||||
Set `mqtt.scenes: N` to expose a scene selector (a Home Assistant `select` entity
|
||||
with discovery). A scene that is activated holds until a zone source takes over;
|
||||
while idle, zone targets follow the panel's reported levels so the bridge doesn't
|
||||
fight scenes or wall keypads.
|
||||
|
||||
The protocol's inter-message delays (100 ms after commands, 1500 ms after queries)
|
||||
are honored, and `#MONITORING` is configured on connect so zone/scene feedback
|
||||
works reliably (even over RS-232).
|
||||
|
||||
## Arbitration
|
||||
|
||||
When several sources target the same device, `priority` decides who wins. A
|
||||
higher-priority source that is actively sending locks out lower-priority sources
|
||||
for `hold_sec` seconds — so giving DMX sources a higher priority than MQTT lets a
|
||||
live show keep Home Assistant from changing the lights mid-cue. When an MQTT command
|
||||
is locked out, the current zone state is mirrored back to MQTT instead.
|
||||
|
||||
## OSC control
|
||||
|
||||
An OSC source exposes the full control surface over UDP. Set `osc.listen` to the
|
||||
`host:port` the server binds and `osc.prefix` to the address namespace it responds
|
||||
under; the message address (under that prefix) selects the operation, so the same
|
||||
source can drive zones, scenes, shades, locks, and sequences. This is the natural
|
||||
fit for a show controller or a TouchOSC layout. Movement and trigger addresses
|
||||
(raise/lower/stop, scene-off) act on receipt and ignore their arguments.
|
||||
|
||||
```yaml
|
||||
sources:
|
||||
- name: show-osc
|
||||
type: osc
|
||||
device: grafik-eye
|
||||
priority: 5 # Above MQTT, below a live DMX stream.
|
||||
hold_sec: 5
|
||||
osc:
|
||||
listen: 0.0.0.0:9000 # host:port the OSC server binds.
|
||||
prefix: /lutron # Address namespace this source responds under.
|
||||
```
|
||||
|
||||
The control addresses, all under `prefix`:
|
||||
|
||||
| Address | Arg | Action |
|
||||
| --- | --- | --- |
|
||||
| `<prefix>/zone/<n>/level` | `f` or `i` | set zone `<n>` level (float 0–1, or int 0–255) |
|
||||
| `<prefix>/zone/<n>/raise` | — | start raising zone `<n>` |
|
||||
| `<prefix>/zone/<n>/lower` | — | start lowering zone `<n>` |
|
||||
| `<prefix>/zone/<n>/stop` | — | stop raising/lowering zone `<n>` |
|
||||
| `<prefix>/scene` | `i` | activate scene `<i>` |
|
||||
| `<prefix>/scene/off` | — | activate the scene-off look |
|
||||
| `<prefix>/shade/<c>/<act>` | — | drive shade column `<c>` (`open`/`close`/`preset`/`raise`/`lower`/`stop`) |
|
||||
| `<prefix>/lock/zone` | `i` | zone lock (0 off, 1 on) |
|
||||
| `<prefix>/lock/scene` | `i` | scene lock (0 off, 1 on) |
|
||||
| `<prefix>/sequence` | `i` | sequence (0 off, 1 scenes 1–4, 2 scenes 5–16) |
|
||||
|
||||
To stream the panel's feedback back to a controller, set `osc.stream_to` to one or
|
||||
more `host:port` destinations — see [Monitoring feedback](#monitoring-feedback)
|
||||
below, which covers the symmetric report addresses and the `osc.level_as_float`
|
||||
encoding.
|
||||
|
||||
## Monitoring feedback
|
||||
|
||||
Beyond the Home Assistant light/scene entities, the MQTT and OSC sources can relay
|
||||
the panel's raw monitoring — anything the GRAFIK Eye reports: zone levels, scenes,
|
||||
keypad button presses, LED states, occupancy, and more. A source's `monitor.enable`
|
||||
lists the `#MONITORING` type numbers to request from the panel (e.g. `3` button,
|
||||
`5` zone, `8` scene; `255` for all); the bridge enables the union across sources on
|
||||
connect and re-asserts it after a reconnect. `monitor.families` optionally limits
|
||||
which `~` message families are forwarded.
|
||||
|
||||
- **OSC** streams to the `osc.stream_to` destinations (`host:port`). Modeled
|
||||
reports get symmetric addresses — `<prefix>/zone/<n>/level`, `<prefix>/scene`,
|
||||
`<prefix>/group/<id>/occupancy` — and anything else falls back to
|
||||
`<prefix>/monitor/<family>/<fields...>`. `osc.level_as_float` (default true)
|
||||
picks the zone-level encoding (0–1 float or 0–255 int).
|
||||
- **MQTT** publishes to `<topic>/<monitor_prefix>/<family>/<fields...>` (retained,
|
||||
`monitor_prefix` defaults to `monitor`). These are raw state topics for
|
||||
automations; no Home Assistant discovery is published for them.
|
||||
|
||||
# What you'll need
|
||||
|
||||
- **A computer to run it on.** It's a single self-contained binary, so any Linux,
|
||||
macOS, or Windows machine works. This guide uses a **Raspberry Pi** as a cheap,
|
||||
low-power example and assumes **Raspberry Pi OS / Raspbian 13 (Trixie)**; a Pi
|
||||
Zero is enough for serial-only control, while sACN, Art-Net, or Home Assistant
|
||||
need networking, so use a Pi Zero **W** (Wi-Fi) or any networked model. Adapt the
|
||||
steps to your platform if you run it elsewhere.
|
||||
- A **USB-to-serial adapter** wired to the QSE-CI-NWK-E's serial terminals. The
|
||||
example config uses a common Prolific PL2303-style adapter; any 3.3 V / RS-232
|
||||
adapter that matches your wiring will do. *(Skip this if you're using the telnet
|
||||
transport instead — see below.)*
|
||||
- A **GRAFIK Eye QS** with the **QSE-CI-NWK-E** network/serial interface module.
|
||||
- **For lighting-board control:** a lighting console or software that sends
|
||||
**sACN/E1.31** or **Art-Net** over your network.
|
||||
- **For Home Assistant:** a running **MQTT broker** (the Docker setup near the end
|
||||
of this guide includes one).
|
||||
|
||||
> **Serial or telnet?** The QSE-CI-NWK-E offers both. Serial (a USB-to-serial
|
||||
> adapter wired to the module) is the classic setup and needs no network between the
|
||||
> Pi and the unit. Telnet talks to the module over your network instead — no wiring,
|
||||
> and the Pi can live anywhere on the LAN. Pick whichever suits your install; the
|
||||
> config covers both.
|
||||
|
||||
# Installation
|
||||
|
||||
## 0. Get the Pi ready and download the code
|
||||
|
||||
If you're starting from a blank SD card, flash **Raspberry Pi OS** with the
|
||||
[Raspberry Pi Imager](https://www.raspberrypi.com/software/). Before you write the
|
||||
card, open the imager's settings (the **gear** / **"Edit settings"** button) and:
|
||||
|
||||
- **Set a username and password**, and
|
||||
- **Enable SSH** (so you can connect to the Pi from another computer).
|
||||
|
||||
Boot the Pi, then open a terminal on it — either directly with a keyboard and
|
||||
monitor, or from another computer over SSH:
|
||||
|
||||
```bash
|
||||
ssh <user>@<pi-address>
|
||||
```
|
||||
|
||||
Now install git and download this project:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y git
|
||||
git clone https://github.com/GRMrGecko/lutron-control.git
|
||||
cd lutron-control
|
||||
```
|
||||
|
||||
Every command from here on is run from inside this `lutron-control` folder.
|
||||
|
||||
## 1. Install Go and build the program
|
||||
|
||||
This program is built with Go. Install the toolchain from the package manager:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y golang
|
||||
go version # confirm it printed a version
|
||||
```
|
||||
|
||||
Now build the binary (from inside the `lutron-control` folder):
|
||||
|
||||
```bash
|
||||
go build -o lutron-control .
|
||||
```
|
||||
|
||||
This produces a `lutron-control` binary in the current folder. On a Pi Zero the
|
||||
first build downloads dependencies and takes a few minutes; later builds are quick.
|
||||
|
||||
> **Faster: build on your computer instead.** Go cross-compiles, so you can build the
|
||||
> binary on a desktop and copy it to the Pi — no Go install on the Pi at all. From a
|
||||
> checkout on your machine:
|
||||
> ```bash
|
||||
> # 64-bit Pi (arm64):
|
||||
> GOOS=linux GOARCH=arm64 go build -o lutron-control .
|
||||
> # 32-bit Pi Zero / Zero W (ARMv6):
|
||||
> GOOS=linux GOARCH=arm GOARM=6 go build -o lutron-control .
|
||||
> # then copy it over:
|
||||
> scp lutron-control <user>@<pi-address>:~/lutron-control/
|
||||
> ```
|
||||
|
||||
## 2. Install the program, service, and config
|
||||
|
||||
Install the binary, create the config directory, drop your config in place, and
|
||||
install the systemd service that keeps it running and starts it on boot:
|
||||
|
||||
```bash
|
||||
# The program (built in step 1):
|
||||
sudo install -m 0755 lutron-control /usr/local/bin/lutron-control
|
||||
|
||||
# The config (locked down because it can hold your MQTT password):
|
||||
sudo install -d /etc/lutron-control
|
||||
sudo install -m 0600 config.example.yaml /etc/lutron-control/config.yaml
|
||||
|
||||
# The background service:
|
||||
sudo install -m 0644 lutron-control.service /etc/systemd/system/lutron-control.service
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
Don't enable or start it yet — the config still has placeholder values. Fill them in
|
||||
first (step 3), then start it (step 4).
|
||||
|
||||
## 3. Fill in the config
|
||||
|
||||
Open the config in a text editor (`nano` is beginner-friendly):
|
||||
|
||||
```bash
|
||||
sudo nano /etc/lutron-control/config.yaml
|
||||
```
|
||||
|
||||
This file is a copy of [`config.example.yaml`](config.example.yaml) and is heavily
|
||||
commented, so each setting explains itself. The important ones:
|
||||
|
||||
- **`devices[].transport`** — `serial` or `telnet`.
|
||||
- **`devices[].serial.device`** (serial transport) — which USB-serial adapter to
|
||||
use. Find yours by running `ls -lah /dev/serial/by-id/` and copying the matching
|
||||
path. The `by-id` path is stable across reboots, unlike `/dev/ttyUSB0`.
|
||||
- **`devices[].serial.baud`** — must match the dipswitch setting on the QSE-CI-NWK-E.
|
||||
- **`devices[].telnet.address`** (telnet transport) — the module's `IP:23`. The
|
||||
login user is `nwk` (or `nwk2`); a password is only needed if one has been
|
||||
programmed on the unit.
|
||||
- **`devices[].integration_id` and `devices[].zones`** — set these to match your
|
||||
GRAFIK Eye. The integration ID is assigned in Lutron's programming; zones is how
|
||||
many dimmable zones your model has. **Not sure of the integration ID?** See the
|
||||
discovery tip below.
|
||||
- **`sources[]`** — the control inputs. Delete the `sacn`/`artnet`/`mqtt`/`osc`
|
||||
source blocks you don't want. For DMX, set the `universe` and channel layout; for
|
||||
MQTT, set your broker address, `username`/`password`, and the lights/zones; for OSC,
|
||||
set the `listen` address and `prefix`.
|
||||
|
||||
Save and exit (`Ctrl+O`, `Enter`, then `Ctrl+X` in nano).
|
||||
|
||||
> **Don't know your integration IDs?** Run discovery — it connects to each device in
|
||||
> the config, prints its integration IDs, and exits:
|
||||
> ```bash
|
||||
> sudo lutron-control --config /etc/lutron-control/config.yaml --discover
|
||||
> ```
|
||||
> Add `--verbose` to any run to force debug logging to the console while you're
|
||||
> setting things up.
|
||||
|
||||
> **Where the config lives:** the program looks for it in this order — `--config
|
||||
> PATH`, then `./config.yaml`, then `~/.config/lutron-control/config.yaml`, then
|
||||
> `/etc/lutron-control/config.yaml` (where the steps above put it). The systemd
|
||||
> service points at the `/etc/lutron-control/config.yaml` copy explicitly.
|
||||
|
||||
## 4. Start the service
|
||||
|
||||
Enable it (so it starts on every boot) and start it now:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now lutron-control
|
||||
```
|
||||
|
||||
Check that it started cleanly (press `Ctrl+C` to stop watching the log):
|
||||
|
||||
```bash
|
||||
journalctl -u lutron-control -f
|
||||
```
|
||||
|
||||
When you update later — rebuild (step 1), reinstall the binary (step 2's first
|
||||
command), then restart:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart lutron-control
|
||||
```
|
||||
|
||||
> The service supports `Type=notify` with the watchdog — the program pings systemd
|
||||
> while healthy, and systemd restarts it if it ever stops responding.
|
||||
|
||||
# Home Assistant & MQTT (Docker)
|
||||
|
||||
If you want Home Assistant control, you need an MQTT broker for this program to
|
||||
publish to. Here's how I run Home Assistant and the Mosquitto MQTT broker together
|
||||
in Docker with `docker compose`.
|
||||
|
||||
Minimal `compose.yaml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homeassistant:
|
||||
container_name: home-assistant
|
||||
image: homeassistant/home-assistant:stable
|
||||
volumes:
|
||||
- ./hass:/config
|
||||
environment:
|
||||
- TZ=America/Chicago
|
||||
restart: always
|
||||
network_mode: host
|
||||
mqtt:
|
||||
container_name: mqtt
|
||||
image: eclipse-mosquitto
|
||||
volumes:
|
||||
- ./mosquitto:/mosquitto/config
|
||||
restart: always
|
||||
network_mode: host
|
||||
```
|
||||
|
||||
`network_mode: host` lets Home Assistant find the broker and lets this control
|
||||
program publish to it at `127.0.0.1:1883`. Start it all with `docker compose up -d`.
|
||||
|
||||
## Mosquitto config
|
||||
|
||||
Mosquitto (the MQTT broker) needs a config file and a password in the mounted
|
||||
`./mosquitto` folder.
|
||||
|
||||
`./mosquitto/mosquitto.conf`:
|
||||
|
||||
```
|
||||
per_listener_settings true
|
||||
allow_zero_length_clientid true
|
||||
listener 1883 0.0.0.0
|
||||
allow_anonymous false
|
||||
password_file /mosquitto/config/pwfile
|
||||
acl_file /mosquitto/config/aclfile
|
||||
```
|
||||
|
||||
`./mosquitto/aclfile` (gives the `mqtt` user full access):
|
||||
|
||||
```
|
||||
user mqtt
|
||||
topic readwrite #
|
||||
```
|
||||
|
||||
Create the password file — use the **same `mqtt` user and password you put in
|
||||
`config.yaml`**:
|
||||
|
||||
```bash
|
||||
docker compose run --rm mqtt mosquitto_passwd -c -b /mosquitto/config/pwfile mqtt 'your-password'
|
||||
docker compose restart mqtt
|
||||
```
|
||||
|
||||
## Home Assistant integration
|
||||
|
||||
In Home Assistant, add the **MQTT** integration (**Settings → Devices & Services**)
|
||||
and point it at the broker: host `127.0.0.1`, port `1883`, and the `mqtt`
|
||||
user/password.
|
||||
|
||||
With `mqtt.discovery: true` (the default in the example config), the lights and the
|
||||
scene selector are announced to Home Assistant automatically and show up on their own
|
||||
— no YAML editing required.
|
||||
|
||||
# Recommended: hardware watchdog
|
||||
|
||||
The Raspberry Pi has a built-in hardware watchdog that can automatically reboot the
|
||||
Pi if it ever locks up. It's worth enabling for an always-on device like this.
|
||||
|
||||
Add this to `/boot/firmware/config.txt` (or `/boot/config.txt` on older images) under
|
||||
the `[all]` section:
|
||||
|
||||
```
|
||||
watchdog=on
|
||||
```
|
||||
|
||||
Then uncomment `RuntimeWatchdogSec` in `/etc/systemd/system.conf` and set it:
|
||||
|
||||
```
|
||||
RuntimeWatchdogSec=10s
|
||||
```
|
||||
|
||||
Reboot to apply.
|
||||
149
artnet.go
Normal file
149
artnet.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jsimonetti/go-artnet"
|
||||
"github.com/jsimonetti/go-artnet/packet"
|
||||
"github.com/jsimonetti/go-artnet/packet/code"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// artnetWatchdogMax bounds how often the timeout watchdog wakes; the actual
|
||||
// interval is the smaller of this and the configured timeout so loss is detected
|
||||
// promptly without busy-polling.
|
||||
const artnetWatchdogMax = time.Second
|
||||
|
||||
// ArtNetSource receives Art-Net DMX and maps a universe's channels to its
|
||||
// device's zones and scenes.
|
||||
type ArtNetSource struct {
|
||||
cfg *SourceConfig
|
||||
dev *Device
|
||||
disp *dmxDispatcher
|
||||
node *artnet.Node
|
||||
// portAddress is the 15-bit Art-Net port address (net/subnet/universe) this
|
||||
// source listens for.
|
||||
portAddress uint16
|
||||
|
||||
mu sync.Mutex
|
||||
lastRx time.Time // Time of the most recent matching packet.
|
||||
released bool // True once loss has been signalled, until packets resume.
|
||||
closeOnce sync.Once // Guards node shutdown; go-artnet's Stop panics if called twice.
|
||||
}
|
||||
|
||||
// newArtNetSource constructs an Art-Net control source.
|
||||
func newArtNetSource(cfg *SourceConfig, dev *Device, disp *dmxDispatcher) *ArtNetSource {
|
||||
port := uint16(cfg.ArtNet.Net&0x7F)<<8 |
|
||||
uint16(cfg.ArtNet.SubNet&0x0F)<<4 |
|
||||
uint16(cfg.ArtNet.Universe&0x0F)
|
||||
return &ArtNetSource{cfg: cfg, dev: dev, disp: disp, portAddress: port}
|
||||
}
|
||||
|
||||
// Name returns the source's configured name.
|
||||
func (s *ArtNetSource) Name() string { return s.cfg.Name }
|
||||
|
||||
// Start opens the Art-Net node and registers the DMX callback.
|
||||
func (s *ArtNetSource) Start(ctx context.Context) error {
|
||||
ip := net.ParseIP(s.cfg.ArtNet.Bind)
|
||||
if ip == nil {
|
||||
ip = net.IPv4zero
|
||||
}
|
||||
|
||||
// Drive the node's logging through our configured logger rather than its
|
||||
// default debug-to-stdout logger.
|
||||
nodeLog := artnet.NewLogger(log.NewEntry(log.StandardLogger()))
|
||||
node := artnet.NewNode(serviceName, code.StNode, ip, nodeLog)
|
||||
s.node = node
|
||||
|
||||
node.RegisterCallback(code.OpDMX, func(p packet.ArtNetPacket) {
|
||||
dmx, ok := p.(*packet.ArtDMXPacket)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Match the packet's port address against the configured one.
|
||||
addr := uint16(dmx.Net&0x7F)<<8 | uint16(dmx.SubUni)
|
||||
if addr != s.portAddress {
|
||||
return
|
||||
}
|
||||
// Mark the stream live so the watchdog holds off on releasing while frames
|
||||
// keep arriving, even when a sender slows retransmission of a static look.
|
||||
s.markRx()
|
||||
s.disp.dispatch(dmx.Data[:])
|
||||
})
|
||||
|
||||
if err := node.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Infof("[%s] Listening for Art-Net on net %d subnet %d universe %d",
|
||||
s.cfg.Name, s.cfg.ArtNet.Net, s.cfg.ArtNet.SubNet, s.cfg.ArtNet.Universe)
|
||||
|
||||
// Detect source loss and stop the node when the application shuts down.
|
||||
go s.watchdog(ctx)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
s.stop()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// markRx records a freshly received frame, clearing any prior released state so
|
||||
// the watchdog resumes guarding the now-live stream.
|
||||
func (s *ArtNetSource) markRx() {
|
||||
s.mu.Lock()
|
||||
s.lastRx = time.Now()
|
||||
s.released = false
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// watchdog releases the source when the universe falls silent. Unlike sACN, the
|
||||
// Art-Net library reports every received frame rather than only changes, so the
|
||||
// stream stays live as long as the sender retransmits; this guards the case where
|
||||
// it stops entirely, mirroring sACN's timeout: the owned zones black out and the
|
||||
// arbitration lock is allowed to lapse so a lower-priority source can resume.
|
||||
func (s *ArtNetSource) watchdog(ctx context.Context) {
|
||||
timeout := time.Duration(s.cfg.ArtNet.TimeoutSec * float64(time.Second))
|
||||
if timeout <= 0 {
|
||||
return
|
||||
}
|
||||
interval := min(timeout, artnetWatchdogMax)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.mu.Lock()
|
||||
// Skip until the first frame arrives, and only release once per loss.
|
||||
if s.lastRx.IsZero() || s.released || time.Since(s.lastRx) < timeout {
|
||||
s.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
s.released = true
|
||||
s.mu.Unlock()
|
||||
|
||||
s.disp.release()
|
||||
log.Infof("[%s] Art-Net universe %d released; zones to 0",
|
||||
s.cfg.Name, s.cfg.ArtNet.Universe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop shuts down the Art-Net node.
|
||||
func (s *ArtNetSource) Stop() {
|
||||
s.stop()
|
||||
}
|
||||
|
||||
// stop shuts the Art-Net node down exactly once. go-artnet's Stop closes an
|
||||
// internal channel and panics on a second call, and both context cancellation
|
||||
// and Stop race to call it.
|
||||
func (s *ArtNetSource) stop() {
|
||||
s.closeOnce.Do(func() {
|
||||
if s.node != nil {
|
||||
s.node.Stop()
|
||||
}
|
||||
})
|
||||
}
|
||||
221
config.example.yaml
Normal file
221
config.example.yaml
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# Configuration for lutron-control.
|
||||
#
|
||||
# Copy this file to config.yaml and edit it. The program searches for the config
|
||||
# in this order:
|
||||
# 1. --config PATH on the command line
|
||||
# 2. ./config.yaml
|
||||
# 3. ~/.config/lutron-control/config.yaml
|
||||
# 4. /etc/lutron-control/config.yaml
|
||||
#
|
||||
# The model is: define one or more Lutron `devices` (each reached over serial or
|
||||
# telnet), then attach `sources` that drive a device's zones. A source can be an
|
||||
# sACN receiver, an Art-Net receiver, an MQTT light, or an OSC server. When several
|
||||
# sources target the same device, `priority` decides who wins: a higher-priority
|
||||
# source that is actively sending locks out lower-priority ones for `hold_sec`
|
||||
# seconds (so a live DMX show keeps Home Assistant from changing the lights
|
||||
# mid-cue). DMX carries only zone levels; the richer controls (raise/lower/stop,
|
||||
# shades, locks, sequence) live on the MQTT and OSC sources.
|
||||
|
||||
log:
|
||||
level: info # debug, info, warn, or error.
|
||||
type: console # console or json.
|
||||
outputs:
|
||||
- console # console, a file path, or default-file (/var/log/<name>.log).
|
||||
|
||||
# --- Lutron devices ---
|
||||
devices:
|
||||
- name: grafik-eye # Referenced by each source's `device`.
|
||||
transport: serial # serial or telnet.
|
||||
|
||||
# Serial transport (used when transport: serial).
|
||||
serial:
|
||||
# Find yours with: ls -lah /dev/serial/by-id/
|
||||
device: /dev/serial/by-id/usb-Prolific_Technology_Inc._USB-Serial_Controller-if00-port0
|
||||
baud: 115200 # Must match the dipswitch on the QSE-CI-NWK-E.
|
||||
|
||||
# Telnet transport (used when transport: telnet). The QSE-CI-NWK-E telnet
|
||||
# server listens on port 23 with predetermined logins "nwk" (or "nwk2"); the
|
||||
# password is only needed if a login passphrase has been configured on the unit.
|
||||
telnet:
|
||||
address: 10.0.0.50:23
|
||||
username: nwk
|
||||
password: ""
|
||||
|
||||
integration_id: 1 # Integration ID bound to the GRAFIK Eye main unit.
|
||||
zones: 6 # Controllable zones on your model (max 24). Maps to
|
||||
# zone-controller components 1..zones, action 14.
|
||||
fade: "00:00" # Fade sent with each level command; "00:00" = instant.
|
||||
|
||||
# Phantom-button components that signal integration control disabled/enabled
|
||||
# (advanced; application-specific programming on the unit). Both default to 0
|
||||
# (off): the signal is only acted on when set to a non-zero component, so an
|
||||
# unrelated button can't silently disable the bridge. Set to match your unit.
|
||||
# disable_component: 74
|
||||
# enable_component: 75
|
||||
|
||||
# Monitoring sent on connect. By default zone-level + reply monitoring are
|
||||
# ensured (so feedback works over serial too), plus scene monitoring when a
|
||||
# scene source is attached. Use enable/disable to tune, or manage: false to
|
||||
# leave the unit's programmed monitoring untouched.
|
||||
monitoring:
|
||||
manage: true
|
||||
# enable: [8] # extra monitoring type numbers to turn on
|
||||
# disable: [3, 4, 6] # silence button/LED/occupancy noise
|
||||
|
||||
# Reliability tuning (optional; defaults shown).
|
||||
reliability:
|
||||
rx_timeout_sec: 60
|
||||
watchdog_interval_sec: 15
|
||||
reconnect_backoff_min_sec: 1
|
||||
reconnect_backoff_max_sec: 30
|
||||
send_all_interval_sec: 10 # periodic full resend; 0 = send zones only on change
|
||||
reset_cooldown_sec: 10
|
||||
|
||||
# --- Control sources ---
|
||||
sources:
|
||||
# sACN (E1.31) receiver. The DMX channel mapping is shared by sACN and Art-Net:
|
||||
# use the sequential layout (start_address + zone/scene counts) for the common
|
||||
# case, or an explicit `channels` list for full control.
|
||||
- name: stage-sacn
|
||||
type: sacn
|
||||
device: grafik-eye
|
||||
priority: 10 # Higher than MQTT so a live stream takes control.
|
||||
hold_sec: 5 # Stay in control for 5s after the last frame.
|
||||
sacn:
|
||||
bind: "" # Bind address ("" = all interfaces).
|
||||
interface: "" # Interface name for multicast (e.g. eth0); often optional on Linux.
|
||||
universe: 3
|
||||
# Sequential layout: zones start at start_address (0-indexed), then one
|
||||
# scene-select channel.
|
||||
start_address: 0 # zone 1 channel
|
||||
zones: 6 # zones 1..6 -> channels start_address..+5 (0 = device zone count)
|
||||
scenes: 0 # >0 adds one scene-select channel after the zones; its
|
||||
# value (1..scenes) triggers that scene, 0 = no action
|
||||
# Explicit map (overrides the sequential layout above when set):
|
||||
# channels:
|
||||
# - { channel: 1, type: zone, zone: 1 } # channel is 1-indexed DMX address
|
||||
# - { channel: 7, type: scene } # value selects the scene to trigger
|
||||
|
||||
# Art-Net receiver. Listens on UDP 6454 for the configured port address. Uses
|
||||
# the same DMX channel mapping fields as sACN.
|
||||
- name: stage-artnet
|
||||
type: artnet
|
||||
device: grafik-eye
|
||||
priority: 10
|
||||
hold_sec: 5
|
||||
artnet:
|
||||
bind: 0.0.0.0
|
||||
net: 0
|
||||
subnet: 0
|
||||
universe: 3
|
||||
timeout_sec: 5 # Silence before the source is lost (zones black out); 0 disables.
|
||||
start_address: 0
|
||||
zones: 6
|
||||
scenes: 0
|
||||
|
||||
# MQTT / Home Assistant lights. Each light drives its own set of QSE zones; a
|
||||
# light's brightness is applied to every zone it controls, and the first zone in
|
||||
# the set is mirrored back to Home Assistant as the aggregate state. All of a
|
||||
# source's lights are grouped under one Home Assistant device.
|
||||
- name: home-assistant
|
||||
type: mqtt
|
||||
device: grafik-eye
|
||||
priority: 1 # Lowest, so DMX sources override it while streaming.
|
||||
# fade: "00:01" # Optional per-source zone fade override (SS, MM:SS,
|
||||
# or HH:MM:SS). Empty uses the device fade; DMX
|
||||
# sources instead default to instant.
|
||||
mqtt:
|
||||
broker: 127.0.0.1
|
||||
port: 1883
|
||||
topic: lutron/qse-nwk # Base topic; also used by the scene selector below.
|
||||
username: mqtt
|
||||
password: change-me
|
||||
client_id: "" # Auto-generated when empty.
|
||||
discovery: true # Publish a Home Assistant discovery config. Entities
|
||||
# carry an availability topic (<topic>/availability,
|
||||
# backed by an MQTT Last Will) so Home Assistant marks
|
||||
# them unavailable when the bridge is offline.
|
||||
discovery_prefix: homeassistant
|
||||
device_name: Lutron QSE NWK
|
||||
scenes: 0 # >0 exposes a scene selector (1..N); commands on
|
||||
# <topic>/scene/set, state on <topic>/scene, plus a
|
||||
# Home Assistant select entity when discovery is on.
|
||||
|
||||
# Extended controls (each off by default; enable the ones your unit supports).
|
||||
# These follow the scenes pattern: enabling one publishes its Home Assistant
|
||||
# entity (when discovery is on) and subscribes its command topic.
|
||||
shades: 0 # >0 exposes N shade columns (1-3) as covers
|
||||
# (open/close/stop) on <topic>/shade/<n>/set.
|
||||
zone_ramp: false # Raise/lower/stop buttons for each light's zones,
|
||||
# on <light-topic>/{raise,lower,stop}/set.
|
||||
zone_lock: false # Zone-lock switch on <topic>/zone_lock/set (QS Standalone).
|
||||
scene_lock: false # Scene-lock switch on <topic>/scene_lock/set (QS Standalone).
|
||||
sequence: false # Sequence select (Off / Scenes 1-4 / Scenes 5-16)
|
||||
# on <topic>/sequence/set (QS Standalone).
|
||||
|
||||
# Monitoring relay. Publishes the panel's "~" reports to
|
||||
# <topic>/<monitor_prefix>/<family>/<fields...> for use in automations
|
||||
# (this is raw state, separate from the Home Assistant entities above; no
|
||||
# discovery is published for it).
|
||||
monitor_prefix: monitor
|
||||
monitor:
|
||||
enable: [] # #MONITORING types to request from the panel
|
||||
# (e.g. [3] button, [5] zone, [8] scene; 255 = all).
|
||||
# Empty relays only what the panel already reports.
|
||||
families: [] # Restrict forwarded "~" families (e.g. [DEVICE, GROUP]);
|
||||
# empty forwards every family.
|
||||
|
||||
# Lights exposed by this source. Each light has its own base topic (commands
|
||||
# on <topic>/set, state on <topic>) and the list of zones it controls. Omit
|
||||
# this whole block to fall back to a single light over every zone using the
|
||||
# base topic above.
|
||||
lights:
|
||||
- name: All Zones # Friendly name in Home Assistant.
|
||||
topic: lutron/qse-nwk/all
|
||||
zones: [1, 2, 3, 4, 5, 6] # This one light controls zones 1-6.
|
||||
# Add more lights to split the panel up, e.g.:
|
||||
# - name: Front
|
||||
# topic: lutron/qse-nwk/front
|
||||
# zones: [1, 2, 3]
|
||||
# - name: Back
|
||||
# topic: lutron/qse-nwk/back
|
||||
# zones: [4, 5, 6]
|
||||
|
||||
# OSC (Open Sound Control) server. Exposes the full control surface over UDP;
|
||||
# the message address (under `prefix`) selects the operation, e.g. from a show
|
||||
# controller or a TouchOSC layout. Movement/trigger addresses act on receipt
|
||||
# and ignore their arguments.
|
||||
#
|
||||
# <prefix>/zone/<n>/level f|i set zone level (float 0-1, or int 0-255)
|
||||
# <prefix>/zone/<n>/raise start raising zone <n>
|
||||
# <prefix>/zone/<n>/lower start lowering zone <n>
|
||||
# <prefix>/zone/<n>/stop stop raising/lowering zone <n>
|
||||
# <prefix>/scene i activate scene <i>
|
||||
# <prefix>/scene/off activate the scene-off look
|
||||
# <prefix>/shade/<c>/<act> shade column <c>: open|close|preset|raise|lower|stop
|
||||
# <prefix>/lock/zone i zone lock (0 off, 1 on)
|
||||
# <prefix>/lock/scene i scene lock (0 off, 1 on)
|
||||
# <prefix>/sequence i sequence (0 off, 1 scenes 1-4, 2 scenes 5-16)
|
||||
#
|
||||
# Monitoring feedback is streamed (when stream_to is set) back out under the
|
||||
# same prefix, symmetric with the input addresses:
|
||||
#
|
||||
# <prefix>/zone/<n>/level f|i reported zone level
|
||||
# <prefix>/scene i reported active scene
|
||||
# <prefix>/group/<id>/occupancy i occupancy state (3 occupied, 4 unoccupied)
|
||||
# <prefix>/monitor/<family>/<fields...> generic fallback for any other report
|
||||
- name: show-osc
|
||||
type: osc
|
||||
device: grafik-eye
|
||||
priority: 5 # Above MQTT, below a live DMX stream.
|
||||
hold_sec: 5
|
||||
osc:
|
||||
listen: 0.0.0.0:9000 # host:port the OSC server binds.
|
||||
prefix: /lutron # Address namespace this source responds under.
|
||||
stream_to: [] # host:port destinations for monitoring feedback,
|
||||
# e.g. [192.168.1.50:9001]. Empty disables streaming.
|
||||
level_as_float: true # Send zone levels as 0-1 floats; false sends 0-255 ints.
|
||||
monitor:
|
||||
enable: [] # #MONITORING types to request from the panel
|
||||
# (e.g. [3] button, [5] zone, [8] scene; 255 = all).
|
||||
families: [] # Restrict forwarded "~" families; empty forwards all.
|
||||
541
config.go
Normal file
541
config.go
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/user"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
|
||||
"github.com/kkyr/fig"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
// Config is the root configuration: a set of Lutron devices and the control
|
||||
// sources that drive them.
|
||||
type Config struct {
|
||||
Log *LogConfig `fig:"log" yaml:"log"`
|
||||
Devices []*DeviceConfig `fig:"devices" yaml:"devices"`
|
||||
Sources []*SourceConfig `fig:"sources" yaml:"sources"`
|
||||
}
|
||||
|
||||
// LogConfig controls log verbosity, format, and output destinations.
|
||||
type LogConfig struct {
|
||||
// Limit the log output by the log level.
|
||||
Level string `fig:"level" yaml:"level" enum:"debug,info,warn,error" default:"info"`
|
||||
// How the log output should be formatted.
|
||||
Type string `fig:"type" yaml:"type" enum:"json,console" default:"console"`
|
||||
// The outputs the log should go to. `console` goes to stderr, a file path logs
|
||||
// to that file, and `default-file` logs to /var/log/<name>.log (or beside the
|
||||
// executable if that is not writable).
|
||||
Outputs []string `fig:"outputs" yaml:"outputs" default:"console"`
|
||||
// Maximum size of the log file in megabytes before it gets rotated.
|
||||
MaxSize int `fig:"max_size" yaml:"max_size" default:"1"`
|
||||
// Maximum number of backups to save.
|
||||
MaxBackups int `fig:"max_backups" yaml:"max_backups" default:"3"`
|
||||
// Maximum number of days to retain old log files.
|
||||
MaxAge int `fig:"max_age" yaml:"max_age" default:"0"`
|
||||
// Use local system time instead of UTC for rotated backup file names.
|
||||
LocalTime *bool `fig:"local_time" yaml:"local_time" default:"true"`
|
||||
// Whether rotated logs should be compressed.
|
||||
Compress *bool `fig:"compress" yaml:"compress" default:"true"`
|
||||
}
|
||||
|
||||
// DeviceConfig describes one Lutron integration interface (e.g. a GRAFIK Eye QS
|
||||
// reached through a QSE-CI-NWK-E) and how to connect to it.
|
||||
type DeviceConfig struct {
|
||||
// Name referenced by sources to bind to this device.
|
||||
Name string `fig:"name" yaml:"name"`
|
||||
// Transport used to reach the device.
|
||||
Transport string `fig:"transport" yaml:"transport" enum:"serial,telnet" default:"serial"`
|
||||
|
||||
Serial SerialConfig `fig:"serial" yaml:"serial"`
|
||||
Telnet TelnetConfig `fig:"telnet" yaml:"telnet"`
|
||||
|
||||
// IntegrationID bound to the GRAFIK Eye main unit in Lutron's programming.
|
||||
IntegrationID int `fig:"integration_id" yaml:"integration_id" default:"1"`
|
||||
// Zones is the number of controllable zones on the model (max 24).
|
||||
Zones int `fig:"zones" yaml:"zones" default:"6"`
|
||||
// Fade sent with each level command; "00:00" means instant.
|
||||
Fade string `fig:"fade" yaml:"fade" default:"00:00"`
|
||||
|
||||
// EnableComponent and DisableComponent are the GRAFIK Eye QS phantom-button
|
||||
// component numbers that, when pressed, signal integration control is
|
||||
// (re)enabled or disabled. These are application-specific (programmed on the
|
||||
// unit) and are not standard scene buttons; a press is "~DEVICE,<id>,<component>,3".
|
||||
// Both default to 0 (disabled): the signal is only acted on when a non-zero
|
||||
// component is configured, so an unrelated button can't silently disable the
|
||||
// bridge. Set them to match your unit's programming to enable the feature.
|
||||
EnableComponent int `fig:"enable_component" yaml:"enable_component" default:"0"`
|
||||
DisableComponent int `fig:"disable_component" yaml:"disable_component" default:"0"`
|
||||
|
||||
Monitoring MonitoringConfig `fig:"monitoring" yaml:"monitoring"`
|
||||
Reliability ReliabilityConfig `fig:"reliability" yaml:"reliability"`
|
||||
}
|
||||
|
||||
// MonitoringConfig controls the #MONITORING commands sent on connect. The
|
||||
// protocol advises enabling only the monitoring you need; by default we ensure
|
||||
// zone-level and reply monitoring are on (so feedback works even over RS-232 or
|
||||
// if the unit's programmed defaults were changed) plus scene monitoring when a
|
||||
// scene source is attached.
|
||||
type MonitoringConfig struct {
|
||||
// Manage enables the on-connect monitoring setup; set false to leave the
|
||||
// unit's programmed monitoring untouched.
|
||||
Manage *bool `fig:"manage" yaml:"manage" default:"true"`
|
||||
// Enable lists extra monitoring type numbers to turn on.
|
||||
Enable []int `fig:"enable" yaml:"enable"`
|
||||
// Disable lists monitoring type numbers to turn off (e.g. to silence noise).
|
||||
Disable []int `fig:"disable" yaml:"disable"`
|
||||
}
|
||||
|
||||
// MonitorForward selects which panel monitoring a source relays outward. The
|
||||
// device enables the union of every source's requested types on connect, then
|
||||
// each source filters the resulting "~" reports it forwards.
|
||||
type MonitorForward struct {
|
||||
// Enable lists #MONITORING type numbers to request from the panel for this
|
||||
// source (1 diagnostic, 2 event, 3 button, 4 LED, 5 zone, 8 scene, 16
|
||||
// sequence, 17 HVAC, 18 mode; 255 requests all). Empty relays whatever the
|
||||
// panel already reports without requesting extra types.
|
||||
Enable []int `fig:"enable" yaml:"enable"`
|
||||
// Families optionally restricts which "~" message families are forwarded
|
||||
// (e.g. ["DEVICE","GROUP"]); empty forwards every family.
|
||||
Families []string `fig:"families" yaml:"families"`
|
||||
}
|
||||
|
||||
// SerialConfig holds the serial-transport connection settings.
|
||||
type SerialConfig struct {
|
||||
// Device path of the serial adapter (find yours with `ls -lah /dev/serial/by-id/`).
|
||||
Device string `fig:"device" yaml:"device"`
|
||||
// Baud must match the dipswitch on the QSE-CI-NWK-E.
|
||||
Baud int `fig:"baud" yaml:"baud" default:"115200"`
|
||||
}
|
||||
|
||||
// TelnetConfig holds the telnet-transport connection settings. The QSE-CI-NWK-E
|
||||
// prompts for an integration login; username/password are sent when prompted.
|
||||
type TelnetConfig struct {
|
||||
// Address is host:port; port 23 is assumed when omitted.
|
||||
Address string `fig:"address" yaml:"address"`
|
||||
// Username sent at the "login:" prompt (the integration login, e.g. "nwk").
|
||||
Username string `fig:"username" yaml:"username"`
|
||||
// Password sent at the "password:" prompt, when one is presented.
|
||||
Password string `fig:"password" yaml:"password"`
|
||||
}
|
||||
|
||||
// ReliabilityConfig tunes the connection supervision behavior.
|
||||
type ReliabilityConfig struct {
|
||||
// RxTimeoutSec is how long the link may be silent (while writing) before the
|
||||
// watchdog probes and ultimately reconnects.
|
||||
RxTimeoutSec int `fig:"rx_timeout_sec" yaml:"rx_timeout_sec" default:"60"`
|
||||
// WatchdogIntervalSec is the watchdog tick interval.
|
||||
WatchdogIntervalSec int `fig:"watchdog_interval_sec" yaml:"watchdog_interval_sec" default:"15"`
|
||||
// ReconnectBackoffMinSec is the initial reconnect backoff.
|
||||
ReconnectBackoffMinSec int `fig:"reconnect_backoff_min_sec" yaml:"reconnect_backoff_min_sec" default:"1"`
|
||||
// ReconnectBackoffMaxSec caps the reconnect backoff.
|
||||
ReconnectBackoffMaxSec int `fig:"reconnect_backoff_max_sec" yaml:"reconnect_backoff_max_sec" default:"30"`
|
||||
// SendAllIntervalSec is how often every zone level is resent to prevent drift;
|
||||
// 0 disables the periodic resend, sending zones only when a target changes.
|
||||
SendAllIntervalSec int `fig:"send_all_interval_sec" yaml:"send_all_interval_sec" default:"10"`
|
||||
// ResetCooldownSec rate-limits the #RESET,0 recovery when the NWK wedges.
|
||||
ResetCooldownSec int `fig:"reset_cooldown_sec" yaml:"reset_cooldown_sec" default:"10"`
|
||||
}
|
||||
|
||||
// SourceConfig describes one control source that drives a device's zones.
|
||||
type SourceConfig struct {
|
||||
// Name identifies the source in logs.
|
||||
Name string `fig:"name" yaml:"name"`
|
||||
// Type selects the source implementation.
|
||||
Type string `fig:"type" yaml:"type" enum:"mqtt,sacn,artnet,osc"`
|
||||
// Device is the target device name this source controls.
|
||||
Device string `fig:"device" yaml:"device"`
|
||||
// Priority arbitrates between sources; a higher-priority source that is active
|
||||
// locks out lower-priority ones (e.g. a live DMX stream over MQTT).
|
||||
Priority int `fig:"priority" yaml:"priority" default:"0"`
|
||||
// HoldSec is how long after its last update a source stays "active" for the
|
||||
// purpose of locking out lower-priority sources.
|
||||
HoldSec float64 `fig:"hold_sec" yaml:"hold_sec" default:"5"`
|
||||
// Fade overrides the zone fade time this source applies when setting levels
|
||||
// (format "SS", "MM:SS", or "HH:MM:SS"). When empty, DMX sources (sacn/artnet)
|
||||
// default to instant ("00:00") so the console owns the crossfade, and other
|
||||
// sources use the device's configured fade.
|
||||
Fade string `fig:"fade" yaml:"fade"`
|
||||
|
||||
MQTT MQTTConfig `fig:"mqtt" yaml:"mqtt"`
|
||||
SACN SACNConfig `fig:"sacn" yaml:"sacn"`
|
||||
ArtNet ArtNetConfig `fig:"artnet" yaml:"artnet"`
|
||||
OSC OSCConfig `fig:"osc" yaml:"osc"`
|
||||
}
|
||||
|
||||
// OSCConfig holds settings for an OSC (Open Sound Control) source. The server
|
||||
// listens for UDP messages whose address, under the configured prefix, selects an
|
||||
// operation (zone level, raise/lower/stop, scene, shade, lock, sequence). See the
|
||||
// example config for the address scheme.
|
||||
type OSCConfig struct {
|
||||
// Listen is the host:port the OSC server binds.
|
||||
Listen string `fig:"listen" yaml:"listen" default:"0.0.0.0:9000"`
|
||||
// Prefix is the OSC address namespace this source responds under.
|
||||
Prefix string `fig:"prefix" yaml:"prefix" default:"/lutron"`
|
||||
// Monitor selects which panel monitoring this source streams out as OSC.
|
||||
Monitor MonitorForward `fig:"monitor" yaml:"monitor"`
|
||||
// StreamTo lists host:port destinations the monitoring feedback is sent to.
|
||||
// Streaming is off when empty.
|
||||
StreamTo []string `fig:"stream_to" yaml:"stream_to"`
|
||||
// LevelAsFloat sends zone levels as a 0-1 float (matching the input level
|
||||
// convention) when true; false sends a raw 0-255 integer. Defaults to true.
|
||||
LevelAsFloat *bool `fig:"level_as_float" yaml:"level_as_float" default:"true"`
|
||||
}
|
||||
|
||||
// MQTTConfig holds settings for an MQTT control source, including the optional
|
||||
// Home Assistant discovery announcement.
|
||||
type MQTTConfig struct {
|
||||
// Broker hostname or IP of the MQTT broker.
|
||||
Broker string `fig:"broker" yaml:"broker"`
|
||||
// Port of the MQTT broker.
|
||||
Port int `fig:"port" yaml:"port" default:"1883"`
|
||||
// Topic is the base state topic; commands are received on <topic>/set.
|
||||
Topic string `fig:"topic" yaml:"topic"`
|
||||
// Username for MQTT authentication.
|
||||
Username string `fig:"username" yaml:"username"`
|
||||
// Password for MQTT authentication.
|
||||
Password string `fig:"password" yaml:"password"`
|
||||
// ClientID for the MQTT connection; auto-generated when empty.
|
||||
ClientID string `fig:"client_id" yaml:"client_id"`
|
||||
// Discovery publishes a Home Assistant MQTT discovery config when true.
|
||||
Discovery bool `fig:"discovery" yaml:"discovery"`
|
||||
// DiscoveryPrefix is the Home Assistant discovery topic prefix.
|
||||
DiscoveryPrefix string `fig:"discovery_prefix" yaml:"discovery_prefix" default:"homeassistant"`
|
||||
// DeviceName is the friendly name announced to Home Assistant.
|
||||
DeviceName string `fig:"device_name" yaml:"device_name" default:"Lutron"`
|
||||
// Scenes, when > 0, exposes a scene selector (1..Scenes) over MQTT and, with
|
||||
// discovery, a Home Assistant select entity. Commands arrive on <topic>/scene/set
|
||||
// and the active scene is published to <topic>/scene.
|
||||
Scenes int `fig:"scenes" yaml:"scenes" default:"0"`
|
||||
// Shades, when > 0, exposes that many shade columns (1-3) as Home Assistant
|
||||
// cover entities (open/close/stop). Commands arrive on <topic>/shade/<n>/set.
|
||||
Shades int `fig:"shades" yaml:"shades" default:"0"`
|
||||
// ZoneRamp exposes raise/lower/stop buttons for each light's zones.
|
||||
ZoneRamp bool `fig:"zone_ramp" yaml:"zone_ramp"`
|
||||
// ZoneLock exposes a zone-lock switch (QS Standalone).
|
||||
ZoneLock bool `fig:"zone_lock" yaml:"zone_lock"`
|
||||
// SceneLock exposes a scene-lock switch (QS Standalone).
|
||||
SceneLock bool `fig:"scene_lock" yaml:"scene_lock"`
|
||||
// Sequence exposes a scene-sequence select (Off / Scenes 1-4 / Scenes 5-16).
|
||||
Sequence bool `fig:"sequence" yaml:"sequence"`
|
||||
// Lights defines the individual lights exposed by this source, each driving
|
||||
// its own set of zones. When omitted, a single light is synthesized from the
|
||||
// base topic and device_name driving every zone.
|
||||
Lights []MQTTLightConfig `fig:"lights" yaml:"lights"`
|
||||
// Monitor selects which panel monitoring this source publishes to MQTT.
|
||||
Monitor MonitorForward `fig:"monitor" yaml:"monitor"`
|
||||
// MonitorPrefix is the sub-topic monitoring is published under, i.e.
|
||||
// <topic>/<MonitorPrefix>/<family>/...
|
||||
MonitorPrefix string `fig:"monitor_prefix" yaml:"monitor_prefix" default:"monitor"`
|
||||
}
|
||||
|
||||
// MQTTLightConfig describes one Home Assistant light exposed by an MQTT source and
|
||||
// the set of QSE zones it controls. The light's brightness drives every zone in
|
||||
// the set; the first zone is mirrored back as the aggregate state.
|
||||
type MQTTLightConfig struct {
|
||||
// Name is the friendly name announced to Home Assistant; defaults to the
|
||||
// source's device_name when empty.
|
||||
Name string `fig:"name" yaml:"name"`
|
||||
// Topic is the base state topic for this light; commands arrive on <topic>/set.
|
||||
Topic string `fig:"topic" yaml:"topic"`
|
||||
// Zones lists the 1-indexed QSE zones this light controls together.
|
||||
Zones []int `fig:"zones" yaml:"zones"`
|
||||
}
|
||||
|
||||
// SACNConfig holds settings for an sACN (E1.31) control source.
|
||||
type SACNConfig struct {
|
||||
// Bind address for the receiver socket ("" binds all interfaces).
|
||||
Bind string `fig:"bind" yaml:"bind"`
|
||||
// Interface is the network interface name used for multicast (optional).
|
||||
Interface string `fig:"interface" yaml:"interface"`
|
||||
// Universe is the sACN universe to join.
|
||||
Universe uint16 `fig:"universe" yaml:"universe" default:"1"`
|
||||
|
||||
DMXMap `fig:",squash"`
|
||||
}
|
||||
|
||||
// ArtNetConfig holds settings for an Art-Net control source.
|
||||
type ArtNetConfig struct {
|
||||
// Bind is the IP the Art-Net node listens on.
|
||||
Bind string `fig:"bind" yaml:"bind" default:"0.0.0.0"`
|
||||
// Net is the Art-Net Net (top 7 bits of the 15-bit port address).
|
||||
Net uint8 `fig:"net" yaml:"net" default:"0"`
|
||||
// SubNet is the high nibble of the low byte of the port address.
|
||||
SubNet uint8 `fig:"subnet" yaml:"subnet" default:"0"`
|
||||
// Universe is the low nibble of the low byte of the port address.
|
||||
Universe uint8 `fig:"universe" yaml:"universe" default:"0"`
|
||||
// TimeoutSec is how long the universe may be silent before the source is
|
||||
// treated as lost: its zones black out and its arbitration lock is released.
|
||||
// Art-Net senders may slow retransmission of an unchanged look to once every
|
||||
// ~4 s, so the default leaves margin above that.
|
||||
TimeoutSec float64 `fig:"timeout_sec" yaml:"timeout_sec" default:"5"`
|
||||
|
||||
DMXMap `fig:",squash"`
|
||||
}
|
||||
|
||||
// DMXMap describes how a DMX universe's channels map to a device's zones and
|
||||
// scenes. Use the sequential layout (start_address plus zone/scene counts) for
|
||||
// the common case, or an explicit channels list for full control. When channels
|
||||
// is set it takes precedence over the sequential layout.
|
||||
type DMXMap struct {
|
||||
// StartAddress is the 0-indexed channel of the first zone in the sequential
|
||||
// layout. Zones occupy start_address.. and scenes follow them.
|
||||
StartAddress int `fig:"start_address" yaml:"start_address" default:"0"`
|
||||
// Zones is the number of sequential zone channels (0 = the device's zone count).
|
||||
Zones int `fig:"zones" yaml:"zones" default:"0"`
|
||||
// Scenes, when > 0, adds one scene-select channel after the zones; its value
|
||||
// (1..Scenes) selects the scene to trigger and 0 means no action.
|
||||
Scenes int `fig:"scenes" yaml:"scenes" default:"0"`
|
||||
// Channels is an explicit per-channel map; when set it replaces the sequential
|
||||
// layout above.
|
||||
Channels []DMXChannelConfig `fig:"channels" yaml:"channels"`
|
||||
}
|
||||
|
||||
// DMXChannelConfig maps one DMX channel to a zone level or scene selection.
|
||||
type DMXChannelConfig struct {
|
||||
// Channel is the 1-indexed DMX address (1-512).
|
||||
Channel int `fig:"channel" yaml:"channel"`
|
||||
// Type selects what the channel controls: "zone" sets a zone level, "scene" is a
|
||||
// scene-select channel whose value triggers the matching scene.
|
||||
Type string `fig:"type" yaml:"type" enum:"zone,scene"`
|
||||
// Zone is the target zone number for type "zone".
|
||||
Zone int `fig:"zone" yaml:"zone"`
|
||||
}
|
||||
|
||||
// Apply configures the global logger from the log configuration.
|
||||
func (l *LogConfig) Apply() {
|
||||
// Apply the level.
|
||||
switch l.Level {
|
||||
case "debug":
|
||||
log.SetLevel(log.DebugLevel)
|
||||
case "info":
|
||||
log.SetLevel(log.InfoLevel)
|
||||
case "warn":
|
||||
log.SetLevel(log.WarnLevel)
|
||||
default:
|
||||
log.SetLevel(log.ErrorLevel)
|
||||
}
|
||||
|
||||
// Apply the formatter.
|
||||
switch l.Type {
|
||||
case "json":
|
||||
log.SetFormatter(&log.JSONFormatter{})
|
||||
default:
|
||||
log.SetFormatter(&log.TextFormatter{})
|
||||
}
|
||||
|
||||
// Resolve and attach each configured output.
|
||||
var outputs []io.Writer
|
||||
for _, output := range l.Outputs {
|
||||
// Console output goes to stderr.
|
||||
if output == "console" {
|
||||
outputs = append(outputs, os.Stderr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve the default file location when requested.
|
||||
if output == "default-file" {
|
||||
resolved, ok := defaultLogPath()
|
||||
if !ok {
|
||||
log.Println("Unable to find a writable log path to save log to.")
|
||||
continue
|
||||
}
|
||||
output = resolved
|
||||
}
|
||||
|
||||
// Rotate the file output via lumberjack.
|
||||
outputs = append(outputs, &lumberjack.Logger{
|
||||
Filename: output,
|
||||
MaxSize: l.MaxSize,
|
||||
MaxBackups: l.MaxBackups,
|
||||
MaxAge: l.MaxAge,
|
||||
LocalTime: *l.LocalTime,
|
||||
Compress: *l.Compress,
|
||||
})
|
||||
}
|
||||
|
||||
if len(outputs) != 0 {
|
||||
log.SetOutput(io.MultiWriter(outputs...))
|
||||
}
|
||||
}
|
||||
|
||||
// defaultLogPath returns a writable path for the `default-file` output, trying
|
||||
// /var/log first and falling back to beside the executable.
|
||||
func defaultLogPath() (string, bool) {
|
||||
logName := fmt.Sprintf("%s.log", serviceName)
|
||||
|
||||
// On *nix, prefer /var/log when writable.
|
||||
if runtime.GOOS != "windows" {
|
||||
logPath := filepath.Join("/var/log", logName)
|
||||
if f, err := os.OpenFile(logPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644); err == nil {
|
||||
f.Close()
|
||||
return logPath, true
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise fall back to the executable's directory.
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
logPath := filepath.Join(filepath.Dir(exe), logName)
|
||||
if f, err := os.OpenFile(logPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644); err == nil {
|
||||
f.Close()
|
||||
return logPath, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ReadConfig loads, validates, and applies the configuration.
|
||||
func (a *App) ReadConfig() {
|
||||
usr, err := user.Current()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Configuration search paths.
|
||||
localConfig, _ := filepath.Abs("./config.yaml")
|
||||
homeDirConfig := filepath.Join(usr.HomeDir, ".config", serviceName, "config.yaml")
|
||||
etcConfig := filepath.Join("/etc", serviceName, "config.yaml")
|
||||
|
||||
// Determine which configuration file to use.
|
||||
var configFile string
|
||||
if app.flags.ConfigPath != "" {
|
||||
if _, err := os.Stat(app.flags.ConfigPath); err == nil {
|
||||
configFile = app.flags.ConfigPath
|
||||
}
|
||||
}
|
||||
if configFile == "" {
|
||||
for _, candidate := range []string{localConfig, homeDirConfig, etcConfig} {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
configFile = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if configFile == "" {
|
||||
log.Fatal("Unable to find a configuration file.")
|
||||
}
|
||||
|
||||
// Load the configuration file.
|
||||
config := &Config{Log: &LogConfig{}}
|
||||
filePath, fileName := path.Split(configFile)
|
||||
if filePath == "" {
|
||||
filePath = "."
|
||||
}
|
||||
err = fig.Load(config, fig.File(fileName), fig.Dirs(filePath))
|
||||
if err != nil {
|
||||
log.Fatalf("Error parsing configuration: %s", err)
|
||||
}
|
||||
|
||||
// Validate cross-field constraints fig can't express in tags.
|
||||
if err := config.Validate(); err != nil {
|
||||
log.Fatalf("Invalid configuration: %s", err)
|
||||
}
|
||||
|
||||
// The verbose flag forces debug-level logging and ensures the console output
|
||||
// is present, overriding the configured log level so debug output is visible.
|
||||
if app.flags.Verbose {
|
||||
config.Log.Level = "debug"
|
||||
if !slices.Contains(config.Log.Outputs, "console") {
|
||||
config.Log.Outputs = append(config.Log.Outputs, "console")
|
||||
}
|
||||
}
|
||||
|
||||
// Apply log configuration and store globally.
|
||||
config.Log.Apply()
|
||||
app.config = config
|
||||
log.Infof("Loaded configuration from %s", configFile)
|
||||
}
|
||||
|
||||
// Validate checks structural constraints across the configuration.
|
||||
func (c *Config) Validate() error {
|
||||
if len(c.Devices) == 0 {
|
||||
return fmt.Errorf("no devices configured")
|
||||
}
|
||||
|
||||
// Validate devices and index them by name.
|
||||
devices := make(map[string]*DeviceConfig, len(c.Devices))
|
||||
for i, d := range c.Devices {
|
||||
if d.Name == "" {
|
||||
return fmt.Errorf("device %d: name is required", i)
|
||||
}
|
||||
if devices[d.Name] != nil {
|
||||
return fmt.Errorf("duplicate device name %q", d.Name)
|
||||
}
|
||||
devices[d.Name] = d
|
||||
|
||||
switch d.Transport {
|
||||
case "serial":
|
||||
if d.Serial.Device == "" {
|
||||
return fmt.Errorf("device %q: serial.device is required", d.Name)
|
||||
}
|
||||
case "telnet":
|
||||
if d.Telnet.Address == "" {
|
||||
return fmt.Errorf("device %q: telnet.address is required", d.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if d.Zones < 1 || d.Zones > 24 {
|
||||
return fmt.Errorf("device %q: zones must be 1-24", d.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate sources reference an existing device.
|
||||
for i, s := range c.Sources {
|
||||
if s.Name == "" {
|
||||
return fmt.Errorf("source %d: name is required", i)
|
||||
}
|
||||
dev := devices[s.Device]
|
||||
if dev == nil {
|
||||
return fmt.Errorf("source %q: unknown device %q", s.Name, s.Device)
|
||||
}
|
||||
if s.Type == "mqtt" {
|
||||
if s.MQTT.Broker == "" {
|
||||
return fmt.Errorf("source %q: mqtt.broker is required", s.Name)
|
||||
}
|
||||
// A base topic is needed for the synthesized single light and for the
|
||||
// scene selector; per-light topics cover the multi-light case.
|
||||
if len(s.MQTT.Lights) == 0 && s.MQTT.Topic == "" {
|
||||
return fmt.Errorf("source %q: mqtt.topic is required", s.Name)
|
||||
}
|
||||
if s.MQTT.Scenes > 0 && s.MQTT.Topic == "" {
|
||||
return fmt.Errorf("source %q: mqtt.topic is required when scenes are enabled", s.Name)
|
||||
}
|
||||
if s.MQTT.Shades < 0 || s.MQTT.Shades > 3 {
|
||||
return fmt.Errorf("source %q: mqtt.shades must be 0-3", s.Name)
|
||||
}
|
||||
// The shade/lock/sequence controls are rooted on the base topic.
|
||||
if (s.MQTT.Shades > 0 || s.MQTT.ZoneLock || s.MQTT.SceneLock || s.MQTT.Sequence) && s.MQTT.Topic == "" {
|
||||
return fmt.Errorf("source %q: mqtt.topic is required for shade/lock/sequence controls", s.Name)
|
||||
}
|
||||
// Validate each configured light's topic and zone set.
|
||||
seen := make(map[string]bool, len(s.MQTT.Lights))
|
||||
for j, l := range s.MQTT.Lights {
|
||||
if l.Topic == "" {
|
||||
return fmt.Errorf("source %q: mqtt.lights[%d].topic is required", s.Name, j)
|
||||
}
|
||||
if seen[l.Topic] {
|
||||
return fmt.Errorf("source %q: duplicate mqtt light topic %q", s.Name, l.Topic)
|
||||
}
|
||||
seen[l.Topic] = true
|
||||
if len(l.Zones) == 0 {
|
||||
return fmt.Errorf("source %q: mqtt light %q controls no zones", s.Name, l.Topic)
|
||||
}
|
||||
for _, z := range l.Zones {
|
||||
if z < 1 || z > dev.Zones {
|
||||
return fmt.Errorf("source %q: mqtt light %q zone %d out of range 1-%d", s.Name, l.Topic, z, dev.Zones)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
115
config_test.go
Normal file
115
config_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kkyr/fig"
|
||||
)
|
||||
|
||||
// TestExampleConfigValidates ensures the shipped config.example.yaml parses and
|
||||
// passes cross-field validation, so the documented options stay in sync with the
|
||||
// config structs.
|
||||
func TestExampleConfigValidates(t *testing.T) {
|
||||
cfg := &Config{Log: &LogConfig{}}
|
||||
if err := fig.Load(cfg, fig.File("config.example.yaml"), fig.Dirs(".")); err != nil {
|
||||
t.Fatalf("parse config.example.yaml: %s", err)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("validate config.example.yaml: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// baseConfig returns a minimal valid configuration with one serial device and an
|
||||
// MQTT source, ready for per-test mutation.
|
||||
func baseConfig() *Config {
|
||||
return &Config{
|
||||
Devices: []*DeviceConfig{{
|
||||
Name: "grafik-eye",
|
||||
Transport: "serial",
|
||||
Serial: SerialConfig{Device: "/dev/ttyUSB0"},
|
||||
Zones: 6,
|
||||
}},
|
||||
Sources: []*SourceConfig{{
|
||||
Name: "home-assistant",
|
||||
Type: "mqtt",
|
||||
Device: "grafik-eye",
|
||||
MQTT: MQTTConfig{Broker: "127.0.0.1", Topic: "lutron/qse-nwk"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMQTTLights verifies a light controlling zones 1-6 validates and that
|
||||
// a zone outside the device's range is rejected.
|
||||
func TestValidateMQTTLights(t *testing.T) {
|
||||
c := baseConfig()
|
||||
c.Sources[0].MQTT.Lights = []MQTTLightConfig{
|
||||
{Name: "All Zones", Topic: "lutron/qse-nwk/all", Zones: []int{1, 2, 3, 4, 5, 6}},
|
||||
}
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Fatalf("valid lights config rejected: %s", err)
|
||||
}
|
||||
|
||||
// A zone beyond the device's 6 zones must be rejected.
|
||||
c.Sources[0].MQTT.Lights[0].Zones = []int{6, 7}
|
||||
if err := c.Validate(); err == nil {
|
||||
t.Error("expected error for out-of-range zone")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMQTTLightRequirements verifies per-light topic, zone-set, and
|
||||
// duplicate-topic constraints.
|
||||
func TestValidateMQTTLightRequirements(t *testing.T) {
|
||||
// A light without a topic is rejected.
|
||||
c := baseConfig()
|
||||
c.Sources[0].MQTT.Lights = []MQTTLightConfig{{Zones: []int{1}}}
|
||||
if err := c.Validate(); err == nil {
|
||||
t.Error("expected error for missing light topic")
|
||||
}
|
||||
|
||||
// A light with no zones is rejected.
|
||||
c = baseConfig()
|
||||
c.Sources[0].MQTT.Lights = []MQTTLightConfig{{Topic: "lutron/a"}}
|
||||
if err := c.Validate(); err == nil {
|
||||
t.Error("expected error for light with no zones")
|
||||
}
|
||||
|
||||
// Two lights sharing a topic are rejected.
|
||||
c = baseConfig()
|
||||
c.Sources[0].MQTT.Lights = []MQTTLightConfig{
|
||||
{Topic: "lutron/a", Zones: []int{1}},
|
||||
{Topic: "lutron/a", Zones: []int{2}},
|
||||
}
|
||||
if err := c.Validate(); err == nil {
|
||||
t.Error("expected error for duplicate light topic")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMQTTSourceSynthesizesLight verifies that, with no lights configured, a
|
||||
// single light over every zone is synthesized from the base topic.
|
||||
func TestNewMQTTSourceSynthesizesLight(t *testing.T) {
|
||||
cfg := &SourceConfig{
|
||||
Name: "home-assistant",
|
||||
Type: "mqtt",
|
||||
MQTT: MQTTConfig{Broker: "127.0.0.1", Topic: "lutron/qse-nwk", DeviceName: "Lutron"},
|
||||
}
|
||||
dev := NewDevice(&DeviceConfig{Name: "grafik-eye", Zones: 6})
|
||||
binding := dev.RegisterSource(cfg.Name, cfg.Priority, 0, cfg.Fade)
|
||||
|
||||
s := newMQTTSource(cfg, dev, binding)
|
||||
if len(s.lights) != 1 {
|
||||
t.Fatalf("expected 1 synthesized light, got %d", len(s.lights))
|
||||
}
|
||||
l := s.lights[0]
|
||||
if l.topic != "lutron/qse-nwk" || l.topicSet != "lutron/qse-nwk/set" {
|
||||
t.Errorf("unexpected light topics: %q / %q", l.topic, l.topicSet)
|
||||
}
|
||||
want := []int{1, 2, 3, 4, 5, 6}
|
||||
if len(l.zones) != len(want) {
|
||||
t.Fatalf("zones = %v, want %v", l.zones, want)
|
||||
}
|
||||
for i, z := range want {
|
||||
if l.zones[i] != z {
|
||||
t.Errorf("zones[%d] = %d, want %d", i, l.zones[i], z)
|
||||
}
|
||||
}
|
||||
}
|
||||
420
device_test.go
Normal file
420
device_test.go
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeTransport records the time and payload of each write for pacing and
|
||||
// content assertions.
|
||||
type fakeTransport struct {
|
||||
mu sync.Mutex
|
||||
writes []time.Time
|
||||
payloads []string
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func newFakeTransport() *fakeTransport {
|
||||
return &fakeTransport{closed: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (f *fakeTransport) Write(p []byte) (int, error) {
|
||||
f.mu.Lock()
|
||||
f.writes = append(f.writes, time.Now())
|
||||
f.payloads = append(f.payloads, string(p))
|
||||
f.mu.Unlock()
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// sent returns a snapshot of the payloads written so far.
|
||||
func (f *fakeTransport) sent() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.payloads...)
|
||||
}
|
||||
|
||||
func (f *fakeTransport) Read(p []byte) (int, error) {
|
||||
<-f.closed
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (f *fakeTransport) Close() error {
|
||||
close(f.closed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestWritePacing verifies that consecutive zone-level sets burst together while
|
||||
// queries and differing commands still honor the protocol's minimum inter-message
|
||||
// delays: at least 100 ms after a command ("#") and 1500 ms after a query ("?").
|
||||
func TestWritePacing(t *testing.T) {
|
||||
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1, Fade: "00:00"})
|
||||
d.ctx = context.Background()
|
||||
ft := newFakeTransport()
|
||||
d.conn = ft
|
||||
|
||||
// Zone-value sets burst with no delay so every zone changes at once.
|
||||
d.write("#DEVICE,1,1,14,50.00,00:00\r\n") // w0
|
||||
d.write("#DEVICE,1,2,14,50.00,00:00\r\n") // w1: bursts after w0
|
||||
// A zone query does not burst: the protocol mandates query spacing, so it must
|
||||
// wait at least the command delay owed by the preceding set.
|
||||
d.write("?DEVICE,1,1,14\r\n") // w2
|
||||
// A differing command after a zone query must wait the longer query delay.
|
||||
d.write("#MONITORING,5,1\r\n") // w3
|
||||
// A second, differing command must wait the command delay.
|
||||
d.write("#MONITORING,11,1\r\n") // w4
|
||||
|
||||
ft.mu.Lock()
|
||||
writes := ft.writes
|
||||
ft.mu.Unlock()
|
||||
|
||||
if len(writes) != 5 {
|
||||
t.Fatalf("expected 5 writes, got %d", len(writes))
|
||||
}
|
||||
if gap := writes[1].Sub(writes[0]); gap >= commandInterMessageDelay {
|
||||
t.Errorf("zone set->set should burst, gap %v >= %v", gap, commandInterMessageDelay)
|
||||
}
|
||||
if gap := writes[2].Sub(writes[1]); gap < commandInterMessageDelay {
|
||||
t.Errorf("zone set->query must not burst, gap %v < %v", gap, commandInterMessageDelay)
|
||||
}
|
||||
if gap := writes[3].Sub(writes[2]); gap < queryInterMessageDelay {
|
||||
t.Errorf("query->command gap %v < %v", gap, queryInterMessageDelay)
|
||||
}
|
||||
if gap := writes[4].Sub(writes[3]); gap < commandInterMessageDelay {
|
||||
t.Errorf("command->command gap %v < %v", gap, commandInterMessageDelay)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolicitedReplyDoesNotOverrideTarget verifies a reply to our own zone query
|
||||
// (e.g. the watchdog liveness probe) does not pull the maintained target to the
|
||||
// reported level, even when it disagrees.
|
||||
func TestSolicitedReplyDoesNotOverrideTarget(t *testing.T) {
|
||||
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1})
|
||||
b := d.RegisterSource("s", 0, 5*time.Second, "")
|
||||
|
||||
// Source drives zone 1 to ~55%; a watchdog-style query then gets an "off" reply.
|
||||
if !d.ApplyZoneLevels(b, map[int]byte{1: 140}) {
|
||||
t.Fatal("ApplyZoneLevels returned false")
|
||||
}
|
||||
d.queryZone(1)
|
||||
d.handleLine("~DEVICE,1,1,14,0.00")
|
||||
|
||||
if tg := d.ZoneTargets(); tg[0] != 140 {
|
||||
t.Errorf("zone 1 target = %d, want 140 (solicited reply must not override)", tg[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnsolicitedReportFollowed verifies an unsolicited monitoring report (an
|
||||
// external keypad or scene change) is adopted as the new target.
|
||||
func TestUnsolicitedReportFollowed(t *testing.T) {
|
||||
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1})
|
||||
d.RegisterSource("s", 0, time.Second, "")
|
||||
|
||||
d.handleLine("~DEVICE,1,2,14,100.00")
|
||||
|
||||
if tg := d.ZoneTargets(); tg[1] != 255 {
|
||||
t.Errorf("zone 2 target = %d, want 255 (followed external change)", tg[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEchoOfOwnWriteNotAdopted verifies a report arriving just after we wrote a
|
||||
// zone is treated as our own echo and does not move the target, even though the
|
||||
// panel quantized the reported level to a value that differs from what we sent.
|
||||
func TestEchoOfOwnWriteNotAdopted(t *testing.T) {
|
||||
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1})
|
||||
b := d.RegisterSource("s", 0, 5*time.Second, "")
|
||||
|
||||
// Source drives zone 1 to byte 7 (~2.75%); we then "write" it and the panel
|
||||
// echoes back its own rounding (3.14% = byte 8).
|
||||
if !d.ApplyZoneLevels(b, map[int]byte{1: 7}) {
|
||||
t.Fatal("ApplyZoneLevels returned false")
|
||||
}
|
||||
d.lastZoneWrite[0] = time.Now()
|
||||
d.handleLine("~DEVICE,1,1,14,3.14")
|
||||
|
||||
if tg := d.ZoneTargets(); tg[0] != 7 {
|
||||
t.Errorf("zone 1 target = %d, want 7 (echo of our write must not override)", tg[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyZoneLevelsTouchesOnlyMappedZones verifies a source only drives the
|
||||
// zones it names, leaving the rest for another source to own.
|
||||
func TestApplyZoneLevelsTouchesOnlyMappedZones(t *testing.T) {
|
||||
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6})
|
||||
b := d.RegisterSource("s", 0, time.Second, "")
|
||||
|
||||
if !d.ApplyZoneLevels(b, map[int]byte{1: 100, 2: 100, 3: 100}) {
|
||||
t.Fatal("ApplyZoneLevels returned false")
|
||||
}
|
||||
tg := d.ZoneTargets()
|
||||
for z := 1; z <= 3; z++ {
|
||||
if tg[z-1] != 100 {
|
||||
t.Errorf("zone %d = %d, want 100", z, tg[z-1])
|
||||
}
|
||||
}
|
||||
for z := 4; z <= 6; z++ {
|
||||
if tg[z-1] != 0 {
|
||||
t.Errorf("unmapped zone %d = %d, want untouched 0", z, tg[z-1])
|
||||
}
|
||||
}
|
||||
|
||||
// Driving the upper zones must not disturb the lower ones.
|
||||
if !d.ApplyZoneLevels(b, map[int]byte{4: 50, 5: 50, 6: 50}) {
|
||||
t.Fatal("ApplyZoneLevels returned false")
|
||||
}
|
||||
tg = d.ZoneTargets()
|
||||
if tg[0] != 100 || tg[5] != 50 {
|
||||
t.Errorf("zones = %v, want zone1=100 zone6=50", tg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyZoneLevelsRefusedWhenControlDisabled verifies commands are rejected
|
||||
// (not silently swallowed) while the panel has integration control disabled.
|
||||
func TestApplyZoneLevelsRefusedWhenControlDisabled(t *testing.T) {
|
||||
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6})
|
||||
b := d.RegisterSource("s", 0, time.Second, "")
|
||||
d.controlDisabled = true
|
||||
|
||||
if d.ApplyZoneLevels(b, map[int]byte{1: 100}) {
|
||||
t.Error("expected ApplyZoneLevels to return false while control is disabled")
|
||||
}
|
||||
if tg := d.ZoneTargets(); tg[0] != 0 {
|
||||
t.Errorf("zone 1 = %d, want unchanged 0", tg[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestControlSignalRequiresConfiguredComponent verifies the enable/disable
|
||||
// phantom-button interception is off unless a non-zero component is configured.
|
||||
func TestControlSignalRequiresConfiguredComponent(t *testing.T) {
|
||||
// Unconfigured (component 0): a matching-looking press is ignored.
|
||||
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1})
|
||||
d.handleLine("~DEVICE,1,0,3")
|
||||
if d.controlDisabled {
|
||||
t.Error("control disabled with no component configured")
|
||||
}
|
||||
|
||||
// Configured: the disable press disables and the enable press re-enables.
|
||||
d = NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1,
|
||||
DisableComponent: 74, EnableComponent: 75})
|
||||
d.handleLine("~DEVICE,1,74,3")
|
||||
if !d.controlDisabled {
|
||||
t.Error("expected control disabled after configured disable press")
|
||||
}
|
||||
d.handleLine("~DEVICE,1,75,3")
|
||||
if d.controlDisabled {
|
||||
t.Error("expected control re-enabled after configured enable press")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDMXMapSequential verifies the sequential layout lays the zones out
|
||||
// contiguously from the start address, then a single scene-select channel.
|
||||
func TestBuildDMXMapSequential(t *testing.T) {
|
||||
zones, scenes, err := buildDMXMap(DMXMap{StartAddress: 2, Zones: 3, Scenes: 4}, 6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantZones := []dmxBinding{{2, 1}, {3, 2}, {4, 3}}
|
||||
wantScenes := []sceneSelectBinding{{channel: 5, maxScene: 4}}
|
||||
if len(zones) != 3 || zones[0] != wantZones[0] || zones[2] != wantZones[2] {
|
||||
t.Errorf("zones = %v, want %v", zones, wantZones)
|
||||
}
|
||||
if len(scenes) != 1 || scenes[0] != wantScenes[0] {
|
||||
t.Errorf("scenes = %v, want %v", scenes, wantScenes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDMXMapExplicit verifies an explicit channel map (1-indexed addresses)
|
||||
// and that an out-of-range zone is rejected.
|
||||
func TestBuildDMXMapExplicit(t *testing.T) {
|
||||
zones, scenes, err := buildDMXMap(DMXMap{Channels: []DMXChannelConfig{
|
||||
{Channel: 1, Type: "zone", Zone: 1},
|
||||
{Channel: 7, Type: "scene"},
|
||||
}}, 6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(zones) != 1 || zones[0] != (dmxBinding{0, 1}) {
|
||||
t.Errorf("zones = %v, want [{0 1}]", zones)
|
||||
}
|
||||
if len(scenes) != 1 || scenes[0] != (sceneSelectBinding{channel: 6, maxScene: qseMaxScene}) {
|
||||
t.Errorf("scenes = %v, want [{6 %d}]", scenes, qseMaxScene)
|
||||
}
|
||||
if _, _, err := buildDMXMap(DMXMap{Channels: []DMXChannelConfig{
|
||||
{Channel: 1, Type: "zone", Zone: 99},
|
||||
}}, 6); err == nil {
|
||||
t.Error("expected error for out-of-range zone")
|
||||
}
|
||||
}
|
||||
|
||||
// containsSubstr reports whether any payload contains sub.
|
||||
func containsSubstr(payloads []string, sub string) bool {
|
||||
for _, p := range payloads {
|
||||
if strings.Contains(p, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestPerSourceFade verifies a source's configured fade is used for its zone
|
||||
// writes instead of the device default.
|
||||
func TestPerSourceFade(t *testing.T) {
|
||||
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1, Fade: "00:00"})
|
||||
d.ctx = context.Background()
|
||||
ft := newFakeTransport()
|
||||
d.conn = ft
|
||||
b := d.RegisterSource("s", 0, time.Second, "00:04")
|
||||
|
||||
if !d.ApplyZoneLevels(b, map[int]byte{1: 255}) {
|
||||
t.Fatal("ApplyZoneLevels returned false")
|
||||
}
|
||||
d.flushZones()
|
||||
|
||||
if !containsSubstr(ft.sent(), "#DEVICE,1,1,14,100.00,00:04") {
|
||||
t.Errorf("expected zone write with source fade 00:04, got %v", ft.sent())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRaiseSuppressesZoneWrites verifies that while a zone is being raised the
|
||||
// writer leaves it alone, and that a stop holds the zone through its grace window
|
||||
// so the writer can't yank it back to a stale target.
|
||||
func TestRaiseSuppressesZoneWrites(t *testing.T) {
|
||||
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1, Fade: "00:00"})
|
||||
d.ctx = context.Background()
|
||||
ft := newFakeTransport()
|
||||
d.conn = ft
|
||||
b := d.RegisterSource("s", 0, time.Second, "")
|
||||
|
||||
// Establish a known sent level for zone 1.
|
||||
d.ApplyZoneLevels(b, map[int]byte{1: 200})
|
||||
d.flushZones()
|
||||
|
||||
// Begin raising zone 1, then create a target/sent divergence the writer would
|
||||
// normally flush.
|
||||
if !d.RaiseZone(b, 1) {
|
||||
t.Fatal("RaiseZone returned false")
|
||||
}
|
||||
if !containsSubstr(ft.sent(), "#DEVICE,1,1,18") {
|
||||
t.Errorf("expected a start-raising command, got %v", ft.sent())
|
||||
}
|
||||
d.dataMu.Lock()
|
||||
d.target[0] = 10
|
||||
d.dataMu.Unlock()
|
||||
|
||||
before := len(ft.sent())
|
||||
d.flushZones()
|
||||
if got := len(ft.sent()); got != before {
|
||||
t.Errorf("writer wrote %d commands while ramping; expected none", got-before)
|
||||
}
|
||||
|
||||
// Stop holds the zone through the grace window.
|
||||
if !d.StopZone(b, 1) {
|
||||
t.Fatal("StopZone returned false")
|
||||
}
|
||||
if !containsSubstr(ft.sent(), "#DEVICE,1,1,20") {
|
||||
t.Errorf("expected a stop command, got %v", ft.sent())
|
||||
}
|
||||
before = len(ft.sent())
|
||||
d.flushZones()
|
||||
if got := len(ft.sent()); got != before {
|
||||
t.Errorf("writer wrote %d commands during stop grace; expected none", got-before)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShadeAndSequenceCommands verifies the shade and sequence helpers emit the
|
||||
// expected integration commands.
|
||||
func TestShadeAndSequenceCommands(t *testing.T) {
|
||||
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 2})
|
||||
d.ctx = context.Background()
|
||||
ft := newFakeTransport()
|
||||
d.conn = ft
|
||||
b := d.RegisterSource("s", 0, time.Second, "")
|
||||
|
||||
// Shade column 1 open is a press then release on component 38.
|
||||
if !d.ApplyShade(b, 1, "open") {
|
||||
t.Fatal("ApplyShade open returned false")
|
||||
}
|
||||
if !containsSubstr(ft.sent(), "#DEVICE,2,38,3") || !containsSubstr(ft.sent(), "#DEVICE,2,38,4") {
|
||||
t.Errorf("expected press+release on shade component 38, got %v", ft.sent())
|
||||
}
|
||||
|
||||
// Sequence through scenes 1-4 is scene controller action 17 with param 1.
|
||||
if !d.SetSequence(1) {
|
||||
t.Fatal("SetSequence returned false")
|
||||
}
|
||||
if !containsSubstr(ft.sent(), "#DEVICE,2,141,17,1") {
|
||||
t.Errorf("expected sequence command, got %v", ft.sent())
|
||||
}
|
||||
if d.SetSequence(9) {
|
||||
t.Error("SetSequence accepted an out-of-range mode")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMonitorFanOut verifies that every "~" report is fanned out to monitor
|
||||
// subscribers, parsed into a family and fields, including families the device
|
||||
// does not model itself (e.g. ~GROUP occupancy and ~ERROR).
|
||||
func TestMonitorFanOut(t *testing.T) {
|
||||
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1})
|
||||
d.RegisterSource("s", 0, time.Second, "")
|
||||
|
||||
var got []MonitorEvent
|
||||
d.OnMonitor(func(ev MonitorEvent) { got = append(got, ev) })
|
||||
|
||||
d.handleLine("~DEVICE,1,2,14,100.00") // Zone level.
|
||||
d.handleLine("~GROUP,1,3,3") // Occupancy: group 1 occupied.
|
||||
d.handleLine("~ERROR,4") // Error report.
|
||||
d.handleLine("QSE>") // Prompt only: must not fan out.
|
||||
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d events, want 3: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Family != "DEVICE" || len(got[0].Fields) != 4 || got[0].Fields[3] != "100.00" {
|
||||
t.Errorf("device event mismatch: %+v", got[0])
|
||||
}
|
||||
if got[1].Family != "GROUP" || got[1].Fields[2] != "3" {
|
||||
t.Errorf("group event mismatch: %+v", got[1])
|
||||
}
|
||||
if got[2].Family != "ERROR" || got[2].Raw != "~ERROR,4" {
|
||||
t.Errorf("error event mismatch: %+v", got[2])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupMonitoringEnablesRequestedUnion verifies that source-requested
|
||||
// monitoring types are enabled on connect alongside the built-in zone/reply
|
||||
// defaults, deduped against them.
|
||||
func TestSetupMonitoringEnablesRequestedUnion(t *testing.T) {
|
||||
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1})
|
||||
d.ctx = context.Background()
|
||||
ft := newFakeTransport()
|
||||
d.conn = ft
|
||||
|
||||
// Request button (3) and zone (5) monitoring; zone is also a built-in default.
|
||||
d.NoteMonitoring(qseMonitorZone, 3)
|
||||
d.setupMonitoring()
|
||||
|
||||
sent := ft.sent()
|
||||
if !containsSubstr(sent, "#MONITORING,5,1") || !containsSubstr(sent, "#MONITORING,11,1") {
|
||||
t.Errorf("expected built-in zone+reply monitoring enabled, got %v", sent)
|
||||
}
|
||||
if !containsSubstr(sent, "#MONITORING,3,1") {
|
||||
t.Errorf("expected requested button monitoring enabled, got %v", sent)
|
||||
}
|
||||
// Zone (5) must be enabled exactly once despite being both built-in and requested.
|
||||
if n := countSubstr(sent, "#MONITORING,5,1"); n != 1 {
|
||||
t.Errorf("zone monitoring enabled %d times, want 1: %v", n, sent)
|
||||
}
|
||||
}
|
||||
|
||||
// countSubstr counts how many payloads contain sub.
|
||||
func countSubstr(payloads []string, sub string) int {
|
||||
n := 0
|
||||
for _, p := range payloads {
|
||||
if strings.Contains(p, sub) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
65
discover.go
Normal file
65
discover.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// discoverWindow is how long to read integration responses per device.
|
||||
const discoverWindow = 3 * time.Second
|
||||
|
||||
// runDiscovery connects to each configured device, asks for its integration IDs,
|
||||
// prints the responses, and returns. It is a config aid, not part of normal
|
||||
// operation.
|
||||
func runDiscovery(devices []*DeviceConfig) {
|
||||
for _, dc := range devices {
|
||||
discoverDevice(dc)
|
||||
}
|
||||
}
|
||||
|
||||
// discoverDevice queries one device for its integration IDs and prints what it
|
||||
// reports.
|
||||
func discoverDevice(dc *DeviceConfig) {
|
||||
fmt.Printf("== %s (%s, integration_id %d) ==\n", dc.Name, dc.Transport, dc.IntegrationID)
|
||||
conn, err := openTransport(dc)
|
||||
if err != nil {
|
||||
fmt.Printf(" connect failed: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Print integration responses until the connection is closed.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
buf := make([]byte, 0, 256)
|
||||
tmp := make([]byte, 256)
|
||||
for {
|
||||
n, err := conn.Read(tmp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
for {
|
||||
i := bytes.IndexByte(buf, '\n')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
line := strings.TrimSpace(strings.ReplaceAll(string(buf[:i]), "QSE>", ""))
|
||||
buf = buf[i+1:]
|
||||
if line != "" {
|
||||
fmt.Printf(" %s\n", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// "?INTEGRATIONID,3" with no ID prints info for all integration IDs.
|
||||
if _, err := conn.Write([]byte("?INTEGRATIONID,3" + qseTerminator)); err != nil {
|
||||
fmt.Printf(" query failed: %s\n", err)
|
||||
}
|
||||
time.Sleep(discoverWindow)
|
||||
conn.Close()
|
||||
<-done
|
||||
}
|
||||
49
flags.go
Normal file
49
flags.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Flags supplied to the cli.
|
||||
type Flags struct {
|
||||
ConfigPath string
|
||||
Discover bool
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
// ParseFlags parses the supplied command-line flags.
|
||||
func (a *App) ParseFlags() {
|
||||
app.flags = new(Flags)
|
||||
flag.Usage = func() {
|
||||
fmt.Printf(serviceName + ": " + serviceDescription + ".\n\nUsage:\n")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
// Print the version and exit when requested.
|
||||
var printVersion bool
|
||||
flag.BoolVar(&printVersion, "v", false, "Print version")
|
||||
|
||||
// Override the configuration path.
|
||||
usage := "Load configuration from `FILE`"
|
||||
flag.StringVar(&app.flags.ConfigPath, "config", "", usage)
|
||||
flag.StringVar(&app.flags.ConfigPath, "c", "", usage+" (shorthand)")
|
||||
|
||||
// Query each configured device for its integration IDs and exit.
|
||||
usage = "Connect to each device, print its integration IDs, and exit"
|
||||
flag.BoolVar(&app.flags.Discover, "discover", false, usage)
|
||||
flag.BoolVar(&app.flags.Discover, "d", false, usage+" (shorthand)")
|
||||
|
||||
// Force debug-level logging to the console, overriding the configured log level.
|
||||
usage = "Force debug-level logging to the console, overriding the config"
|
||||
flag.BoolVar(&app.flags.Verbose, "verbose", false, usage)
|
||||
flag.BoolVar(&app.flags.Verbose, "V", false, usage+" (shorthand)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if printVersion {
|
||||
fmt.Println(serviceName + ": " + serviceVersion)
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
25
go.mod
Normal file
25
go.mod
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
module github.com/grmrgecko/lutron-control
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/Hundemeier/go-sacn/sacn v0.0.0-20221003163232-00e6fbef50ad
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5
|
||||
github.com/jsimonetti/go-artnet v0.0.0-20260603054657-c38cae151b06
|
||||
github.com/kkyr/fig v0.5.0
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
go.bug.st/serial v1.7.1
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
golang.org/x/net v0.44.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
56
go.sum
Normal file
56
go.sum
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
github.com/Hundemeier/go-sacn/sacn v0.0.0-20221003163232-00e6fbef50ad h1:2aEVdLjBRx/6ZhkaTAJAxMFWsM+1K7peaFNYcX51Xbk=
|
||||
github.com/Hundemeier/go-sacn/sacn v0.0.0-20221003163232-00e6fbef50ad/go.mod h1:VNysAu18mov+2Wj3lgtZt8EeUM1poHgalmOLa0FuCas=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5 h1:fqwINudmUrvGCuw+e3tedZ2UJ0hklSw6t8UPomctKyQ=
|
||||
github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5/go.mod h1:lqMjoCs0y0GoRRujSPZRBaGb4c5ER6TfkFKSClxkMbY=
|
||||
github.com/jsimonetti/go-artnet v0.0.0-20260603054657-c38cae151b06 h1:iAlZSPT2fdcKxdYWjZUsqNVhUgHvJUJ3ZPgCDf22vT8=
|
||||
github.com/jsimonetti/go-artnet v0.0.0-20260603054657-c38cae151b06/go.mod h1:0CLdFUe1H/VOSDfifnqH1OUQOncooVsTz1P0faFLAO8=
|
||||
github.com/kkyr/fig v0.5.0 h1:D4ym5MYYScOSgqyx1HYQaqFn9dXKzIuSz8N6SZ4rzqM=
|
||||
github.com/kkyr/fig v0.5.0/go.mod h1:U4Rq/5eUNJ8o5UvOEc9DiXtNf41srOLn2r/BfCyuc58=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc=
|
||||
go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
|
||||
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
21
lutron-control.service
Normal file
21
lutron-control.service
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[Unit]
|
||||
Description=Lutron Control
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=20
|
||||
|
||||
[Service]
|
||||
# Type=notify works with the program's native sd_notify support; WatchdogSec
|
||||
# enables the systemd watchdog the program pings while healthy.
|
||||
Type=notify
|
||||
NotifyAccess=main
|
||||
ExecStart=/usr/local/bin/lutron-control --config /etc/lutron-control/config.yaml
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
WatchdogSec=120
|
||||
# Grant access to the serial adapter (unused, but harmless, on the telnet transport).
|
||||
SupplementaryGroups=dialout
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
142
main.go
Normal file
142
main.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-systemd/v22/daemon"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
serviceName = "lutron-control"
|
||||
serviceDescription = "Bridges DMX (sACN/Art-Net) and MQTT control to Lutron GRAFIK Eye QS zones over serial or telnet"
|
||||
serviceVersion = "0.1.0"
|
||||
)
|
||||
|
||||
// App is the global application structure tying together configuration, the
|
||||
// Lutron devices, and the control sources that drive them.
|
||||
type App struct {
|
||||
flags *Flags
|
||||
config *Config
|
||||
devices []*Device
|
||||
sources []Source
|
||||
}
|
||||
|
||||
var app *App
|
||||
|
||||
func main() {
|
||||
app = new(App)
|
||||
app.ParseFlags()
|
||||
app.ReadConfig()
|
||||
|
||||
// Discovery mode: query each device for its integration IDs and exit.
|
||||
if app.flags.Discover {
|
||||
runDiscovery(app.config.Devices)
|
||||
return
|
||||
}
|
||||
|
||||
// Build a Lutron device for each configured interface, keyed by name so
|
||||
// sources can bind to their target.
|
||||
byName := make(map[string]*Device)
|
||||
for _, dc := range app.config.Devices {
|
||||
d := NewDevice(dc)
|
||||
app.devices = append(app.devices, d)
|
||||
byName[dc.Name] = d
|
||||
}
|
||||
|
||||
// Build each control source and bind it to its target device.
|
||||
for _, sc := range app.config.Sources {
|
||||
dev := byName[sc.Device]
|
||||
s, err := NewSource(sc, dev)
|
||||
if err != nil {
|
||||
log.Fatalf("Source %q: %s", sc.Name, err)
|
||||
}
|
||||
app.sources = append(app.sources, s)
|
||||
}
|
||||
|
||||
// Context cancelled on shutdown so every background loop can stop cleanly.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Start the sources before the devices. A source registers its feedback and
|
||||
// monitoring callbacks in Start (OnZoneFeedback, NoteMonitoring, etc.), which
|
||||
// the device's connect-time setup reads, so they must be in place before the
|
||||
// device connects. A frame arriving before the device loops run simply lands
|
||||
// in the device's target state and is flushed once the device starts.
|
||||
for _, s := range app.sources {
|
||||
if err := s.Start(ctx); err != nil {
|
||||
log.Errorf("Failed to start source %q: %s", s.Name(), err)
|
||||
}
|
||||
}
|
||||
for _, d := range app.devices {
|
||||
d.Start(ctx)
|
||||
}
|
||||
|
||||
log.Infof("%s %s started with %d device(s) and %d source(s)",
|
||||
serviceName, serviceVersion, len(app.devices), len(app.sources))
|
||||
|
||||
// Notify systemd we're ready and begin feeding its watchdog.
|
||||
daemon.SdNotify(false, daemon.SdNotifyReady)
|
||||
daemon.SdNotify(false, "STATUS=Running")
|
||||
stopWatchdog := startSDWatchdog(ctx)
|
||||
|
||||
// Wait for a termination signal.
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-c
|
||||
|
||||
log.Info("Shutting down")
|
||||
daemon.SdNotify(false, daemon.SdNotifyStopping)
|
||||
stopWatchdog()
|
||||
cancel()
|
||||
|
||||
for _, s := range app.sources {
|
||||
s.Stop()
|
||||
}
|
||||
for _, d := range app.devices {
|
||||
d.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// startSDWatchdog periodically pings the systemd watchdog while running, using
|
||||
// the interval systemd advertises via $WATCHDOG_USEC. It returns a function that
|
||||
// stops the pinger. When systemd has not enabled the watchdog, it does nothing.
|
||||
func startSDWatchdog(ctx context.Context) func() {
|
||||
interval, err := daemon.SdWatchdogEnabled(false)
|
||||
if err != nil || interval <= 0 {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// Ping at half the configured interval to stay comfortably within the deadline.
|
||||
interval /= 2
|
||||
if interval <= 0 {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
daemon.SdNotify(false, daemon.SdNotifyWatchdog)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var once bool
|
||||
return func() {
|
||||
if !once {
|
||||
once = true
|
||||
close(stop)
|
||||
}
|
||||
}
|
||||
}
|
||||
751
mqtt.go
Normal file
751
mqtt.go
Normal file
|
|
@ -0,0 +1,751 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// MQTT light state values exchanged with Home Assistant.
|
||||
const (
|
||||
mqttStateOn = "ON"
|
||||
mqttStateOff = "OFF"
|
||||
// mqttDefaultBrightness is used when a light is turned on without a level.
|
||||
mqttDefaultBrightness = 127
|
||||
|
||||
// Availability payloads published on the source's availability topic and
|
||||
// referenced by every discovery config so Home Assistant marks the entities
|
||||
// unavailable when the bridge is offline.
|
||||
mqttAvailable = "online"
|
||||
mqttNotAvailable = "offline"
|
||||
)
|
||||
|
||||
// MQTTSource exposes a device's zones as one or more dimmable lights over MQTT,
|
||||
// compatible with Home Assistant's JSON light schema. Each light drives its own
|
||||
// configured set of zones; one broker connection is shared by all of them, along
|
||||
// with an optional device-wide scene selector.
|
||||
type MQTTSource struct {
|
||||
cfg *SourceConfig
|
||||
dev *Device
|
||||
binding *sourceBinding
|
||||
client mqtt.Client
|
||||
|
||||
lights []*mqttLight
|
||||
|
||||
// availabilityTopic carries the bridge's online/offline state; it backs the
|
||||
// Last Will and every entity's availability so Home Assistant marks them
|
||||
// unavailable when the bridge dies.
|
||||
availabilityTopic string
|
||||
|
||||
// Monitoring relay state. monitorBase/monitorPrefix root the topics raw "~"
|
||||
// reports are published under; monitorFamilies filters which families to
|
||||
// forward (nil forwards all).
|
||||
monitorBase string
|
||||
monitorPrefix string
|
||||
monitorFamilies map[string]bool
|
||||
|
||||
// Scene selector state (device-wide), when enabled.
|
||||
sceneTopic string
|
||||
sceneSet string
|
||||
|
||||
mu sync.Mutex
|
||||
scene int
|
||||
sentScene int
|
||||
}
|
||||
|
||||
// mqttLight is a single Home Assistant light backed by a fixed set of QSE zones.
|
||||
// Brightness drives every zone in the set; the first zone is mirrored back as the
|
||||
// aggregate state.
|
||||
type mqttLight struct {
|
||||
src *MQTTSource
|
||||
name string
|
||||
topic string
|
||||
topicSet string
|
||||
zones []int
|
||||
|
||||
mu sync.Mutex
|
||||
state string
|
||||
brightness int
|
||||
sentState string
|
||||
sentBrightness int
|
||||
published bool
|
||||
}
|
||||
|
||||
// newMQTTSource constructs an MQTT control source and its lights.
|
||||
func newMQTTSource(cfg *SourceConfig, dev *Device, binding *sourceBinding) *MQTTSource {
|
||||
// Root the availability topic on the base topic when one is configured;
|
||||
// otherwise (multi-light setups omit it) fall back to the source name.
|
||||
availBase := cfg.MQTT.Topic
|
||||
if availBase == "" {
|
||||
availBase = serviceName + "/" + mqttSlug(cfg.Name)
|
||||
}
|
||||
|
||||
s := &MQTTSource{
|
||||
cfg: cfg,
|
||||
dev: dev,
|
||||
binding: binding,
|
||||
availabilityTopic: availBase + "/availability",
|
||||
monitorBase: availBase,
|
||||
monitorPrefix: cfg.MQTT.MonitorPrefix,
|
||||
sceneTopic: cfg.MQTT.Topic + "/scene",
|
||||
sceneSet: cfg.MQTT.Topic + "/scene/set",
|
||||
scene: -1, // Unknown until reported.
|
||||
sentScene: -2,
|
||||
}
|
||||
// Build the family filter (uppercased); empty means forward everything.
|
||||
if len(cfg.MQTT.Monitor.Families) > 0 {
|
||||
s.monitorFamilies = make(map[string]bool, len(cfg.MQTT.Monitor.Families))
|
||||
for _, f := range cfg.MQTT.Monitor.Families {
|
||||
s.monitorFamilies[strings.ToUpper(f)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Use the configured lights, or synthesize a single light over every zone when
|
||||
// none are listed.
|
||||
lights := cfg.MQTT.Lights
|
||||
if len(lights) == 0 {
|
||||
zones := make([]int, dev.Zones())
|
||||
for i := range zones {
|
||||
zones[i] = i + 1
|
||||
}
|
||||
lights = []MQTTLightConfig{{Name: cfg.MQTT.DeviceName, Topic: cfg.MQTT.Topic, Zones: zones}}
|
||||
}
|
||||
for _, lc := range lights {
|
||||
name := lc.Name
|
||||
if name == "" {
|
||||
name = cfg.MQTT.DeviceName
|
||||
}
|
||||
s.lights = append(s.lights, &mqttLight{
|
||||
src: s,
|
||||
name: name,
|
||||
topic: lc.Topic,
|
||||
topicSet: lc.Topic + "/set",
|
||||
zones: append([]int(nil), lc.Zones...),
|
||||
state: mqttStateOff,
|
||||
})
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// scenesEnabled reports whether this source exposes scene control.
|
||||
func (s *MQTTSource) scenesEnabled() bool { return s.cfg.MQTT.Scenes > 0 }
|
||||
|
||||
// deviceSlug returns the Home Assistant device identifier shared by this source's
|
||||
// lights, derived from the base topic and falling back to the source name when no
|
||||
// base topic is configured.
|
||||
func (s *MQTTSource) deviceSlug() string {
|
||||
if slug := mqttSlug(s.cfg.MQTT.Topic); slug != "" {
|
||||
return slug
|
||||
}
|
||||
return mqttSlug(s.cfg.Name)
|
||||
}
|
||||
|
||||
// Name returns the source's configured name.
|
||||
func (s *MQTTSource) Name() string { return s.cfg.Name }
|
||||
|
||||
// Start connects to the broker and wires the command and feedback paths.
|
||||
func (s *MQTTSource) Start(ctx context.Context) error {
|
||||
// Mirror panel zone feedback into each light's aggregate state.
|
||||
s.dev.OnZoneFeedback(func(zone int, level byte) {
|
||||
for _, l := range s.lights {
|
||||
l.onZoneFeedback(zone, level)
|
||||
}
|
||||
})
|
||||
|
||||
// Mirror the panel's active scene into the scene selector, when enabled.
|
||||
if s.scenesEnabled() {
|
||||
s.dev.NoteSceneControl()
|
||||
s.dev.OnSceneFeedback(func(scene int) {
|
||||
s.mu.Lock()
|
||||
s.scene = scene
|
||||
s.mu.Unlock()
|
||||
s.publishScene()
|
||||
})
|
||||
}
|
||||
|
||||
// Relay the panel's raw monitoring out to MQTT topics, requesting the desired
|
||||
// monitoring types from the panel first. Enabled when the source configures
|
||||
// any monitoring types or a family filter.
|
||||
if len(s.cfg.MQTT.Monitor.Enable) > 0 || len(s.cfg.MQTT.Monitor.Families) > 0 {
|
||||
s.dev.NoteMonitoring(s.cfg.MQTT.Monitor.Enable...)
|
||||
s.dev.OnMonitor(s.publishMonitor)
|
||||
}
|
||||
|
||||
clientID := s.cfg.MQTT.ClientID
|
||||
if clientID == "" {
|
||||
clientID = fmt.Sprintf("%s-%s-%d", serviceName, s.cfg.Name, os.Getpid())
|
||||
}
|
||||
|
||||
opts := mqtt.NewClientOptions()
|
||||
opts.AddBroker(fmt.Sprintf("tcp://%s:%d", s.cfg.MQTT.Broker, s.cfg.MQTT.Port))
|
||||
opts.SetClientID(clientID)
|
||||
if s.cfg.MQTT.Username != "" {
|
||||
opts.SetUsername(s.cfg.MQTT.Username)
|
||||
opts.SetPassword(s.cfg.MQTT.Password)
|
||||
}
|
||||
opts.SetAutoReconnect(true)
|
||||
opts.SetConnectRetry(true)
|
||||
opts.SetConnectRetryInterval(5 * time.Second)
|
||||
// Register a retained Last Will so the broker marks the bridge offline if the
|
||||
// connection drops; the matching birth message is published in onConnect.
|
||||
opts.SetWill(s.availabilityTopic, mqttNotAvailable, 1, true)
|
||||
opts.SetOnConnectHandler(s.onConnect)
|
||||
opts.SetConnectionLostHandler(func(_ mqtt.Client, err error) {
|
||||
log.Warnf("[%s] MQTT connection lost: %s", s.cfg.Name, err)
|
||||
})
|
||||
|
||||
s.client = mqtt.NewClient(opts)
|
||||
// Connect in the background so a down broker doesn't block startup.
|
||||
go func() {
|
||||
token := s.client.Connect()
|
||||
token.Wait()
|
||||
if err := token.Error(); err != nil {
|
||||
log.Errorf("[%s] MQTT connect failed: %s", s.cfg.Name, err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
s.Stop()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop disconnects from the broker.
|
||||
func (s *MQTTSource) Stop() {
|
||||
if s.client != nil && s.client.IsConnected() {
|
||||
// Publish offline explicitly: a graceful disconnect doesn't trigger the
|
||||
// Last Will, so without this the entities would stay "available".
|
||||
if token := s.client.Publish(s.availabilityTopic, 1, true, mqttNotAvailable); token.WaitTimeout(250*time.Millisecond) && token.Error() != nil {
|
||||
log.Warnf("[%s] MQTT availability publish failed on stop: %s", s.cfg.Name, token.Error())
|
||||
}
|
||||
s.client.Disconnect(250)
|
||||
}
|
||||
}
|
||||
|
||||
// onConnect subscribes each light's command topic and publishes discovery and
|
||||
// state, then wires up the scene selector when enabled.
|
||||
func (s *MQTTSource) onConnect(c mqtt.Client) {
|
||||
log.Infof("[%s] Connected to MQTT broker", s.cfg.Name)
|
||||
|
||||
// Announce the bridge online (retained) before discovery so entities appear
|
||||
// available; the Last Will flips this to offline if the connection drops.
|
||||
if token := c.Publish(s.availabilityTopic, 1, true, mqttAvailable); token.Wait() && token.Error() != nil {
|
||||
log.Errorf("[%s] MQTT availability publish failed: %s", s.cfg.Name, token.Error())
|
||||
}
|
||||
|
||||
for _, l := range s.lights {
|
||||
if token := c.Subscribe(l.topicSet, 0, l.onMessage); token.Wait() && token.Error() != nil {
|
||||
log.Errorf("[%s] MQTT subscribe failed for %s: %s", s.cfg.Name, l.topic, token.Error())
|
||||
}
|
||||
if s.cfg.MQTT.Discovery {
|
||||
l.publishDiscovery()
|
||||
}
|
||||
// Force the next publish to actually go out.
|
||||
l.mu.Lock()
|
||||
l.published = false
|
||||
l.mu.Unlock()
|
||||
l.publishState()
|
||||
}
|
||||
|
||||
// Wire up the scene selector when enabled.
|
||||
if s.scenesEnabled() {
|
||||
if token := c.Subscribe(s.sceneSet, 0, s.onSceneMessage); token.Wait() && token.Error() != nil {
|
||||
log.Errorf("[%s] MQTT scene subscribe failed: %s", s.cfg.Name, token.Error())
|
||||
}
|
||||
if s.cfg.MQTT.Discovery {
|
||||
s.publishSceneDiscovery()
|
||||
}
|
||||
s.publishScene()
|
||||
}
|
||||
|
||||
// Wire up the extended controls (shades, ramp buttons, locks, sequence).
|
||||
s.setupControls(c)
|
||||
}
|
||||
|
||||
// onSceneMessage handles a scene-selection command (a plain scene number).
|
||||
func (s *MQTTSource) onSceneMessage(_ mqtt.Client, msg mqtt.Message) {
|
||||
scene, err := strconv.Atoi(strings.TrimSpace(string(msg.Payload())))
|
||||
if err != nil || scene < 1 {
|
||||
log.Warnf("[%s] Bad MQTT scene payload: %q", s.cfg.Name, msg.Payload())
|
||||
return
|
||||
}
|
||||
log.Debugf("[%s] MQTT scene RX %d", s.cfg.Name, scene)
|
||||
if !s.dev.ApplyScene(s.binding, scene) {
|
||||
// Locked out by a higher-priority source; reflect the current scene back.
|
||||
s.publishScene()
|
||||
}
|
||||
}
|
||||
|
||||
// publishScene publishes the active scene number when it has changed.
|
||||
func (s *MQTTSource) publishScene() {
|
||||
if s.client == nil || !s.client.IsConnected() {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
// Scene 0 is the panel's "unknown/none" report and isn't one of the select's
|
||||
// options (1..N), so don't publish it as a state.
|
||||
if s.scene == s.sentScene || s.scene <= 0 {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.sentScene = s.scene
|
||||
payload := strconv.Itoa(s.scene)
|
||||
s.mu.Unlock()
|
||||
|
||||
token := s.client.Publish(s.sceneTopic, 0, true, payload)
|
||||
if token.Wait() && token.Error() != nil {
|
||||
log.Warnf("[%s] MQTT scene publish failed: %s", s.cfg.Name, token.Error())
|
||||
return
|
||||
}
|
||||
log.Debugf("[%s] Published scene %s to %s", s.cfg.Name, payload, s.sceneTopic)
|
||||
}
|
||||
|
||||
// publishMonitor forwards one monitoring message to an MQTT topic, after applying
|
||||
// the family filter. The topic is <base>/<prefix>/<family>/<identifying fields>
|
||||
// and the payload is the trailing field (Lutron reports put the value last), so
|
||||
// every monitored message is relayed losslessly. These are raw state topics for
|
||||
// automations; no Home Assistant discovery is published for them.
|
||||
func (s *MQTTSource) publishMonitor(ev MonitorEvent) {
|
||||
if s.client == nil || !s.client.IsConnected() {
|
||||
return
|
||||
}
|
||||
if s.monitorFamilies != nil && !s.monitorFamilies[ev.Family] {
|
||||
return
|
||||
}
|
||||
segs := []string{s.monitorBase, s.monitorPrefix, strings.ToLower(ev.Family)}
|
||||
payload := ev.Raw
|
||||
if n := len(ev.Fields); n > 0 {
|
||||
segs = append(segs, ev.Fields[:n-1]...)
|
||||
payload = ev.Fields[n-1]
|
||||
}
|
||||
topic := strings.Join(segs, "/")
|
||||
|
||||
token := s.client.Publish(topic, 0, true, payload)
|
||||
if token.Wait() && token.Error() != nil {
|
||||
log.Warnf("[%s] MQTT monitor publish failed for %s: %s", s.cfg.Name, topic, token.Error())
|
||||
return
|
||||
}
|
||||
log.Debugf("[%s] Published monitor %q to %s", s.cfg.Name, payload, topic)
|
||||
}
|
||||
|
||||
// publishSceneDiscovery publishes a Home Assistant select entity for the scenes.
|
||||
func (s *MQTTSource) publishSceneDiscovery() {
|
||||
slug := mqttSlug(s.cfg.MQTT.Topic) + "_scene"
|
||||
topic := fmt.Sprintf("%s/select/%s/config", s.cfg.MQTT.DiscoveryPrefix, slug)
|
||||
options := make([]string, s.cfg.MQTT.Scenes)
|
||||
for i := range options {
|
||||
options[i] = strconv.Itoa(i + 1)
|
||||
}
|
||||
deviceSlug := s.deviceSlug()
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"name": s.cfg.MQTT.DeviceName + " Scene",
|
||||
"unique_id": slug,
|
||||
"command_topic": s.sceneSet,
|
||||
"state_topic": s.sceneTopic,
|
||||
"options": options,
|
||||
"availability_topic": s.availabilityTopic,
|
||||
"payload_available": mqttAvailable,
|
||||
"payload_not_available": mqttNotAvailable,
|
||||
"device": map[string]any{
|
||||
"identifiers": []string{deviceSlug},
|
||||
"name": s.cfg.MQTT.DeviceName,
|
||||
"manufacturer": "Lutron",
|
||||
"model": "GRAFIK Eye QS",
|
||||
},
|
||||
})
|
||||
token := s.client.Publish(topic, 0, true, payload)
|
||||
if token.Wait() && token.Error() != nil {
|
||||
log.Errorf("[%s] MQTT scene discovery publish failed: %s", s.cfg.Name, token.Error())
|
||||
return
|
||||
}
|
||||
log.Infof("[%s] Published Home Assistant scene discovery to %s", s.cfg.Name, topic)
|
||||
}
|
||||
|
||||
// setupControls subscribes the command topics and publishes discovery for the
|
||||
// optional extended controls: shade covers, per-light raise/lower/stop buttons,
|
||||
// zone/scene lock switches, and the sequence selector. Each is gated by its
|
||||
// config toggle, mirroring how scenes are enabled.
|
||||
func (s *MQTTSource) setupControls(c mqtt.Client) {
|
||||
base := s.cfg.MQTT.Topic
|
||||
|
||||
// Shade columns as Home Assistant covers (open/close/stop).
|
||||
for col := 1; col <= s.cfg.MQTT.Shades; col++ {
|
||||
set := fmt.Sprintf("%s/shade/%d/set", base, col)
|
||||
if token := c.Subscribe(set, 0, s.shadeHandler(col)); token.Wait() && token.Error() != nil {
|
||||
log.Errorf("[%s] MQTT shade subscribe failed: %s", s.cfg.Name, token.Error())
|
||||
}
|
||||
if s.cfg.MQTT.Discovery {
|
||||
s.publishCoverDiscovery(col, set)
|
||||
}
|
||||
}
|
||||
|
||||
// Raise/lower/stop buttons for each light's zones.
|
||||
if s.cfg.MQTT.ZoneRamp {
|
||||
for _, l := range s.lights {
|
||||
for _, action := range []string{"raise", "lower", "stop"} {
|
||||
set := fmt.Sprintf("%s/%s/set", l.topic, action)
|
||||
if token := c.Subscribe(set, 0, s.rampHandler(l, action)); token.Wait() && token.Error() != nil {
|
||||
log.Errorf("[%s] MQTT ramp subscribe failed: %s", s.cfg.Name, token.Error())
|
||||
}
|
||||
if s.cfg.MQTT.Discovery {
|
||||
s.publishButtonDiscovery(l, action, set)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zone and scene lock switches.
|
||||
if s.cfg.MQTT.ZoneLock {
|
||||
set := base + "/zone_lock/set"
|
||||
if token := c.Subscribe(set, 0, s.lockHandler(false)); token.Wait() && token.Error() != nil {
|
||||
log.Errorf("[%s] MQTT zone-lock subscribe failed: %s", s.cfg.Name, token.Error())
|
||||
}
|
||||
if s.cfg.MQTT.Discovery {
|
||||
s.publishSwitchDiscovery("zone_lock", "Zone Lock", set)
|
||||
}
|
||||
}
|
||||
if s.cfg.MQTT.SceneLock {
|
||||
set := base + "/scene_lock/set"
|
||||
if token := c.Subscribe(set, 0, s.lockHandler(true)); token.Wait() && token.Error() != nil {
|
||||
log.Errorf("[%s] MQTT scene-lock subscribe failed: %s", s.cfg.Name, token.Error())
|
||||
}
|
||||
if s.cfg.MQTT.Discovery {
|
||||
s.publishSwitchDiscovery("scene_lock", "Scene Lock", set)
|
||||
}
|
||||
}
|
||||
|
||||
// Sequence selector.
|
||||
if s.cfg.MQTT.Sequence {
|
||||
set := base + "/sequence/set"
|
||||
if token := c.Subscribe(set, 0, s.sequenceHandler); token.Wait() && token.Error() != nil {
|
||||
log.Errorf("[%s] MQTT sequence subscribe failed: %s", s.cfg.Name, token.Error())
|
||||
}
|
||||
if s.cfg.MQTT.Discovery {
|
||||
s.publishSequenceDiscovery(set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sequenceOptions are the Home Assistant select options for the sequence state,
|
||||
// indexed by the Lutron sequence mode (0 off, 1 scenes 1-4, 2 scenes 5-16).
|
||||
var sequenceOptions = []string{"Off", "Scenes 1-4", "Scenes 5-16"}
|
||||
|
||||
// shadeHandler drives a shade column from a Home Assistant cover command
|
||||
// (OPEN/CLOSE/STOP, case-insensitive).
|
||||
func (s *MQTTSource) shadeHandler(column int) mqtt.MessageHandler {
|
||||
return func(_ mqtt.Client, msg mqtt.Message) {
|
||||
action := strings.ToLower(strings.TrimSpace(string(msg.Payload())))
|
||||
log.Debugf("[%s] MQTT shade %d RX %s", s.cfg.Name, column, action)
|
||||
if !s.dev.ApplyShade(s.binding, column, action) {
|
||||
log.Warnf("[%s] Unknown shade action %q", s.cfg.Name, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rampHandler starts or stops raising/lowering every zone of a light.
|
||||
func (s *MQTTSource) rampHandler(l *mqttLight, action string) mqtt.MessageHandler {
|
||||
return func(_ mqtt.Client, _ mqtt.Message) {
|
||||
log.Debugf("[%s] MQTT %s %s", s.cfg.Name, l.topic, action)
|
||||
for _, z := range l.zones {
|
||||
switch action {
|
||||
case "raise":
|
||||
s.dev.RaiseZone(s.binding, z)
|
||||
case "lower":
|
||||
s.dev.LowerZone(s.binding, z)
|
||||
case "stop":
|
||||
s.dev.StopZone(s.binding, z)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lockHandler toggles the zone or scene lock from an ON/OFF switch command.
|
||||
func (s *MQTTSource) lockHandler(scene bool) mqtt.MessageHandler {
|
||||
return func(_ mqtt.Client, msg mqtt.Message) {
|
||||
on := strings.EqualFold(strings.TrimSpace(string(msg.Payload())), mqttStateOn)
|
||||
if scene {
|
||||
s.dev.SetSceneLock(on)
|
||||
} else {
|
||||
s.dev.SetZoneLock(on)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sequenceHandler sets the sequence state from a select command.
|
||||
func (s *MQTTSource) sequenceHandler(_ mqtt.Client, msg mqtt.Message) {
|
||||
choice := strings.TrimSpace(string(msg.Payload()))
|
||||
for mode, opt := range sequenceOptions {
|
||||
if opt == choice {
|
||||
s.dev.SetSequence(mode)
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Warnf("[%s] Unknown sequence option %q", s.cfg.Name, choice)
|
||||
}
|
||||
|
||||
// haDevice returns the Home Assistant device block shared by this source's
|
||||
// entities so they group under one device.
|
||||
func (s *MQTTSource) haDevice() map[string]any {
|
||||
return map[string]any{
|
||||
"identifiers": []string{s.deviceSlug()},
|
||||
"name": s.cfg.MQTT.DeviceName,
|
||||
"manufacturer": "Lutron",
|
||||
"model": "GRAFIK Eye QS",
|
||||
}
|
||||
}
|
||||
|
||||
// publishConfig publishes a retained Home Assistant discovery config.
|
||||
func (s *MQTTSource) publishConfig(topic string, payload []byte, kind string) {
|
||||
token := s.client.Publish(topic, 0, true, payload)
|
||||
if token.Wait() && token.Error() != nil {
|
||||
log.Errorf("[%s] MQTT %s discovery publish failed: %s", s.cfg.Name, kind, token.Error())
|
||||
return
|
||||
}
|
||||
log.Infof("[%s] Published Home Assistant %s discovery to %s", s.cfg.Name, kind, topic)
|
||||
}
|
||||
|
||||
// publishCoverDiscovery publishes a Home Assistant cover for a shade column. The
|
||||
// cover is optimistic since the panel reports no shade position.
|
||||
func (s *MQTTSource) publishCoverDiscovery(column int, set string) {
|
||||
slug := fmt.Sprintf("%s_shade_%d", mqttSlug(s.cfg.MQTT.Topic), column)
|
||||
topic := fmt.Sprintf("%s/cover/%s/config", s.cfg.MQTT.DiscoveryPrefix, slug)
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"name": fmt.Sprintf("%s Shade %d", s.cfg.MQTT.DeviceName, column),
|
||||
"unique_id": slug,
|
||||
"command_topic": set,
|
||||
"payload_open": "open",
|
||||
"payload_close": "close",
|
||||
"payload_stop": "stop",
|
||||
"optimistic": true,
|
||||
"availability_topic": s.availabilityTopic,
|
||||
"payload_available": mqttAvailable,
|
||||
"payload_not_available": mqttNotAvailable,
|
||||
"device": s.haDevice(),
|
||||
})
|
||||
s.publishConfig(topic, payload, "cover")
|
||||
}
|
||||
|
||||
// publishButtonDiscovery publishes a Home Assistant button for a light's
|
||||
// raise/lower/stop action.
|
||||
func (s *MQTTSource) publishButtonDiscovery(l *mqttLight, action, set string) {
|
||||
slug := fmt.Sprintf("%s_%s", mqttSlug(l.topic), action)
|
||||
topic := fmt.Sprintf("%s/button/%s/config", s.cfg.MQTT.DiscoveryPrefix, slug)
|
||||
label := strings.ToUpper(action[:1]) + action[1:]
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"name": fmt.Sprintf("%s %s", l.name, label),
|
||||
"unique_id": slug,
|
||||
"command_topic": set,
|
||||
"availability_topic": s.availabilityTopic,
|
||||
"payload_available": mqttAvailable,
|
||||
"payload_not_available": mqttNotAvailable,
|
||||
"device": s.haDevice(),
|
||||
})
|
||||
s.publishConfig(topic, payload, "button")
|
||||
}
|
||||
|
||||
// publishSwitchDiscovery publishes a Home Assistant switch for a lock. The switch
|
||||
// is optimistic since the panel reports no lock state.
|
||||
func (s *MQTTSource) publishSwitchDiscovery(key, name, set string) {
|
||||
slug := fmt.Sprintf("%s_%s", mqttSlug(s.cfg.MQTT.Topic), key)
|
||||
topic := fmt.Sprintf("%s/switch/%s/config", s.cfg.MQTT.DiscoveryPrefix, slug)
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"name": s.cfg.MQTT.DeviceName + " " + name,
|
||||
"unique_id": slug,
|
||||
"command_topic": set,
|
||||
"payload_on": mqttStateOn,
|
||||
"payload_off": mqttStateOff,
|
||||
"optimistic": true,
|
||||
"availability_topic": s.availabilityTopic,
|
||||
"payload_available": mqttAvailable,
|
||||
"payload_not_available": mqttNotAvailable,
|
||||
"device": s.haDevice(),
|
||||
})
|
||||
s.publishConfig(topic, payload, "switch")
|
||||
}
|
||||
|
||||
// publishSequenceDiscovery publishes a Home Assistant select for the sequence
|
||||
// state. It is optimistic since the panel reports no sequence state.
|
||||
func (s *MQTTSource) publishSequenceDiscovery(set string) {
|
||||
slug := mqttSlug(s.cfg.MQTT.Topic) + "_sequence"
|
||||
topic := fmt.Sprintf("%s/select/%s/config", s.cfg.MQTT.DiscoveryPrefix, slug)
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"name": s.cfg.MQTT.DeviceName + " Sequence",
|
||||
"unique_id": slug,
|
||||
"command_topic": set,
|
||||
"options": sequenceOptions,
|
||||
"optimistic": true,
|
||||
"availability_topic": s.availabilityTopic,
|
||||
"payload_available": mqttAvailable,
|
||||
"payload_not_available": mqttNotAvailable,
|
||||
"device": s.haDevice(),
|
||||
})
|
||||
s.publishConfig(topic, payload, "select")
|
||||
}
|
||||
|
||||
// onZoneFeedback mirrors the light's representative zone (the first in its set)
|
||||
// into the aggregate light state.
|
||||
func (l *mqttLight) onZoneFeedback(zone int, level byte) {
|
||||
if len(l.zones) == 0 || zone != l.zones[0] {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.brightness = int(level)
|
||||
if level == 0 {
|
||||
l.state = mqttStateOff
|
||||
} else {
|
||||
l.state = mqttStateOn
|
||||
}
|
||||
l.mu.Unlock()
|
||||
l.publishState()
|
||||
}
|
||||
|
||||
// onMessage handles a command on the light's set topic.
|
||||
func (l *mqttLight) onMessage(_ mqtt.Client, msg mqtt.Message) {
|
||||
var cmd struct {
|
||||
State string `json:"state"`
|
||||
Brightness *int `json:"brightness"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload(), &cmd); err != nil {
|
||||
log.Warnf("[%s] Bad MQTT payload for %s: %s", l.src.cfg.Name, l.topic, err)
|
||||
return
|
||||
}
|
||||
log.Debugf("[%s] MQTT RX %s %s", l.src.cfg.Name, l.topic, msg.Payload())
|
||||
|
||||
// Compute the requested target level.
|
||||
l.mu.Lock()
|
||||
if cmd.Brightness != nil {
|
||||
l.brightness = clampByte(*cmd.Brightness)
|
||||
}
|
||||
if cmd.State != "" && cmd.State != l.state {
|
||||
l.state = cmd.State
|
||||
// Turning on without a level defaults to roughly half brightness.
|
||||
if l.state == mqttStateOn && l.brightness == 0 {
|
||||
l.brightness = mqttDefaultBrightness
|
||||
}
|
||||
}
|
||||
target := 0
|
||||
if l.state == mqttStateOn {
|
||||
target = l.brightness
|
||||
}
|
||||
l.mu.Unlock()
|
||||
|
||||
// Apply the level uniformly across this light's zones only.
|
||||
levels := make(map[int]byte, len(l.zones))
|
||||
for _, z := range l.zones {
|
||||
levels[z] = byte(target)
|
||||
}
|
||||
if !l.src.dev.ApplyZoneLevels(l.src.binding, levels) {
|
||||
// We can't take control — a higher-priority source (e.g. live DMX) is in
|
||||
// control, or integration control is disabled. Mirror the device's real
|
||||
// zone state back out instead of fighting it.
|
||||
targets := l.src.dev.ZoneTargets()
|
||||
l.mu.Lock()
|
||||
if len(l.zones) > 0 {
|
||||
if z := l.zones[0]; z >= 1 && z <= len(targets) {
|
||||
l.brightness = int(targets[z-1])
|
||||
}
|
||||
}
|
||||
if l.brightness == 0 {
|
||||
l.state = mqttStateOff
|
||||
} else {
|
||||
l.state = mqttStateOn
|
||||
}
|
||||
l.published = false
|
||||
l.mu.Unlock()
|
||||
}
|
||||
l.publishState()
|
||||
}
|
||||
|
||||
// publishState publishes the light's aggregate state when it has changed.
|
||||
func (l *mqttLight) publishState() {
|
||||
c := l.src.client
|
||||
if c == nil || !c.IsConnected() {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
if l.published && l.state == l.sentState && l.brightness == l.sentBrightness {
|
||||
l.mu.Unlock()
|
||||
return
|
||||
}
|
||||
l.sentState = l.state
|
||||
l.sentBrightness = l.brightness
|
||||
l.published = true
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"state": l.state,
|
||||
"brightness": l.brightness,
|
||||
})
|
||||
l.mu.Unlock()
|
||||
|
||||
token := c.Publish(l.topic, 0, true, payload)
|
||||
if token.Wait() && token.Error() != nil {
|
||||
log.Warnf("[%s] MQTT publish failed for %s: %s", l.src.cfg.Name, l.topic, token.Error())
|
||||
return
|
||||
}
|
||||
log.Debugf("[%s] Published %s to %s", l.src.cfg.Name, payload, l.topic)
|
||||
}
|
||||
|
||||
// publishDiscovery publishes a Home Assistant MQTT discovery config (retained) so
|
||||
// the light appears automatically. All of a source's lights are grouped under the
|
||||
// same Home Assistant device.
|
||||
func (l *mqttLight) publishDiscovery() {
|
||||
slug := mqttSlug(l.topic)
|
||||
topic := fmt.Sprintf("%s/light/%s/config", l.src.cfg.MQTT.DiscoveryPrefix, slug)
|
||||
deviceSlug := l.src.deviceSlug()
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"schema": "json",
|
||||
"name": l.name,
|
||||
"unique_id": slug,
|
||||
"state_topic": l.topic,
|
||||
"command_topic": l.topicSet,
|
||||
"brightness": true,
|
||||
"supported_color_modes": []string{"brightness"},
|
||||
"availability_topic": l.src.availabilityTopic,
|
||||
"payload_available": mqttAvailable,
|
||||
"payload_not_available": mqttNotAvailable,
|
||||
"device": map[string]any{
|
||||
"identifiers": []string{deviceSlug},
|
||||
"name": l.src.cfg.MQTT.DeviceName,
|
||||
"manufacturer": "Lutron",
|
||||
"model": "GRAFIK Eye QS",
|
||||
},
|
||||
})
|
||||
token := l.src.client.Publish(topic, 0, true, payload)
|
||||
if token.Wait() && token.Error() != nil {
|
||||
log.Errorf("[%s] MQTT discovery publish failed for %s: %s", l.src.cfg.Name, l.topic, token.Error())
|
||||
return
|
||||
}
|
||||
log.Infof("[%s] Published Home Assistant discovery to %s", l.src.cfg.Name, topic)
|
||||
}
|
||||
|
||||
// mqttSlug derives a stable identifier from the base topic for HA unique IDs.
|
||||
func mqttSlug(topic string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range topic {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "_")
|
||||
}
|
||||
|
||||
// clampByte clamps an integer to the 0-255 DMX/brightness range.
|
||||
func clampByte(v int) int {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 255 {
|
||||
return 255
|
||||
}
|
||||
return v
|
||||
}
|
||||
371
osc.go
Normal file
371
osc.go
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hypebeast/go-osc/osc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// OSCSource exposes a device's controls over OSC (Open Sound Control). Incoming
|
||||
// UDP messages select an operation by address under the configured prefix:
|
||||
//
|
||||
// <prefix>/zone/<n>/level f|i set zone level (float 0-1, or int 0-255)
|
||||
// <prefix>/zone/<n>/raise start raising zone <n>
|
||||
// <prefix>/zone/<n>/lower start lowering zone <n>
|
||||
// <prefix>/zone/<n>/stop stop raising/lowering zone <n>
|
||||
// <prefix>/scene i activate scene <i>
|
||||
// <prefix>/scene/off activate the scene-off look
|
||||
// <prefix>/shade/<c>/<act> drive shade column <c> (open|close|preset|raise|lower|stop)
|
||||
// <prefix>/lock/zone i zone lock (0 off, 1 on)
|
||||
// <prefix>/lock/scene i scene lock (0 off, 1 on)
|
||||
// <prefix>/sequence i sequence (0 off, 1 scenes 1-4, 2 scenes 5-16)
|
||||
//
|
||||
// Movement and trigger addresses (raise/lower/stop, scene/off) act on receipt and
|
||||
// ignore their arguments.
|
||||
type OSCSource struct {
|
||||
cfg *SourceConfig
|
||||
dev *Device
|
||||
binding *sourceBinding
|
||||
server *osc.Server
|
||||
conn net.PacketConn
|
||||
|
||||
// Monitoring feedback streaming. dests are the resolved destinations the
|
||||
// panel's reports are sent to; families filters which "~" families to forward
|
||||
// (empty forwards all); levelFloat picks the zone-level encoding.
|
||||
dests []*net.UDPAddr
|
||||
families map[string]bool
|
||||
levelFloat bool
|
||||
}
|
||||
|
||||
// newOSCSource constructs an OSC control source, resolving any monitoring stream
|
||||
// destinations up front.
|
||||
func newOSCSource(cfg *SourceConfig, dev *Device, binding *sourceBinding) *OSCSource {
|
||||
s := &OSCSource{
|
||||
cfg: cfg,
|
||||
dev: dev,
|
||||
binding: binding,
|
||||
levelFloat: cfg.OSC.LevelAsFloat == nil || *cfg.OSC.LevelAsFloat,
|
||||
}
|
||||
// Resolve stream destinations; skip (and log) any that don't resolve.
|
||||
for _, addr := range cfg.OSC.StreamTo {
|
||||
ua, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
log.Errorf("[%s] Bad OSC stream_to %q: %s", cfg.Name, addr, err)
|
||||
continue
|
||||
}
|
||||
s.dests = append(s.dests, ua)
|
||||
}
|
||||
// Build the family filter (uppercased); empty means forward everything.
|
||||
if len(cfg.OSC.Monitor.Families) > 0 {
|
||||
s.families = make(map[string]bool, len(cfg.OSC.Monitor.Families))
|
||||
for _, f := range cfg.OSC.Monitor.Families {
|
||||
s.families[strings.ToUpper(f)] = true
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Name returns the source's configured name.
|
||||
func (s *OSCSource) Name() string { return s.cfg.Name }
|
||||
|
||||
// Start binds the UDP socket and serves OSC messages until the context is done.
|
||||
func (s *OSCSource) Start(ctx context.Context) error {
|
||||
// go-osc rejects pattern characters in registered addresses, so route every
|
||||
// message through a single default handler that parses the address.
|
||||
d := osc.NewStandardDispatcher()
|
||||
if err := d.AddMsgHandler("*", s.route); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := net.ListenPacket("udp", s.cfg.OSC.Listen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.conn = conn
|
||||
s.server = &osc.Server{Dispatcher: d}
|
||||
|
||||
// Stream the panel's monitoring out to the configured destinations, requesting
|
||||
// the desired monitoring types from the panel first. Registered after the
|
||||
// socket is bound so the stream callback always has a connection to send on.
|
||||
if len(s.dests) > 0 {
|
||||
s.dev.NoteMonitoring(s.cfg.OSC.Monitor.Enable...)
|
||||
s.dev.OnMonitor(s.streamEvent)
|
||||
log.Infof("[%s] Streaming OSC monitoring to %d destination(s)", s.cfg.Name, len(s.dests))
|
||||
}
|
||||
|
||||
// Serve in the background; Serve returns once the connection is closed on
|
||||
// shutdown, which is not an error worth surfacing.
|
||||
go func() {
|
||||
if err := s.server.Serve(conn); err != nil {
|
||||
log.Debugf("[%s] OSC server stopped: %s", s.cfg.Name, err)
|
||||
}
|
||||
}()
|
||||
log.Infof("[%s] Listening for OSC on %s (prefix %s)", s.cfg.Name, s.cfg.OSC.Listen, s.cfg.OSC.Prefix)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
s.Stop()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop closes the UDP socket, unblocking Serve.
|
||||
func (s *OSCSource) Stop() {
|
||||
if s.conn != nil {
|
||||
s.conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// route parses an OSC address under the prefix and dispatches it to the device.
|
||||
func (s *OSCSource) route(msg *osc.Message) {
|
||||
rest, ok := strings.CutPrefix(msg.Address, s.cfg.OSC.Prefix)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
return
|
||||
}
|
||||
log.Debugf("[%s] OSC RX %s %v", s.cfg.Name, msg.Address, msg.Arguments)
|
||||
|
||||
switch parts[0] {
|
||||
case "zone":
|
||||
s.routeZone(parts, msg)
|
||||
case "scene":
|
||||
// "/scene" sets a scene; "/scene/off" clears it.
|
||||
if len(parts) >= 2 && parts[1] == "off" {
|
||||
s.dev.SceneOff(s.binding)
|
||||
return
|
||||
}
|
||||
if scene, ok := oscInt(msg); ok && scene >= 1 {
|
||||
s.dev.ApplyScene(s.binding, scene)
|
||||
}
|
||||
case "shade":
|
||||
// "/shade/<column>/<action>".
|
||||
if len(parts) >= 3 {
|
||||
if col, err := strconv.Atoi(parts[1]); err == nil {
|
||||
s.dev.ApplyShade(s.binding, col, parts[2])
|
||||
}
|
||||
}
|
||||
case "lock":
|
||||
if len(parts) >= 2 {
|
||||
on, _ := oscBool(msg)
|
||||
switch parts[1] {
|
||||
case "zone":
|
||||
s.dev.SetZoneLock(on)
|
||||
case "scene":
|
||||
s.dev.SetSceneLock(on)
|
||||
}
|
||||
}
|
||||
case "sequence":
|
||||
if mode, ok := oscInt(msg); ok {
|
||||
s.dev.SetSequence(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// routeZone handles the "/zone/<n>/..." addresses.
|
||||
func (s *OSCSource) routeZone(parts []string, msg *osc.Message) {
|
||||
if len(parts) < 3 {
|
||||
return
|
||||
}
|
||||
zone, err := strconv.Atoi(parts[1])
|
||||
if err != nil || zone < 1 {
|
||||
return
|
||||
}
|
||||
switch parts[2] {
|
||||
case "level":
|
||||
if level, ok := oscLevel(msg); ok {
|
||||
s.dev.ApplyZoneLevels(s.binding, map[int]byte{zone: level})
|
||||
}
|
||||
case "raise":
|
||||
s.dev.RaiseZone(s.binding, zone)
|
||||
case "lower":
|
||||
s.dev.LowerZone(s.binding, zone)
|
||||
case "stop":
|
||||
s.dev.StopZone(s.binding, zone)
|
||||
}
|
||||
}
|
||||
|
||||
// streamEvent forwards one monitoring message to the configured destinations,
|
||||
// after applying the family filter. Known reports (zone level, scene, occupancy)
|
||||
// map to symmetric addresses; anything else falls back to a generic address so
|
||||
// every monitored message is relayed.
|
||||
func (s *OSCSource) streamEvent(ev MonitorEvent) {
|
||||
if s.families != nil && !s.families[ev.Family] {
|
||||
return
|
||||
}
|
||||
addr, args := s.monitorAddress(ev)
|
||||
s.send(addr, args...)
|
||||
}
|
||||
|
||||
// monitorAddress maps a MonitorEvent to an OSC address and arguments. The common
|
||||
// reports get clean, symmetric addresses; all others use the generic fallback.
|
||||
func (s *OSCSource) monitorAddress(ev MonitorEvent) (string, []any) {
|
||||
prefix := strings.TrimRight(s.cfg.OSC.Prefix, "/")
|
||||
f := ev.Fields
|
||||
switch ev.Family {
|
||||
case "DEVICE":
|
||||
// ~DEVICE,<id>,<component>,<action>,<params...>
|
||||
if len(f) >= 4 {
|
||||
component, action := f[1], f[2]
|
||||
switch {
|
||||
case action == strconv.Itoa(qseActionZoneLevel):
|
||||
// Zone level: the component is the zone number, the param a percent.
|
||||
if pct, err := strconv.ParseFloat(f[3], 64); err == nil {
|
||||
return fmt.Sprintf("%s/zone/%s/level", prefix, component), []any{s.levelArg(pct)}
|
||||
}
|
||||
case component == strconv.Itoa(qseSceneController) && action == strconv.Itoa(qseActionScene):
|
||||
if scene, err := strconv.Atoi(f[3]); err == nil {
|
||||
return prefix + "/scene", []any{int32(scene)}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "OUTPUT":
|
||||
// ~OUTPUT,<id>,1,<level%>
|
||||
if len(f) >= 3 && f[1] == "1" {
|
||||
if pct, err := strconv.ParseFloat(f[2], 64); err == nil {
|
||||
return fmt.Sprintf("%s/zone/%s/level", prefix, f[0]), []any{s.levelArg(pct)}
|
||||
}
|
||||
}
|
||||
case "GROUP":
|
||||
// ~GROUP,<id>,3,<state>
|
||||
if len(f) >= 3 {
|
||||
if state, err := strconv.Atoi(f[2]); err == nil {
|
||||
return fmt.Sprintf("%s/group/%s/occupancy", prefix, f[0]), []any{int32(state)}
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.genericAddress(prefix, ev)
|
||||
}
|
||||
|
||||
// genericAddress builds the fallback address for an unmodeled report:
|
||||
// <prefix>/monitor/<family>/<fields-except-last> with the trailing field as the
|
||||
// value argument (Lutron reports put the value last), so nothing is lost.
|
||||
func (s *OSCSource) genericAddress(prefix string, ev MonitorEvent) (string, []any) {
|
||||
segs := []string{"monitor", strings.ToLower(ev.Family)}
|
||||
var args []any
|
||||
if n := len(ev.Fields); n > 0 {
|
||||
segs = append(segs, ev.Fields[:n-1]...)
|
||||
args = []any{oscArg(ev.Fields[n-1])}
|
||||
}
|
||||
return prefix + "/" + strings.Join(segs, "/"), args
|
||||
}
|
||||
|
||||
// levelArg encodes a zone level percent (0-100) as a 0-1 float or a 0-255 int,
|
||||
// matching the source's configured input convention.
|
||||
func (s *OSCSource) levelArg(pct float64) any {
|
||||
if s.levelFloat {
|
||||
return float32(pct / 100.0)
|
||||
}
|
||||
return int32(math.Round(pct / 100.0 * 255.0))
|
||||
}
|
||||
|
||||
// send marshals an OSC message and writes it to every stream destination over the
|
||||
// listening socket. Failures are logged at debug; streaming is best-effort.
|
||||
func (s *OSCSource) send(addr string, args ...any) {
|
||||
if s.conn == nil {
|
||||
return
|
||||
}
|
||||
b, err := osc.NewMessage(addr, args...).MarshalBinary()
|
||||
if err != nil {
|
||||
log.Debugf("[%s] OSC marshal failed for %s: %s", s.cfg.Name, addr, err)
|
||||
return
|
||||
}
|
||||
for _, dst := range s.dests {
|
||||
if _, err := s.conn.WriteTo(b, dst); err != nil {
|
||||
log.Debugf("[%s] OSC stream to %s failed: %s", s.cfg.Name, dst, err)
|
||||
}
|
||||
}
|
||||
log.Debugf("[%s] OSC TX %s %v", s.cfg.Name, addr, args)
|
||||
}
|
||||
|
||||
// oscArg parses a Lutron field into the tightest OSC argument type: an integer, a
|
||||
// float, or the raw string when it is neither.
|
||||
func oscArg(v string) any {
|
||||
if i, err := strconv.Atoi(v); err == nil {
|
||||
return int32(i)
|
||||
}
|
||||
if f, err := strconv.ParseFloat(v, 32); err == nil {
|
||||
return float32(f)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// oscLevel maps the first argument to a 0-255 zone level: a float is treated as a
|
||||
// 0-1 fraction, an integer as already on the 0-255 scale.
|
||||
func oscLevel(msg *osc.Message) (byte, bool) {
|
||||
if len(msg.Arguments) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
switch v := msg.Arguments[0].(type) {
|
||||
case float32:
|
||||
return fractionByte(float64(v)), true
|
||||
case float64:
|
||||
return fractionByte(v), true
|
||||
case int32:
|
||||
return byte(clampByte(int(v))), true
|
||||
case int64:
|
||||
return byte(clampByte(int(v))), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// fractionByte clamps a 0-1 fraction and scales it to 0-255.
|
||||
func fractionByte(f float64) byte {
|
||||
if f < 0 {
|
||||
f = 0
|
||||
}
|
||||
if f > 1 {
|
||||
f = 1
|
||||
}
|
||||
return byte(math.Round(f * 255))
|
||||
}
|
||||
|
||||
// oscInt extracts an integer from the first argument (float arguments are
|
||||
// truncated).
|
||||
func oscInt(msg *osc.Message) (int, bool) {
|
||||
if len(msg.Arguments) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
switch v := msg.Arguments[0].(type) {
|
||||
case int32:
|
||||
return int(v), true
|
||||
case int64:
|
||||
return int(v), true
|
||||
case float32:
|
||||
return int(v), true
|
||||
case float64:
|
||||
return int(v), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// oscBool reads a 0/1 (or boolean) on/off argument; a missing argument is off.
|
||||
func oscBool(msg *osc.Message) (bool, bool) {
|
||||
if len(msg.Arguments) == 0 {
|
||||
return false, false
|
||||
}
|
||||
switch v := msg.Arguments[0].(type) {
|
||||
case bool:
|
||||
return v, true
|
||||
case int32:
|
||||
return v != 0, true
|
||||
case int64:
|
||||
return v != 0, true
|
||||
case float32:
|
||||
return v != 0, true
|
||||
case float64:
|
||||
return v != 0, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
104
osc_test.go
Normal file
104
osc_test.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newTestOSCSource builds an OSC source with the given config for mapping tests,
|
||||
// without binding any socket.
|
||||
func newTestOSCSource(cfg OSCConfig) *OSCSource {
|
||||
sc := &SourceConfig{Name: "osc", Type: "osc", OSC: cfg}
|
||||
dev := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1})
|
||||
binding := dev.RegisterSource(sc.Name, 0, 0, "")
|
||||
return newOSCSource(sc, dev, binding)
|
||||
}
|
||||
|
||||
// TestOSCMonitorAddressKnown verifies the symmetric address mapping for the
|
||||
// modeled reports: zone level, scene, and occupancy.
|
||||
func TestOSCMonitorAddressKnown(t *testing.T) {
|
||||
s := newTestOSCSource(OSCConfig{Prefix: "/lutron"})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ev MonitorEvent
|
||||
wantAddr string
|
||||
wantArgs int
|
||||
}{
|
||||
{
|
||||
name: "zone level",
|
||||
ev: MonitorEvent{Family: "DEVICE", Fields: []string{"1", "2", "14", "100.00"}},
|
||||
wantAddr: "/lutron/zone/2/level",
|
||||
wantArgs: 1,
|
||||
},
|
||||
{
|
||||
name: "scene",
|
||||
ev: MonitorEvent{Family: "DEVICE", Fields: []string{"1", "141", "7", "3"}},
|
||||
wantAddr: "/lutron/scene",
|
||||
wantArgs: 1,
|
||||
},
|
||||
{
|
||||
name: "occupancy",
|
||||
ev: MonitorEvent{Family: "GROUP", Fields: []string{"1", "3", "3"}},
|
||||
wantAddr: "/lutron/group/1/occupancy",
|
||||
wantArgs: 1,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
addr, args := s.monitorAddress(tc.ev)
|
||||
if addr != tc.wantAddr {
|
||||
t.Errorf("%s: addr = %q, want %q", tc.name, addr, tc.wantAddr)
|
||||
}
|
||||
if len(args) != tc.wantArgs {
|
||||
t.Errorf("%s: %d args, want %d", tc.name, len(args), tc.wantArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOSCMonitorAddressGenericFallback verifies an unmodeled report maps to the
|
||||
// generic /monitor/<family>/... address with the trailing field as the value.
|
||||
func TestOSCMonitorAddressGenericFallback(t *testing.T) {
|
||||
s := newTestOSCSource(OSCConfig{Prefix: "/lutron"})
|
||||
|
||||
// A button press: ~DEVICE,1,70,3 -> /lutron/monitor/device/1/70 arg 3.
|
||||
addr, args := s.monitorAddress(MonitorEvent{Family: "DEVICE", Fields: []string{"1", "70", "3"}})
|
||||
if addr != "/lutron/monitor/device/1/70" {
|
||||
t.Errorf("addr = %q, want /lutron/monitor/device/1/70", addr)
|
||||
}
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("%d args, want 1", len(args))
|
||||
}
|
||||
if v, ok := args[0].(int32); !ok || v != 3 {
|
||||
t.Errorf("arg = %v (%T), want int32(3)", args[0], args[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOSCLevelArg verifies the float and integer zone-level encodings.
|
||||
func TestOSCLevelArg(t *testing.T) {
|
||||
flt := true
|
||||
s := newTestOSCSource(OSCConfig{Prefix: "/lutron", LevelAsFloat: &flt})
|
||||
if v, ok := s.levelArg(100).(float32); !ok || v != 1.0 {
|
||||
t.Errorf("float level = %v (%T), want float32(1)", v, v)
|
||||
}
|
||||
|
||||
no := false
|
||||
si := newTestOSCSource(OSCConfig{Prefix: "/lutron", LevelAsFloat: &no})
|
||||
if v, ok := si.levelArg(100).(int32); !ok || v != 255 {
|
||||
t.Errorf("int level = %v (%T), want int32(255)", v, v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewOSCSourceResolvesDestinations verifies stream_to destinations resolve and
|
||||
// bad entries are skipped, and that the family filter is built.
|
||||
func TestNewOSCSourceResolvesDestinations(t *testing.T) {
|
||||
s := newTestOSCSource(OSCConfig{
|
||||
Prefix: "/lutron",
|
||||
StreamTo: []string{"127.0.0.1:9001", "not a host:port:::"},
|
||||
Monitor: MonitorForward{Families: []string{"device", "GROUP"}},
|
||||
})
|
||||
if len(s.dests) != 1 {
|
||||
t.Errorf("resolved %d destinations, want 1 (one bad entry skipped)", len(s.dests))
|
||||
}
|
||||
if !s.families["DEVICE"] || !s.families["GROUP"] {
|
||||
t.Errorf("family filter = %v, want DEVICE and GROUP", s.families)
|
||||
}
|
||||
}
|
||||
158
sacn.go
Normal file
158
sacn.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Hundemeier/go-sacn/sacn"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// sacnKeepAliveMax bounds how often a live but unchanging stream re-asserts its
|
||||
// arbitration lock; the actual interval is the smaller of this and half the
|
||||
// configured hold so the lock never lapses while frames keep arriving.
|
||||
const sacnKeepAliveMax = time.Second
|
||||
|
||||
// SACNSource receives sACN (E1.31) DMX and maps a universe's channels to its
|
||||
// device's zones and scenes.
|
||||
type SACNSource struct {
|
||||
cfg *SourceConfig
|
||||
dev *Device
|
||||
disp *dmxDispatcher
|
||||
recv *sacn.ReceiverSocket
|
||||
closeOnce sync.Once
|
||||
|
||||
mu sync.Mutex
|
||||
live bool // True while the universe's stream is present (set on data, cleared on timeout).
|
||||
}
|
||||
|
||||
// newSACNSource constructs an sACN control source.
|
||||
func newSACNSource(cfg *SourceConfig, dev *Device, disp *dmxDispatcher) *SACNSource {
|
||||
return &SACNSource{cfg: cfg, dev: dev, disp: disp}
|
||||
}
|
||||
|
||||
// Name returns the source's configured name.
|
||||
func (s *SACNSource) Name() string { return s.cfg.Name }
|
||||
|
||||
// Start opens the receiver socket and joins the configured universe.
|
||||
func (s *SACNSource) Start(ctx context.Context) error {
|
||||
// Resolve the multicast interface when one is configured.
|
||||
var ifi *net.Interface
|
||||
if s.cfg.SACN.Interface != "" {
|
||||
got, err := net.InterfaceByName(s.cfg.SACN.Interface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ifi = got
|
||||
}
|
||||
|
||||
recv, err := sacn.NewReceiverSocket(s.cfg.SACN.Bind, ifi)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.recv = recv
|
||||
|
||||
universe := s.cfg.SACN.Universe
|
||||
recv.SetOnChangeCallback(func(old sacn.DataPacket, newPacket sacn.DataPacket) {
|
||||
if newPacket.Universe() != universe {
|
||||
return
|
||||
}
|
||||
// The receiver only reports changed frames; mark the stream live so the
|
||||
// keepalive loop holds the lock while a static look keeps streaming.
|
||||
s.setLive(true)
|
||||
s.disp.dispatch(newPacket.Data())
|
||||
})
|
||||
recv.SetTimeoutCallback(func(univ uint16) {
|
||||
if univ != universe {
|
||||
return
|
||||
}
|
||||
// The console stopped streaming (released): black out the zones this
|
||||
// source drives on source loss, and stop refreshing the lock so a
|
||||
// lower-priority source can resume after hold.
|
||||
s.setLive(false)
|
||||
s.disp.release()
|
||||
log.Infof("[%s] sACN universe %d released; zones to 0", s.cfg.Name, univ)
|
||||
})
|
||||
|
||||
recv.Start()
|
||||
// JoinUniverse panics if the multicast group can't be joined (e.g. no usable
|
||||
// interface); recover so one source's failure doesn't crash the bridge.
|
||||
if err := joinUniverse(recv, universe); err != nil {
|
||||
recv.Close()
|
||||
s.recv = nil
|
||||
return err
|
||||
}
|
||||
log.Infof("[%s] Listening for sACN on universe %d", s.cfg.Name, universe)
|
||||
|
||||
// Hold the arbitration lock while the stream stays live, then close the
|
||||
// receiver when the application shuts down.
|
||||
go s.keepAlive(ctx)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
s.close()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// setLive records whether the universe's stream is currently present.
|
||||
func (s *SACNSource) setLive(live bool) {
|
||||
s.mu.Lock()
|
||||
s.live = live
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// keepAlive re-asserts zone activity while the stream is live so a static look
|
||||
// (which produces no change callbacks) doesn't lapse the source's arbitration
|
||||
// lock and let a lower-priority source take over. The hold window itself still
|
||||
// governs how long control persists after the stream actually stops.
|
||||
func (s *SACNSource) keepAlive(ctx context.Context) {
|
||||
interval := time.Duration(s.cfg.HoldSec*float64(time.Second)) / 2
|
||||
if interval <= 0 || interval > sacnKeepAliveMax {
|
||||
interval = sacnKeepAliveMax
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.mu.Lock()
|
||||
live := s.live
|
||||
s.mu.Unlock()
|
||||
if live {
|
||||
s.disp.refreshActivity()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// close shuts the receiver socket down exactly once. go-sacn's Close panics on
|
||||
// a second call, and both context cancellation and Stop race to close it.
|
||||
func (s *SACNSource) close() {
|
||||
s.closeOnce.Do(func() {
|
||||
s.recv.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// joinUniverse joins a universe, converting go-sacn's panic-on-failure into an
|
||||
// error the caller can act on.
|
||||
func joinUniverse(recv *sacn.ReceiverSocket, universe uint16) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("join universe %d: %v", universe, r)
|
||||
}
|
||||
}()
|
||||
recv.JoinUniverse(universe)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop closes the receiver socket.
|
||||
func (s *SACNSource) Stop() {
|
||||
if s.recv != nil {
|
||||
s.close()
|
||||
}
|
||||
}
|
||||
233
source.go
Normal file
233
source.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Source is a control input that drives a device's zones. Each source is bound
|
||||
// to one device and arbitrates for control via the device's Apply.
|
||||
type Source interface {
|
||||
// Name returns the source's configured name.
|
||||
Name() string
|
||||
// Start begins receiving and applying control input.
|
||||
Start(ctx context.Context) error
|
||||
// Stop releases the source's resources.
|
||||
Stop()
|
||||
}
|
||||
|
||||
// NewSource constructs the source implementation for the given configuration,
|
||||
// bound to its target device.
|
||||
func NewSource(cfg *SourceConfig, dev *Device) (Source, error) {
|
||||
if dev == nil {
|
||||
return nil, fmt.Errorf("device %q not found", cfg.Device)
|
||||
}
|
||||
hold := time.Duration(cfg.HoldSec * float64(time.Second))
|
||||
// DMX is a stream of instantaneous level data — the console owns the crossfade
|
||||
// — so default DMX sources to an instant fade when none is configured; other
|
||||
// sources fall back to the device's fade.
|
||||
fade := cfg.Fade
|
||||
if fade == "" && (cfg.Type == "sacn" || cfg.Type == "artnet") {
|
||||
fade = "00:00"
|
||||
}
|
||||
binding := dev.RegisterSource(cfg.Name, cfg.Priority, hold, fade)
|
||||
|
||||
switch cfg.Type {
|
||||
case "mqtt":
|
||||
return newMQTTSource(cfg, dev, binding), nil
|
||||
case "osc":
|
||||
return newOSCSource(cfg, dev, binding), nil
|
||||
case "sacn":
|
||||
disp, err := newDMXDispatcher(dev, binding, cfg.SACN.DMXMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newSACNSource(cfg, dev, disp), nil
|
||||
case "artnet":
|
||||
disp, err := newDMXDispatcher(dev, binding, cfg.ArtNet.DMXMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newArtNetSource(cfg, dev, disp), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown source type %q", cfg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// dmxBinding maps a DMX channel (0-indexed offset into the universe) to a target
|
||||
// zone for level control.
|
||||
type dmxBinding struct {
|
||||
channel int
|
||||
target int
|
||||
}
|
||||
|
||||
// sceneSelectBinding maps a DMX channel to scene activation by value: the channel's
|
||||
// value selects the scene (1..maxScene) to trigger, and 0 means no action. It fires
|
||||
// when the value changes, so it acts as a momentary trigger rather than a level.
|
||||
type sceneSelectBinding struct {
|
||||
channel int
|
||||
maxScene int
|
||||
}
|
||||
|
||||
// dmxDispatcher applies a DMX universe frame to a device: zone channels set zone
|
||||
// levels, scene-select channels trigger scenes by value.
|
||||
type dmxDispatcher struct {
|
||||
dev *Device
|
||||
binding *sourceBinding
|
||||
zoneBindings []dmxBinding
|
||||
sceneBindings []sceneSelectBinding
|
||||
|
||||
mu sync.Mutex
|
||||
prev []byte // Previous frame, for scene change detection.
|
||||
}
|
||||
|
||||
// newDMXDispatcher builds a dispatcher from the DMX channel map. It notes scene
|
||||
// control on the device so scene monitoring is enabled.
|
||||
func newDMXDispatcher(dev *Device, binding *sourceBinding, m DMXMap) (*dmxDispatcher, error) {
|
||||
zones, scenes, err := buildDMXMap(m, dev.Zones())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(scenes) > 0 {
|
||||
dev.NoteSceneControl()
|
||||
}
|
||||
return &dmxDispatcher{
|
||||
dev: dev,
|
||||
binding: binding,
|
||||
zoneBindings: zones,
|
||||
sceneBindings: scenes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// dispatch applies a universe frame to the device.
|
||||
func (d *dmxDispatcher) dispatch(data []byte) {
|
||||
// Snapshot the previous frame and store the current one for edge detection.
|
||||
d.mu.Lock()
|
||||
prev := d.prev
|
||||
cur := make([]byte, len(data))
|
||||
copy(cur, data)
|
||||
d.prev = cur
|
||||
d.mu.Unlock()
|
||||
|
||||
// Zone channels: apply each mapped zone's level, leaving zones this source
|
||||
// doesn't map untouched rather than forcing them to zero.
|
||||
if len(d.zoneBindings) > 0 {
|
||||
levels := make(map[int]byte, len(d.zoneBindings))
|
||||
for _, zb := range d.zoneBindings {
|
||||
if zb.channel < len(data) && zb.target >= 1 && zb.target <= d.dev.Zones() {
|
||||
levels[zb.target] = data[zb.channel]
|
||||
}
|
||||
}
|
||||
d.dev.ApplyZoneLevels(d.binding, levels)
|
||||
}
|
||||
|
||||
// Scene-select channels: trigger a scene when the channel's value changes to a
|
||||
// non-zero scene number. Holding a value doesn't re-fire; sweeping a fader
|
||||
// through the channel fires each scene it passes, so set it deliberately.
|
||||
for _, sb := range d.sceneBindings {
|
||||
if sb.channel >= len(data) {
|
||||
continue
|
||||
}
|
||||
scene := int(data[sb.channel])
|
||||
prevScene := 0
|
||||
if sb.channel < len(prev) {
|
||||
prevScene = int(prev[sb.channel])
|
||||
}
|
||||
if scene != prevScene && scene >= 1 && scene <= sb.maxScene {
|
||||
d.dev.ApplyScene(d.binding, scene)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refreshActivity re-asserts the source's zone activity so a continuously
|
||||
// streaming universe keeps its arbitration lock between value changes. Scene-select
|
||||
// channels trigger on change and carry their own hold, so only zone-driving
|
||||
// dispatchers need this.
|
||||
func (d *dmxDispatcher) refreshActivity() {
|
||||
if len(d.zoneBindings) > 0 {
|
||||
d.dev.RefreshZoneActivity(d.binding)
|
||||
}
|
||||
}
|
||||
|
||||
// release drives every zone this dispatcher owns to 0, mirroring a DMX source
|
||||
// going away: an unsourced universe collapses to all-zeros, so a console release
|
||||
// blacks the zones out instead of latching the last value. Scene-select channels
|
||||
// are left untouched; they trigger on change. The previous-frame state is cleared
|
||||
// so a returning identical look re-applies.
|
||||
func (d *dmxDispatcher) release() {
|
||||
if len(d.zoneBindings) == 0 {
|
||||
return
|
||||
}
|
||||
levels := make(map[int]byte, len(d.zoneBindings))
|
||||
for _, zb := range d.zoneBindings {
|
||||
if zb.target >= 1 && zb.target <= d.dev.Zones() {
|
||||
levels[zb.target] = 0
|
||||
}
|
||||
}
|
||||
d.dev.ApplyZoneLevels(d.binding, levels)
|
||||
|
||||
d.mu.Lock()
|
||||
d.prev = nil
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
// buildDMXMap resolves a DMX channel map into zone bindings and scene-select
|
||||
// bindings. An explicit channels list takes precedence; otherwise the zones are
|
||||
// laid out sequentially from the start address, followed by a single scene-select
|
||||
// channel when scenes are enabled.
|
||||
func buildDMXMap(m DMXMap, deviceZones int) (zones []dmxBinding, scenes []sceneSelectBinding, err error) {
|
||||
// Explicit per-channel map.
|
||||
if len(m.Channels) > 0 {
|
||||
for _, c := range m.Channels {
|
||||
if c.Channel < 1 || c.Channel > 512 {
|
||||
return nil, nil, fmt.Errorf("channel %d: must be 1-512", c.Channel)
|
||||
}
|
||||
idx := c.Channel - 1 // 1-indexed DMX address to 0-indexed offset.
|
||||
switch c.Type {
|
||||
case "zone":
|
||||
if c.Zone < 1 || c.Zone > deviceZones {
|
||||
return nil, nil, fmt.Errorf("channel %d: zone %d out of range 1-%d", c.Channel, c.Zone, deviceZones)
|
||||
}
|
||||
zones = append(zones, dmxBinding{channel: idx, target: c.Zone})
|
||||
case "scene":
|
||||
scenes = append(scenes, sceneSelectBinding{channel: idx, maxScene: qseMaxScene})
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("channel %d: unknown type %q", c.Channel, c.Type)
|
||||
}
|
||||
}
|
||||
return zones, scenes, nil
|
||||
}
|
||||
|
||||
// Sequential layout: zones from the start address, then one scene-select channel.
|
||||
zoneCount := m.Zones
|
||||
if zoneCount == 0 {
|
||||
zoneCount = deviceZones
|
||||
}
|
||||
if m.StartAddress < 0 {
|
||||
return nil, nil, fmt.Errorf("start_address %d: must be >= 0", m.StartAddress)
|
||||
}
|
||||
if m.Scenes < 0 || m.Scenes > qseMaxScene {
|
||||
return nil, nil, fmt.Errorf("scenes %d: must be 0-%d", m.Scenes, qseMaxScene)
|
||||
}
|
||||
// One channel carries the scene selector when scenes are enabled.
|
||||
sceneChannels := 0
|
||||
if m.Scenes > 0 {
|
||||
sceneChannels = 1
|
||||
}
|
||||
// Reject a layout that runs past the 512-channel universe, which would
|
||||
// otherwise silently drop the out-of-range channels at dispatch.
|
||||
if last := m.StartAddress + zoneCount + sceneChannels; last > 512 {
|
||||
return nil, nil, fmt.Errorf("sequential layout runs to channel %d, past the 512-channel universe", last)
|
||||
}
|
||||
addr := m.StartAddress
|
||||
for z := 1; z <= zoneCount; z++ {
|
||||
zones = append(zones, dmxBinding{channel: addr, target: z})
|
||||
addr++
|
||||
}
|
||||
if m.Scenes > 0 {
|
||||
scenes = append(scenes, sceneSelectBinding{channel: addr, maxScene: m.Scenes})
|
||||
}
|
||||
return zones, scenes, nil
|
||||
}
|
||||
214
transport.go
Normal file
214
transport.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// transport is a bidirectional byte stream to a Lutron interface. Both the
|
||||
// serial and telnet implementations satisfy it.
|
||||
type transport io.ReadWriteCloser
|
||||
|
||||
// openTransport dials a device using its configured transport.
|
||||
func openTransport(cfg *DeviceConfig) (transport, error) {
|
||||
switch cfg.Transport {
|
||||
case "serial":
|
||||
return openSerial(cfg.Serial)
|
||||
case "telnet":
|
||||
return openTelnet(cfg.Telnet)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown transport %q", cfg.Transport)
|
||||
}
|
||||
}
|
||||
|
||||
// openSerial opens the serial port at the configured baud rate.
|
||||
func openSerial(cfg SerialConfig) (transport, error) {
|
||||
port, err := serial.Open(cfg.Device, &serial.Mode{BaudRate: cfg.Baud})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// telnetLoginTimeout bounds the login handshake so a wrong host can't wedge the
|
||||
// connect path indefinitely.
|
||||
const telnetLoginTimeout = 10 * time.Second
|
||||
|
||||
// openTelnet dials the device over TCP and performs the Lutron integration
|
||||
// login handshake, returning a stream with telnet IAC negotiation filtered out.
|
||||
func openTelnet(cfg TelnetConfig) (transport, error) {
|
||||
addr := cfg.Address
|
||||
if _, _, err := net.SplitHostPort(addr); err != nil {
|
||||
// No port given; default to the telnet port.
|
||||
addr = net.JoinHostPort(addr, "23")
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", addr, telnetLoginTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Keep the TCP connection alive. A QSE-CI-NWK-E telnet link can sit idle for
|
||||
// long stretches (we only write on changes), and a router/NAT may drop an
|
||||
// idle connection without keepalives (per Lutron App Note 048618).
|
||||
if tcp, ok := conn.(*net.TCPConn); ok {
|
||||
tcp.SetKeepAlive(true)
|
||||
tcp.SetKeepAlivePeriod(30 * time.Second)
|
||||
}
|
||||
tc := &telnetConn{Conn: conn}
|
||||
|
||||
// Perform the login handshake. The QSE-CI-NWK-E prompts "login:" and, on some
|
||||
// units, "password:"; we answer each as it appears.
|
||||
if err := tc.login(cfg.Username, cfg.Password); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return tc, nil
|
||||
}
|
||||
|
||||
// telnetConn wraps a net.Conn and strips telnet IAC command sequences from the
|
||||
// inbound stream, auto-refusing any option negotiation so the server proceeds.
|
||||
type telnetConn struct {
|
||||
net.Conn
|
||||
pending []byte // Bytes held back mid-IAC-sequence across Read calls.
|
||||
}
|
||||
|
||||
// Telnet command bytes (RFC 854) used by the IAC filter.
|
||||
const (
|
||||
iac = 255
|
||||
iacD = 254 // DONT
|
||||
iacC = 253 // DO
|
||||
iacW = 252 // WONT
|
||||
iacL = 251 // WILL
|
||||
iacS = 250 // SB (subnegotiation begin)
|
||||
iacE = 240 // SE (subnegotiation end)
|
||||
)
|
||||
|
||||
// login reads prompts and sends the credentials as they are requested. It
|
||||
// returns once credentials are handled and the integration prompt is seen.
|
||||
func (t *telnetConn) login(username, password string) error {
|
||||
t.SetReadDeadline(time.Now().Add(telnetLoginTimeout))
|
||||
defer t.SetReadDeadline(time.Time{})
|
||||
|
||||
var acc []byte
|
||||
buf := make([]byte, 256)
|
||||
sentUser, sentPass := false, false
|
||||
for {
|
||||
n, err := t.Read(buf)
|
||||
if n > 0 {
|
||||
acc = append(acc, buf[:n]...)
|
||||
lower := strings.ToLower(string(acc))
|
||||
if !sentUser && username != "" && strings.Contains(lower, "login:") {
|
||||
if _, err := t.Write([]byte(username + "\r\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
sentUser = true
|
||||
acc = acc[:0]
|
||||
continue
|
||||
}
|
||||
// The QSE-CI-NWK-E prompts "passphrase:" when a login passphrase is set;
|
||||
// older/other units use "password:". Answer either.
|
||||
if !sentPass && password != "" && (strings.Contains(lower, "passphrase:") || strings.Contains(lower, "password:")) {
|
||||
if _, err := t.Write([]byte(password + "\r\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
sentPass = true
|
||||
acc = acc[:0]
|
||||
continue
|
||||
}
|
||||
// Once credentials are handled and the unit echoes a prompt, the link
|
||||
// is ready for integration commands.
|
||||
if strings.Contains(lower, "qse>") || strings.Contains(lower, "gnet>") || strings.Contains(lower, "qnet>") {
|
||||
return nil
|
||||
}
|
||||
if (sentUser || username == "") && (sentPass || password == "") && len(acc) > 0 {
|
||||
// No recognizable prompt, but credentials are handled; assume ready.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if username == "" && password == "" {
|
||||
// Some units open straight into the command stream with no prompt.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("telnet login: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read returns inbound data with telnet IAC sequences removed.
|
||||
func (t *telnetConn) Read(p []byte) (int, error) {
|
||||
n, err := t.Conn.Read(p)
|
||||
if n == 0 {
|
||||
return 0, err
|
||||
}
|
||||
data := append(t.pending, p[:n]...)
|
||||
t.pending = nil
|
||||
clean, leftover := t.filterIAC(data)
|
||||
t.pending = leftover
|
||||
copy(p, clean)
|
||||
return len(clean), err
|
||||
}
|
||||
|
||||
// filterIAC removes IAC command sequences from data, replying to DO/WILL with
|
||||
// WONT/DONT. Bytes belonging to an incomplete trailing sequence are returned as
|
||||
// leftover to be prepended to the next read.
|
||||
func (t *telnetConn) filterIAC(data []byte) (clean, leftover []byte) {
|
||||
out := make([]byte, 0, len(data))
|
||||
for i := 0; i < len(data); {
|
||||
b := data[i]
|
||||
if b != iac {
|
||||
out = append(out, b)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// Need at least the command byte to proceed.
|
||||
if i+1 >= len(data) {
|
||||
return out, data[i:]
|
||||
}
|
||||
cmd := data[i+1]
|
||||
switch cmd {
|
||||
case iac:
|
||||
// Escaped 0xFF is a literal data byte.
|
||||
out = append(out, iac)
|
||||
i += 2
|
||||
case iacC, iacL, iacD, iacW:
|
||||
// Option negotiation: the option byte is required too.
|
||||
if i+2 >= len(data) {
|
||||
return out, data[i:]
|
||||
}
|
||||
t.refuse(cmd, data[i+2])
|
||||
i += 3
|
||||
case iacS:
|
||||
// Subnegotiation: skip until IAC SE.
|
||||
end := bytes.Index(data[i:], []byte{iac, iacE})
|
||||
if end < 0 {
|
||||
return out, data[i:]
|
||||
}
|
||||
i += end + 2
|
||||
default:
|
||||
// Two-byte command without an option.
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// refuse answers an option request by declining it, keeping the server from
|
||||
// waiting on a negotiation we don't implement.
|
||||
func (t *telnetConn) refuse(cmd, option byte) {
|
||||
var reply byte
|
||||
switch cmd {
|
||||
case iacC: // Server asks us to DO -> we WONT.
|
||||
reply = iacW
|
||||
case iacL: // Server WILL -> we DONT.
|
||||
reply = iacD
|
||||
default:
|
||||
return
|
||||
}
|
||||
t.Conn.Write([]byte{iac, reply, option})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue