First commit

This commit is contained in:
James Coleman 2026-06-28 08:32:22 -05:00
commit a61031f5ef
21 changed files with 5377 additions and 0 deletions

9
.gitignore vendored Normal file
View 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
View 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
View 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 01, or int 0255) |
| `<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 14, 2 scenes 516) |
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 (01 float or 0255 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
View 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
View 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
View 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
View 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)
}
}
}

1269
device.go Normal file

File diff suppressed because it is too large Load diff

420
device_test.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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})
}