From a61031f5ef45bc6835eef9dcd76d59ab0c07cbd7 Mon Sep 17 00:00:00 2001 From: James Coleman Date: Sun, 28 Jun 2026 08:32:22 -0500 Subject: [PATCH] First commit --- .gitignore | 9 + LICENSE | 19 + README.md | 445 ++++++++++++++ artnet.go | 149 +++++ config.example.yaml | 221 +++++++ config.go | 541 +++++++++++++++++ config_test.go | 115 ++++ device.go | 1269 ++++++++++++++++++++++++++++++++++++++++ device_test.go | 420 +++++++++++++ discover.go | 65 ++ flags.go | 49 ++ go.mod | 25 + go.sum | 56 ++ lutron-control.service | 21 + main.go | 142 +++++ mqtt.go | 751 ++++++++++++++++++++++++ osc.go | 371 ++++++++++++ osc_test.go | 104 ++++ sacn.go | 158 +++++ source.go | 233 ++++++++ transport.go | 214 +++++++ 21 files changed, 5377 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 artnet.go create mode 100644 config.example.yaml create mode 100644 config.go create mode 100644 config_test.go create mode 100644 device.go create mode 100644 device_test.go create mode 100644 discover.go create mode 100644 flags.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 lutron-control.service create mode 100644 main.go create mode 100644 mqtt.go create mode 100644 osc.go create mode 100644 osc_test.go create mode 100644 sacn.go create mode 100644 source.go create mode 100644 transport.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1366a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Local config holds secrets (MQTT password); never commit it. +config.yaml + +# Built binary. +/lutron-control + +# Logs. +*.log +*.log.gz diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..83d4a90 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7f5b92 --- /dev/null +++ b/README.md @@ -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 `/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 | +| --- | --- | --- | +| `/zone//level` | `f` or `i` | set zone `` level (float 0–1, or int 0–255) | +| `/zone//raise` | — | start raising zone `` | +| `/zone//lower` | — | start lowering zone `` | +| `/zone//stop` | — | stop raising/lowering zone `` | +| `/scene` | `i` | activate scene `` | +| `/scene/off` | — | activate the scene-off look | +| `/shade//` | — | drive shade column `` (`open`/`close`/`preset`/`raise`/`lower`/`stop`) | +| `/lock/zone` | `i` | zone lock (0 off, 1 on) | +| `/lock/scene` | `i` | scene lock (0 off, 1 on) | +| `/sequence` | `i` | sequence (0 off, 1 scenes 1–4, 2 scenes 5–16) | + +To stream the panel's feedback back to a controller, set `osc.stream_to` to one or +more `host:port` destinations — see [Monitoring feedback](#monitoring-feedback) +below, which covers the symmetric report addresses and the `osc.level_as_float` +encoding. + +## Monitoring feedback + +Beyond the Home Assistant light/scene entities, the MQTT and OSC sources can relay +the panel's raw monitoring — anything the GRAFIK Eye reports: zone levels, scenes, +keypad button presses, LED states, occupancy, and more. A source's `monitor.enable` +lists the `#MONITORING` type numbers to request from the panel (e.g. `3` button, +`5` zone, `8` scene; `255` for all); the bridge enables the union across sources on +connect and re-asserts it after a reconnect. `monitor.families` optionally limits +which `~` message families are forwarded. + +- **OSC** streams to the `osc.stream_to` destinations (`host:port`). Modeled + reports get symmetric addresses — `/zone//level`, `/scene`, + `/group//occupancy` — and anything else falls back to + `/monitor//`. `osc.level_as_float` (default true) + picks the zone-level encoding (0–1 float or 0–255 int). +- **MQTT** publishes to `///` (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 @ +``` + +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 @:~/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. diff --git a/artnet.go b/artnet.go new file mode 100644 index 0000000..79fb910 --- /dev/null +++ b/artnet.go @@ -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() + } + }) +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..f12ffc4 --- /dev/null +++ b/config.example.yaml @@ -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/.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 (/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 + # /scene/set, state on /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 /shade//set. + zone_ramp: false # Raise/lower/stop buttons for each light's zones, + # on /{raise,lower,stop}/set. + zone_lock: false # Zone-lock switch on /zone_lock/set (QS Standalone). + scene_lock: false # Scene-lock switch on /scene_lock/set (QS Standalone). + sequence: false # Sequence select (Off / Scenes 1-4 / Scenes 5-16) + # on /sequence/set (QS Standalone). + + # Monitoring relay. Publishes the panel's "~" reports to + # /// 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 /set, state on ) 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. + # + # /zone//level f|i set zone level (float 0-1, or int 0-255) + # /zone//raise start raising zone + # /zone//lower start lowering zone + # /zone//stop stop raising/lowering zone + # /scene i activate scene + # /scene/off activate the scene-off look + # /shade// shade column : open|close|preset|raise|lower|stop + # /lock/zone i zone lock (0 off, 1 on) + # /lock/scene i scene lock (0 off, 1 on) + # /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: + # + # /zone//level f|i reported zone level + # /scene i reported active scene + # /group//occupancy i occupancy state (3 occupied, 4 unoccupied) + # /monitor// 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. diff --git a/config.go b/config.go new file mode 100644 index 0000000..4999b23 --- /dev/null +++ b/config.go @@ -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/.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,,,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 /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 /scene/set + // and the active scene is published to /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 /shade//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. + // ///... + 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 /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 +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..e7f273d --- /dev/null +++ b/config_test.go @@ -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) + } + } +} diff --git a/device.go b/device.go new file mode 100644 index 0000000..d29bcbc --- /dev/null +++ b/device.go @@ -0,0 +1,1269 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "math" + "strconv" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +// Lutron integration protocol constants (not user-configurable). +const ( + qseTerminator = "\r\n" + qseActionZoneLevel = 14 // Action number for a zone's light level (component 1-24). + qseBtnAction = 3 // "Press" action carried by the enable/disable signals. + qseActionRelease = 4 // "Release" action for phantom buttons (shades react on release). + qseSceneController = 141 // Component number of the GRAFIK Eye QS scene controller. + qseActionScene = 7 // Action number for the current scene (set/get). + qseMaxScene = 16 // Highest scene number the GRAFIK Eye QS supports. + + // Zone movement actions (component 1-24). Raise/lower start motion and run + // until a stop; the panel reports the new level via zone monitoring. + qseActionStartRaising = 18 + qseActionStartLowering = 19 + qseActionStopMoving = 20 + + // Scene controller (141) actions (QS Standalone). + qseActionZoneLock = 15 // Zone lock: 0 off, 1 on. + qseActionSceneLock = 16 // Scene lock: 0 off, 1 on. + qseActionSequence = 17 // Sequence: 0 off, 1 scenes 1-4, 2 scenes 5-16. + + // qseSceneOffButton is the Scene Off phantom-button component. + qseSceneOffButton = 83 + + // zoneStopGrace is how long zone writes stay suppressed after a raise/lower + // stop so the panel's final level report can update the target before the + // writer resumes, preventing it from yanking the zone back to its pre-move + // level. + zoneStopGrace = 2 * time.Second + + // #MONITORING types (protocol p.12). + qseMonitorZone = 5 // Zone-level monitoring. + qseMonitorScene = 8 // Scene monitoring. + qseMonitorReply = 11 // Reply state (responses cease if disabled). + + // writeInterval is the zone-writer tick. + writeInterval = 100 * time.Millisecond + + // Minimum inter-message delays mandated by the integration protocol: a command + // ("#") requires at least 100 ms before the next message, a query ("?") at + // least 1500 ms. The protocol also advises relying on monitoring rather than + // polling, which is why queries are only used for the connect-time sync and + // the single-query watchdog probe. + commandInterMessageDelay = 100 * time.Millisecond + queryInterMessageDelay = 1500 * time.Millisecond + + // queryReplyWindow bounds how long after we query a zone its reply is treated + // as solicited. A solicited reply (notably the watchdog liveness probe) is + // used for liveness only and must not override the level we maintain; an + // unsolicited report past this window is a genuine external change to follow. + queryReplyWindow = 5 * time.Second + + // zoneEchoWindow bounds how long after we write a zone its report is treated + // as an echo of that write rather than a genuine external change. The panel + // quantizes levels to its own scale, so its echo rarely byte-matches what we + // sent; adopting it back into the target would fight our own writes. + zoneEchoWindow = 5 * time.Second +) + +// qseErrorDescriptions maps ~ERROR, codes from the Lutron integration +// protocol (doc 040249) to human-readable text. +var qseErrorDescriptions = map[string]string{ + "1": "parameter count mismatch", + "2": "object does not exist (check integration ID)", + "3": "invalid action number", + "4": "parameter data out of range", + "5": "parameter data malformed", + "6": "unsupported command", +} + +// shadeColumn holds the GRAFIK Eye QS phantom-button component numbers for one +// shade column. Open, close, and preset react on release (a press then release); +// raise and lower start motion on press and are halted by releasing the held +// component. +type shadeColumn struct{ open, preset, close, lower, raise int } + +// qseShadeColumns maps a 1-indexed shade column to its component numbers. +var qseShadeColumns = map[int]shadeColumn{ + 1: {open: 38, preset: 39, close: 40, lower: 41, raise: 47}, + 2: {open: 44, preset: 45, close: 46, lower: 52, raise: 53}, + 3: {open: 50, preset: 51, close: 56, lower: 57, raise: 58}, +} + +// sourceBinding tracks a control source's arbitration state on a device. Zone +// and scene activity are tracked separately so the device knows whether zones +// are being actively driven. +type sourceBinding struct { + name string + priority int + hold time.Duration + fade string // Zone fade time this source applies; empty uses the device default. + lastZoneActive time.Time + lastSceneActive time.Time +} + +// activeAt reports whether the source has applied zone or scene control within +// its hold window as of now. +func (b *sourceBinding) activeAt(now time.Time) bool { + return now.Sub(b.lastZoneActive) < b.hold || now.Sub(b.lastSceneActive) < b.hold +} + +// zoneFeedbackFn is called when the device reports a zone's current level. +type zoneFeedbackFn func(zone int, level byte) + +// sceneFeedbackFn is called when the device reports the active scene. +type sceneFeedbackFn func(scene int) + +// MonitorEvent is a parsed monitoring message reported by the panel. It carries +// every "~" feedback line generically so a source can relay arbitrary monitoring +// (zone, scene, button, occupancy, etc.) without the device modelling each type. +type MonitorEvent struct { + Family string // The "~KEYWORD": DEVICE, OUTPUT, GROUP, MONITORING, SYSTEM, ERROR. + Fields []string // Comma-split fields after the keyword (integration id, component, action, params). + Raw string // The original line, for passthrough consumers. +} + +// monitorFn is called for every monitoring message the panel reports. +type monitorFn func(ev MonitorEvent) + +// Device supervises a single Lutron interface: it owns the connection, reads +// integration responses, writes changed zone levels, and arbitrates between the +// control sources that want to drive its zones. +type Device struct { + cfg *DeviceConfig + zones int + + // Connection ownership. connMu guards conn and serializes writes. + connMu sync.Mutex + conn transport + + // Outbound pacing. paceMu serializes all outbound messages and enforces the + // protocol's minimum inter-message delays; nextSendAt is the earliest time + // the next message may be sent. lastWasZoneOp records whether the previous + // send was a zone-value operation so a run of them can burst together. + paceMu sync.Mutex + nextSendAt time.Time + lastWasZoneOp bool + + // Zone and arbitration state. + dataMu sync.Mutex + target []byte // Desired zone levels (0-255). + sent []byte // Last levels written to the panel. + reported []byte // Last levels reported by the panel. + zoneFade []string // Per-zone fade time set by the last source to drive each zone. + zoneRamping []bool // Per-zone raise/lower in progress; writes are suppressed until stop. + zoneSuppress []time.Time // Per-zone write-suppression deadline (raise/lower stop grace). + shadeHeld map[int]int // Per-column held shade component, released on stop. + lastZoneQuery []time.Time // When each zone was last queried, to spot solicited replies. + lastZoneWrite []time.Time // When each zone was last written, to spot reports echoing our own writes. + sendAll bool // Force a full resend on the next write pass. + controlDisabled bool // Panel signalled integration control is disabled. + resyncing bool // Connect-time level sync in progress; suppress zone writes. + pendingResync int // Zone reports still awaited before the resync is complete. + bindings []*sourceBinding + feedbackFns []zoneFeedbackFn + + // Generic monitoring fan-out. monitorFns receive every "~" feedback line; + // requestedMonitoring is the union of #MONITORING types sources have asked the + // panel to report, enabled on connect alongside the built-in defaults. + monitorFns []monitorFn + requestedMonitoring map[int]bool + + // Scene state. + sceneFeedbackFns []sceneFeedbackFn + sceneMonitoring bool // A scene source is attached; enable scene monitoring. + sceneSuppress time.Time // Suppress zone writes until this time (a scene is holding). + + // Watchdog timestamps (Unix nanoseconds). + lastResponse atomicTime + lastWrite atomicTime + lastReset atomicTime + + // Lifecycle. + ctx context.Context + reconnect chan string + wg sync.WaitGroup +} + +// NewDevice constructs a device from its configuration. +func NewDevice(cfg *DeviceConfig) *Device { + d := &Device{ + cfg: cfg, + zones: cfg.Zones, + target: make([]byte, cfg.Zones), + sent: make([]byte, cfg.Zones), + reported: make([]byte, cfg.Zones), + zoneFade: make([]string, cfg.Zones), + zoneRamping: make([]bool, cfg.Zones), + zoneSuppress: make([]time.Time, cfg.Zones), + shadeHeld: make(map[int]int), + lastZoneQuery: make([]time.Time, cfg.Zones), + lastZoneWrite: make([]time.Time, cfg.Zones), + sendAll: true, + reconnect: make(chan string, 1), + + requestedMonitoring: make(map[int]bool), + } + return d +} + +// Name returns the device's configured name. +func (d *Device) Name() string { return d.cfg.Name } + +// Zones returns the number of controllable zones. +func (d *Device) Zones() int { return d.zones } + +// RegisterSource registers a control source for arbitration and returns its +// binding handle. It must be called before Start. +func (d *Device) RegisterSource(name string, priority int, hold time.Duration, fade string) *sourceBinding { + b := &sourceBinding{name: name, priority: priority, hold: hold, fade: fade} + d.dataMu.Lock() + d.bindings = append(d.bindings, b) + d.dataMu.Unlock() + return b +} + +// OnZoneFeedback registers a callback invoked when the panel reports a zone's +// level. It must be called before Start. +func (d *Device) OnZoneFeedback(fn zoneFeedbackFn) { + d.dataMu.Lock() + d.feedbackFns = append(d.feedbackFns, fn) + d.dataMu.Unlock() +} + +// OnSceneFeedback registers a callback invoked when the panel reports the active +// scene. It must be called before Start. +func (d *Device) OnSceneFeedback(fn sceneFeedbackFn) { + d.dataMu.Lock() + d.sceneFeedbackFns = append(d.sceneFeedbackFns, fn) + d.dataMu.Unlock() +} + +// NoteSceneControl marks that a scene source is attached so scene monitoring is +// enabled on connect. It must be called before Start. +func (d *Device) NoteSceneControl() { + d.dataMu.Lock() + d.sceneMonitoring = true + d.dataMu.Unlock() +} + +// OnMonitor registers a callback invoked for every monitoring message the panel +// reports. It must be called before Start. +func (d *Device) OnMonitor(fn monitorFn) { + d.dataMu.Lock() + d.monitorFns = append(d.monitorFns, fn) + d.dataMu.Unlock() +} + +// NoteMonitoring records #MONITORING types a source wants the panel to report so +// the union is enabled on connect. Type 255 requests all monitoring. It must be +// called before Start. +func (d *Device) NoteMonitoring(types ...int) { + d.dataMu.Lock() + for _, t := range types { + if t > 0 { + d.requestedMonitoring[t] = true + } + } + d.dataMu.Unlock() +} + +// lockedOut reports whether a higher-priority source is currently active, +// blocking this binding from taking control. Caller must hold dataMu. +func (d *Device) lockedOut(b *sourceBinding, now time.Time) bool { + for _, other := range d.bindings { + if other == b { + continue + } + if other.priority > b.priority && other.activeAt(now) { + return true + } + } + return false +} + +// ApplyZoneLevels sets the target level for a set of 1-indexed zones on behalf of +// a source, leaving every other zone untouched (a source only ever drives the +// zones it owns). It returns false when the caller can't take control — either a +// higher-priority source is currently active, or the panel has signalled that +// integration control is disabled — in which case the levels are ignored and the +// caller should reflect the device's real state instead of its request. +func (d *Device) ApplyZoneLevels(b *sourceBinding, levels map[int]byte) bool { + d.dataMu.Lock() + defer d.dataMu.Unlock() + + now := time.Now() + if d.controlDisabled || d.lockedOut(b, now) { + return false + } + + b.lastZoneActive = now + // Zone control takes over from any active scene. + d.sceneSuppress = time.Time{} + for zone, level := range levels { + if zone >= 1 && zone <= d.zones { + d.target[zone-1] = level + d.zoneFade[zone-1] = b.fade + // An absolute level cancels any raise/lower in progress for the zone. + d.zoneRamping[zone-1] = false + d.zoneSuppress[zone-1] = time.Time{} + } + } + return true +} + +// RefreshZoneActivity re-asserts a source's zone activity without changing any +// levels, keeping its arbitration lock alive between value changes. A +// continuously streaming source (e.g. sACN holding a static look) needs this +// because its receiver only reports changed frames, so a steady stream would +// otherwise look idle once the hold window elapsed and let a lower-priority +// source take over. It is a no-op while integration control is disabled or a +// higher-priority source holds control, matching ApplyZoneLevels. +func (d *Device) RefreshZoneActivity(b *sourceBinding) { + d.dataMu.Lock() + defer d.dataMu.Unlock() + + now := time.Now() + if d.controlDisabled || d.lockedOut(b, now) { + return + } + b.lastZoneActive = now +} + +// ApplyScene activates a GRAFIK Eye scene on behalf of a source. It returns false +// when a higher-priority source is active. While the scene holds, zone writes are +// suppressed so the scene isn't immediately overwritten by a resend. +func (d *Device) ApplyScene(b *sourceBinding, scene int) bool { + d.dataMu.Lock() + now := time.Now() + if d.controlDisabled || d.lockedOut(b, now) { + d.dataMu.Unlock() + return false + } + b.lastSceneActive = now + d.sceneSuppress = now.Add(b.hold) + d.dataMu.Unlock() + + // Activate the scene via the Scene Controller component (141), action 7. + cmd := fmt.Sprintf("#DEVICE,%d,%d,%d,%d", d.cfg.IntegrationID, qseSceneController, qseActionScene, scene) + log.Infof("[%s] Activating scene %d", d.cfg.Name, scene) + log.Debugf("[%s] TX %s", d.cfg.Name, cmd) + d.write(cmd + qseTerminator) + return true +} + +// deviceCommand formats a "#DEVICE,,,[,...]" line, +// terminated for transmission. +func (d *Device) deviceCommand(component, action int, params ...string) string { + cmd := fmt.Sprintf("#DEVICE,%d,%d,%d", d.cfg.IntegrationID, component, action) + if len(params) > 0 { + cmd += "," + strings.Join(params, ",") + } + return cmd + qseTerminator +} + +// RaiseZone starts raising a zone's level on behalf of a source and holds until a +// matching StopZone. Zone writes for the zone are suppressed while it moves so the +// resend loop can't interrupt the ramp; the new level is followed via zone +// monitoring. It returns false when the caller can't take control (a higher- +// priority source is active or integration control is disabled). +func (d *Device) RaiseZone(b *sourceBinding, zone int) bool { + return d.moveZone(b, zone, qseActionStartRaising) +} + +// LowerZone starts lowering a zone's level; see RaiseZone. +func (d *Device) LowerZone(b *sourceBinding, zone int) bool { + return d.moveZone(b, zone, qseActionStartLowering) +} + +// moveZone begins a raise or lower on a zone, marking it ramping so the writer +// leaves it alone until StopZone. +func (d *Device) moveZone(b *sourceBinding, zone, action int) bool { + if zone < 1 || zone > d.zones { + return false + } + d.dataMu.Lock() + now := time.Now() + if d.controlDisabled || d.lockedOut(b, now) { + d.dataMu.Unlock() + return false + } + b.lastZoneActive = now + d.sceneSuppress = time.Time{} + d.zoneRamping[zone-1] = true + d.dataMu.Unlock() + + d.write(d.deviceCommand(zone, action)) + return true +} + +// StopZone halts a zone started by RaiseZone or LowerZone. Zone writes stay +// suppressed for a short grace period so the panel's final level report updates +// the target before the writer resumes, keeping the zone at its resting level. +// Stop is always sent (even when locked out) so a moving zone can be halted. +func (d *Device) StopZone(b *sourceBinding, zone int) bool { + if zone < 1 || zone > d.zones { + return false + } + d.dataMu.Lock() + now := time.Now() + b.lastZoneActive = now + d.zoneRamping[zone-1] = false + d.zoneSuppress[zone-1] = now.Add(zoneStopGrace) + d.dataMu.Unlock() + + d.write(d.deviceCommand(zone, qseActionStopMoving)) + return true +} + +// ApplyShade drives a shade column on behalf of a source. The action is one of +// "open", "close", "preset", "raise", "lower", or "stop". Open, close, and preset +// are momentary (press then release); raise and lower start motion and are halted +// by a subsequent "stop". It returns false for an unknown column or action. +func (d *Device) ApplyShade(b *sourceBinding, column int, action string) bool { + col, ok := qseShadeColumns[column] + if !ok { + return false + } + switch action { + case "open": + d.pressRelease(col.open) + case "close": + d.pressRelease(col.close) + case "preset": + d.pressRelease(col.preset) + case "raise": + d.shadeStart(column, col.raise) + case "lower": + d.shadeStart(column, col.lower) + case "stop": + d.shadeStop(column) + default: + return false + } + return true +} + +// pressRelease issues a press then release on a phantom-button component, which +// the panel acts on at the release. +func (d *Device) pressRelease(component int) { + d.write(d.deviceCommand(component, qseBtnAction)) + d.write(d.deviceCommand(component, qseActionRelease)) +} + +// shadeStart presses a shade raise/lower component to begin motion, recording the +// held component so a later stop releases it. A held opposite direction is +// released first. +func (d *Device) shadeStart(column, component int) { + d.dataMu.Lock() + prev := d.shadeHeld[column] + d.shadeHeld[column] = component + d.dataMu.Unlock() + if prev != 0 && prev != component { + d.write(d.deviceCommand(prev, qseActionRelease)) + } + d.write(d.deviceCommand(component, qseBtnAction)) +} + +// shadeStop releases the held shade component for a column, halting motion. +func (d *Device) shadeStop(column int) { + d.dataMu.Lock() + component := d.shadeHeld[column] + delete(d.shadeHeld, column) + d.dataMu.Unlock() + if component != 0 { + d.write(d.deviceCommand(component, qseActionRelease)) + } +} + +// SetZoneLock enables or disables the zone lock (QS Standalone). +func (d *Device) SetZoneLock(on bool) { + d.write(d.deviceCommand(qseSceneController, qseActionZoneLock, boolParam(on))) +} + +// SetSceneLock enables or disables the scene lock (QS Standalone). +func (d *Device) SetSceneLock(on bool) { + d.write(d.deviceCommand(qseSceneController, qseActionSceneLock, boolParam(on))) +} + +// SetSequence sets the scene sequence state: 0 off, 1 scenes 1-4, 2 scenes 5-16. +func (d *Device) SetSequence(mode int) bool { + if mode < 0 || mode > 2 { + return false + } + d.write(d.deviceCommand(qseSceneController, qseActionSequence, strconv.Itoa(mode))) + return true +} + +// SceneOff activates the Scene Off button on behalf of a source, turning all +// zones off. Like ApplyScene it suppresses zone writes while the off look holds +// so the writer doesn't immediately re-light the zones. +func (d *Device) SceneOff(b *sourceBinding) bool { + d.dataMu.Lock() + now := time.Now() + if d.controlDisabled || d.lockedOut(b, now) { + d.dataMu.Unlock() + return false + } + b.lastSceneActive = now + d.sceneSuppress = now.Add(b.hold) + d.dataMu.Unlock() + + d.pressRelease(qseSceneOffButton) + return true +} + +// boolParam renders a Lutron 0/1 on/off parameter. +func boolParam(on bool) string { + if on { + return "1" + } + return "0" +} + +// ZoneTargets returns a snapshot of the current target zone levels. +func (d *Device) ZoneTargets() []byte { + d.dataMu.Lock() + defer d.dataMu.Unlock() + out := make([]byte, d.zones) + copy(out, d.target) + return out +} + +// Start launches the supervisor and worker loops. +func (d *Device) Start(ctx context.Context) { + d.ctx = ctx + d.wg.Add(1) + go d.supervisor(ctx) + d.wg.Add(1) + go d.writeLoop(ctx) + d.wg.Add(1) + go d.resendLoop(ctx) + d.wg.Add(1) + go d.watchdog(ctx) +} + +// Stop closes the connection and waits for the loops to exit. +func (d *Device) Stop() { + d.closeConn() + d.wg.Wait() +} + +// supervisor owns the (re)connect path. Other loops request a reconnect and the +// supervisor reopens the link, then resyncs zone levels from the panel. +func (d *Device) supervisor(ctx context.Context) { + defer d.wg.Done() + for { + if ctx.Err() != nil { + return + } + + // Reset per-connection state before the connection is exposed. Writes are + // suppressed until the connect-time query sync populates the current zone + // levels, so the writer can't push its zero-initialized targets and + // momentarily drive every zone to off. Setting the gate before connect + // closes the window where the writer could see the new connection but not + // yet the resync flag. + d.dataMu.Lock() + d.sendAll = true + d.resyncing = true + d.pendingResync = d.zones + d.dataMu.Unlock() + + conn := d.connect(ctx) + if conn == nil { + return // Shutting down. + } + d.lastResponse.set(time.Now()) + + // Configure monitoring and resync zone levels concurrently with the + // reader. The zone queries are paced 1500 ms apart, so running them inline + // would block reading for the whole burst and leave the panel's replies + // sitting unread in the port buffer; spawning them lets responses be + // processed as they arrive. + syncDone := make(chan struct{}) + go func() { + defer close(syncDone) + d.setupMonitoring() + d.queryZoneLevels() + // Failsafe: the resync gate normally clears as each zone reports its + // level. If a zone never replies, give the last query's response time + // to arrive, then lift the gate so writes aren't suppressed forever. + select { + case <-ctx.Done(): + case <-time.After(queryInterMessageDelay): + } + d.dataMu.Lock() + d.resyncing = false + d.dataMu.Unlock() + }() + + // Read until the connection fails or a reconnect is requested. + d.readLoop(ctx, conn) + + // Let the sync goroutine unwind before reconnecting so it can't write to + // the next connection. Once the link is down its paced writes fail fast. + <-syncDone + + select { + case <-ctx.Done(): + return + default: + } + } +} + +// connect opens the transport, retrying with exponential backoff until it +// succeeds or the context is cancelled (in which case it returns nil). +func (d *Device) connect(ctx context.Context) transport { + backoff := time.Duration(d.cfg.Reliability.ReconnectBackoffMinSec) * time.Second + maxBackoff := time.Duration(d.cfg.Reliability.ReconnectBackoffMaxSec) * time.Second + // Guard against a zero/negative configured backoff, which would spin the retry + // loop and never grow (0*2 stays 0). + if backoff <= 0 { + backoff = time.Second + } + if maxBackoff < backoff { + maxBackoff = backoff + } + for { + if ctx.Err() != nil { + return nil + } + log.Infof("[%s] Opening %s connection", d.cfg.Name, d.cfg.Transport) + conn, err := openTransport(d.cfg) + if err == nil { + d.connMu.Lock() + d.conn = conn + d.connMu.Unlock() + // Drain any stale reconnect request from a prior connection. + select { + case <-d.reconnect: + default: + } + log.Infof("[%s] Connection established", d.cfg.Name) + return conn + } + log.Errorf("[%s] Connect failed: %s; retry in %s", d.cfg.Name, err, backoff) + + select { + case <-ctx.Done(): + return nil + case <-time.After(backoff): + } + backoff = min(backoff*2, maxBackoff) + } +} + +// requestReconnect closes the current connection and wakes the supervisor. +func (d *Device) requestReconnect(reason string) { + log.Warnf("[%s] Reconnect requested: %s", d.cfg.Name, reason) + d.closeConn() + select { + case d.reconnect <- reason: + default: + } +} + +// closeConn closes the current connection if any. +func (d *Device) closeConn() { + d.connMu.Lock() + if d.conn != nil { + d.conn.Close() + d.conn = nil + } + d.connMu.Unlock() +} + +// write sends a payload to the panel, serialized and paced to honor the +// protocol's minimum inter-message delays, and requests a reconnect on failure. +// It returns true on success. +func (d *Device) write(payload string) bool { + // Queries ("?") and commands ("#") carry different post-send delays. + isQuery := len(payload) > 0 && payload[0] == '?' + zoneOp := isZoneValueOp(payload) + + // Serialize all outbound messages. A run of zone-value operations bursts so + // every zone changes at once instead of cascading; the inter-message delay is + // only owed when this message differs in kind from the previous send. + d.paceMu.Lock() + defer d.paceMu.Unlock() + if !(zoneOp && d.lastWasZoneOp) { + d.paceWait() + } + + d.connMu.Lock() + conn := d.conn + if conn == nil { + d.connMu.Unlock() + return false + } + _, err := conn.Write([]byte(payload)) + d.connMu.Unlock() + + // Schedule the earliest time a differing next message may be sent. + delay := commandInterMessageDelay + if isQuery { + delay = queryInterMessageDelay + } + d.nextSendAt = time.Now().Add(delay) + d.lastWasZoneOp = zoneOp + + if err != nil { + log.Errorf("[%s] Write failed: %s", d.cfg.Name, err) + d.requestReconnect("write error") + return false + } + d.lastWrite.set(time.Now()) + return true +} + +// isZoneValueOp reports whether a payload is a zone-level set ("#DEVICE,...,14,..."). +// Consecutive zone-level sets burst without the inter-message delay so all zones +// change together rather than cascading. Zone-level queries ("?DEVICE,...,14") are +// deliberately excluded: the protocol mandates a 1500 ms gap between queries, so +// they must pace normally rather than burst. +func isZoneValueOp(payload string) bool { + body := strings.TrimRight(payload, qseTerminator) + if !strings.HasPrefix(body, "#DEVICE,") { + return false + } + fields := strings.Split(body, ",") + return len(fields) >= 4 && fields[3] == strconv.Itoa(qseActionZoneLevel) +} + +// paceWait blocks until the minimum inter-message delay owed from the previous +// send has elapsed, returning early on shutdown. It must be called with paceMu +// held. +func (d *Device) paceWait() { + wait := time.Until(d.nextSendAt) + if wait <= 0 { + return + } + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-d.ctx.Done(): + case <-timer.C: + } +} + +// readLoop reads CRLF-terminated integration responses from conn until it +// fails, dispatching each line. It returns so the supervisor can reconnect. +func (d *Device) readLoop(ctx context.Context, conn transport) { + buf := make([]byte, 0, 256) + tmp := make([]byte, 256) + for { + n, err := conn.Read(tmp) + if err != nil { + if ctx.Err() == nil { + log.Errorf("[%s] Read failed: %s", d.cfg.Name, err) + d.requestReconnect("read error") + } + return + } + if n == 0 { + continue + } + buf = append(buf, tmp[:n]...) + + // Extract and handle each complete line. + for { + i := bytes.IndexByte(buf, '\n') + if i < 0 { + break + } + line := strings.TrimRight(string(buf[:i]), "\r") + buf = buf[i+1:] + d.handleLine(line) + } + + // Guard against an unterminated flood growing the buffer without bound. + if len(buf) > 4096 { + buf = buf[:0] + } + } +} + +// handleLine parses one integration response and updates state. +func (d *Device) handleLine(line string) { + // The QSE echoes a "QSE>" prompt; strip it and trim whitespace. + line = strings.TrimSpace(strings.ReplaceAll(line, "QSE>", "")) + if line == "" { + return + } + d.lastResponse.set(time.Now()) + log.Debugf("[%s] RX %s", d.cfg.Name, line) + + upper := strings.ToUpper(line) + switch { + case strings.HasPrefix(upper, "~ERROR"): + d.handleError(line) + case d.cfg.DisableComponent != 0 && line == d.disableSignal(): + log.Infof("[%s] Received disable signal", d.cfg.Name) + d.dataMu.Lock() + d.controlDisabled = true + d.dataMu.Unlock() + case d.cfg.EnableComponent != 0 && line == d.enableSignal(): + log.Infof("[%s] Received enable signal", d.cfg.Name) + d.dataMu.Lock() + d.controlDisabled = false + d.sendAll = true + d.dataMu.Unlock() + case strings.HasPrefix(line, d.devicePrefix()): + d.handleDeviceFeedback(line) + } + + // Relay every "~" report to generic monitor subscribers (OSC/MQTT), in + // addition to the typed handling above which drives arbitration and HA state. + d.dispatchMonitor(line) +} + +// dispatchMonitor parses a "~" feedback line into a MonitorEvent and fans it out +// to monitor subscribers. Non-"~" lines (echoes, prompts) are ignored. +func (d *Device) dispatchMonitor(line string) { + if !strings.HasPrefix(line, "~") { + return + } + d.dataMu.Lock() + if len(d.monitorFns) == 0 { + d.dataMu.Unlock() + return + } + fns := d.monitorFns + d.dataMu.Unlock() + + fields := strings.Split(line, ",") + ev := MonitorEvent{ + Family: strings.ToUpper(strings.TrimPrefix(fields[0], "~")), + Fields: fields[1:], + Raw: line, + } + for _, fn := range fns { + fn(ev) + } +} + +// handleDeviceFeedback dispatches a "~DEVICE,,..." response to the zone-level +// or scene handler based on its component and action. +func (d *Device) handleDeviceFeedback(line string) { + fields := strings.Split(line, ",") + if len(fields) < 4 || fields[1] != strconv.Itoa(d.cfg.IntegrationID) { + return + } + switch { + case fields[3] == strconv.Itoa(qseActionZoneLevel): + d.handleZoneFeedback(fields) + case fields[2] == strconv.Itoa(qseSceneController) && fields[3] == strconv.Itoa(qseActionScene): + d.handleSceneFeedback(fields) + } +} + +// handleZoneFeedback parses a "~DEVICE,,,14," response, records +// the reported level, and mirrors it to feedback subscribers. The target is +// synced to the reported level during the connect-time resync and when an +// unsolicited report reflects a genuine external change (a keypad or scene), so +// the writer follows it instead of fighting it. A report that merely echoes one +// of our own recent writes or query probes is not adopted: the panel quantizes +// levels to its own scale, so its echo rarely byte-matches what we sent, and +// adopting it back would create a self-sustaining write loop (and re-populate +// zones mid-fade that a release is trying to drive to 0). +func (d *Device) handleZoneFeedback(fields []string) { + if len(fields) < 5 { + return + } + zone, err := strconv.Atoi(fields[2]) + if err != nil || zone < 1 || zone > d.zones { + return + } + pct, err := strconv.ParseFloat(fields[4], 64) + if err != nil { + return + } + level := byte(math.Round(pct / 100.0 * 255.0)) + + d.dataMu.Lock() + d.reported[zone-1] = level + // Classify the report: a solicited reply follows our own query, an echo follows + // our own write. Neither is an external change, so neither should move the + // target. Adopt only a resync reply or a genuine unsolicited report. + solicited := time.Since(d.lastZoneQuery[zone-1]) < queryReplyWindow + echo := time.Since(d.lastZoneWrite[zone-1]) < zoneEchoWindow + resyncing := d.resyncing + adopt := resyncing || (!solicited && !echo) + if adopt { + d.target[zone-1] = level + d.sent[zone-1] = level + } + // Lift the connect-time resync gate once every zone has reported, so the + // writer resumes with targets that mirror the panel rather than zeros. + if resyncing && d.pendingResync > 0 { + d.pendingResync-- + if d.pendingResync == 0 { + d.resyncing = false + } + } + // Mirror genuine reports to subscribers (e.g. Home Assistant), but not a + // solicited probe reply, which is for liveness only and must not flip + // subscribers to a value that disagrees with the level we maintain. + var fns []zoneFeedbackFn + if resyncing || !solicited { + fns = d.feedbackFns + } + d.dataMu.Unlock() + + for _, fn := range fns { + fn(zone, level) + } +} + +// handleSceneFeedback parses a "~DEVICE,,141,7," response and notifies +// scene subscribers. +func (d *Device) handleSceneFeedback(fields []string) { + if len(fields) < 5 { + return + } + scene, err := strconv.Atoi(fields[4]) + if err != nil { + return + } + d.dataMu.Lock() + fns := d.sceneFeedbackFns + d.dataMu.Unlock() + + for _, fn := range fns { + fn(scene) + } +} + +// handleError logs a ~ERROR response and, for the NWK-lockup error, resets the +// interface. The reset is rate-limited because a wedged NWK floods the error. +func (d *Device) handleError(line string) { + code := "" + if _, after, ok := strings.Cut(line, ","); ok { + code = strings.TrimSpace(after) + } + desc := qseErrorDescriptions[code] + if desc == "" { + desc = "unknown error" + } + + // Error 6 ("unsupported command") is also the symptom of the NWK lockup that + // only #RESET,0 clears, so we recover from it; other errors are logged only. + if code == "6" { + cooldown := time.Duration(d.cfg.Reliability.ResetCooldownSec) * time.Second + if time.Since(d.lastReset.get()) >= cooldown { + d.lastReset.set(time.Now()) + log.Warnf("[%s] NWK returned %s (%s); sending #RESET,0", d.cfg.Name, line, desc) + d.write("#RESET,0" + qseTerminator) + } + return + } + log.Warnf("[%s] NWK returned %s (%s)", d.cfg.Name, line, desc) +} + +// writeLoop periodically pushes changed zone levels out to the panel. +func (d *Device) writeLoop(ctx context.Context) { + defer d.wg.Done() + ticker := time.NewTicker(writeInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + d.flushZones() + } + } +} + +// flushZones writes any zone levels that changed since the last pass. +func (d *Device) flushZones() { + d.dataMu.Lock() + // Don't push zone levels while the connect-time resync is still in progress, + // while integration control is disabled, or while a scene is holding (so the + // scene isn't immediately overwritten). + if d.resyncing || d.controlDisabled || time.Now().Before(d.sceneSuppress) { + d.dataMu.Unlock() + return + } + now := time.Now() + targets := make([]byte, d.zones) + copy(targets, d.target) + fades := make([]string, d.zones) + copy(fades, d.zoneFade) + ramping := make([]bool, d.zones) + copy(ramping, d.zoneRamping) + suppress := make([]time.Time, d.zones) + copy(suppress, d.zoneSuppress) + // Snapshot the last-sent levels under the lock too; the read loop writes them + // from handleZoneFeedback, so comparing against d.sent directly would race. + sent := make([]byte, d.zones) + copy(sent, d.sent) + resendAll := d.sendAll + d.sendAll = false + d.dataMu.Unlock() + + // Skip writing entirely if the link is down; the supervisor is reconnecting. + d.connMu.Lock() + connected := d.conn != nil + d.connMu.Unlock() + if !connected { + return + } + + for zone := range d.zones { + // Skip zones being raised/lowered and those still in their post-stop grace + // so the writer can't interrupt a ramp or yank a zone back to its + // pre-move level before the panel reports the resting level. + if ramping[zone] || now.Before(suppress[zone]) { + continue + } + if !resendAll && targets[zone] == sent[zone] { + continue + } + if !d.sendZoneLevel(zone+1, targets[zone], fades[zone]) { + // Write failed: mark a full resend for next pass and let the supervisor + // reopen the port. + d.dataMu.Lock() + d.sendAll = true + d.dataMu.Unlock() + return + } + // Record the write so the panel's echo of it isn't mistaken for an + // external change in handleZoneFeedback. + d.dataMu.Lock() + d.sent[zone] = targets[zone] + d.lastZoneWrite[zone] = time.Now() + d.dataMu.Unlock() + } +} + +// sendZoneLevel writes a single zone's level as a percentage with the given fade +// time, falling back to the device default when the source set none. +func (d *Device) sendZoneLevel(zone int, value byte, fade string) bool { + if fade == "" { + fade = d.cfg.Fade + } + pct := math.Round(float64(value)/255.0*100.0*100.0) / 100.0 + cmd := fmt.Sprintf("#DEVICE,%d,%d,%d,%.2f,%s", + d.cfg.IntegrationID, zone, qseActionZoneLevel, pct, fade) + log.Debugf("[%s] TX %s", d.cfg.Name, cmd) + return d.write(cmd + qseTerminator) +} + +// setupMonitoring configures which integration messages the panel reports. The +// protocol advises enabling only what is needed; we ensure zone-level and reply +// monitoring are on (so feedback works regardless of the unit's programmed +// defaults) plus scene monitoring when a scene source is attached, then apply any +// configured overrides. +func (d *Device) setupMonitoring() { + m := d.cfg.Monitoring + if m.Manage == nil || *m.Manage { + d.setMonitoring(qseMonitorZone, true) + d.setMonitoring(qseMonitorReply, true) + d.dataMu.Lock() + wantScene := d.sceneMonitoring + // Snapshot the source-requested types, skipping the built-ins already sent. + requested := make([]int, 0, len(d.requestedMonitoring)) + for t := range d.requestedMonitoring { + if t != qseMonitorZone && t != qseMonitorReply { + requested = append(requested, t) + } + } + d.dataMu.Unlock() + if wantScene { + d.setMonitoring(qseMonitorScene, true) + } + // Enable the union of monitoring types sources asked to relay. + for _, t := range requested { + if t == qseMonitorScene && wantScene { + continue // Already enabled above. + } + d.setMonitoring(t, true) + } + } + for _, t := range m.Enable { + d.setMonitoring(t, true) + } + for _, t := range m.Disable { + d.setMonitoring(t, false) + } +} + +// setMonitoring enables or disables one monitoring type via #MONITORING. +func (d *Device) setMonitoring(monitorType int, enable bool) { + action := 2 // Disable. + if enable { + action = 1 // Enable. + } + cmd := fmt.Sprintf("#MONITORING,%d,%d", monitorType, action) + log.Debugf("[%s] TX %s", d.cfg.Name, cmd) + d.write(cmd + qseTerminator) +} + +// queryZoneLevels asks the panel for each zone's current level so our view +// matches reality after a (re)connect. Replies are handled in handleLine. The +// queries are paced (1500 ms apart) per the protocol, so this is used only for +// the one-time connect sync; ongoing updates arrive via zone monitoring. +func (d *Device) queryZoneLevels() { + for zone := 1; zone <= d.zones; zone++ { + d.queryZone(zone) + } +} + +// queryZone asks the panel for one zone's current level. It records the query +// time so the solicited reply is used for liveness only and does not override the +// level we maintain (see handleZoneFeedback). +func (d *Device) queryZone(zone int) { + query := fmt.Sprintf("?DEVICE,%d,%d,%d", d.cfg.IntegrationID, zone, qseActionZoneLevel) + d.dataMu.Lock() + d.lastZoneQuery[zone-1] = time.Now() + d.dataMu.Unlock() + log.Debugf("[%s] TX %s", d.cfg.Name, query) + d.write(query + qseTerminator) +} + +// resendLoop forces a periodic full resend so the panel can't drift out of sync. +// A zero interval disables the periodic refresh: zones are then sent only when a +// target changes (reconnect and recovery resyncs still re-assert full state). +func (d *Device) resendLoop(ctx context.Context) { + defer d.wg.Done() + interval := time.Duration(d.cfg.Reliability.SendAllIntervalSec) * time.Second + if interval <= 0 { + return + } + rxTimeout := time.Duration(d.cfg.Reliability.RxTimeoutSec) * time.Second + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + // Skip resends when the link looks unhealthy; the watchdog handles it. + if time.Since(d.lastResponse.get()) > rxTimeout { + continue + } + d.dataMu.Lock() + d.sendAll = true + d.dataMu.Unlock() + } + } +} + +// watchdog detects a stale link and reconnects it. The panel only +// replies to a #DEVICE write when a level actually changes, so a quiet RX is not +// itself a fault: we first probe the link (which gets a reply on a healthy link) +// and only reconnect if that probe also goes unanswered. +func (d *Device) watchdog(ctx context.Context) { + defer d.wg.Done() + interval := time.Duration(d.cfg.Reliability.WatchdogIntervalSec) * time.Second + rxTimeout := time.Duration(d.cfg.Reliability.RxTimeoutSec) * time.Second + ticker := time.NewTicker(interval) + defer ticker.Stop() + probed := false + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + + now := time.Now() + wroteRecently := now.Sub(d.lastWrite.get()) < rxTimeout + staleFor := now.Sub(d.lastResponse.get()) + if wroteRecently && staleFor > rxTimeout { + if !probed { + d.probeLink(staleFor) + probed = true + } else { + d.requestReconnect(fmt.Sprintf("no reply to probe; silent for %.0fs", staleFor.Seconds())) + probed = false + } + } else { + probed = false + } + } +} + +// probeLink elicits a reply to confirm the link is alive and re-asserts state on +// recovery. It prefers re-asserting monitoring: the panel acks with ~MONITORING +// (proving liveness) and re-enabling the reply path recovers a NWK whose poll +// state has gone stale after idle — a bare ?DEVICE poll returns a stale level +// (e.g. 0.01) on such a link while monitoring stays accurate, so we lean on +// monitoring rather than polling. When monitoring is left to the unit's +// programming, fall back to a zone query whose reply (value ignored) still proves +// the link is alive. staleFor is how long the link has been silent, for logging. +func (d *Device) probeLink(staleFor time.Duration) { + // Re-drive every zone's target on recovery. The probe itself touches only one + // channel, but the resend loop pauses while RX is stale, so all zones — not + // just the probed one — must be re-asserted once the link is back. + d.dataMu.Lock() + d.sendAll = true + d.dataMu.Unlock() + + m := d.cfg.Monitoring + if m.Manage == nil || *m.Manage { + log.Infof("[%s] No reply for %.0fs while sending; re-asserting monitoring to test the link and restore the reply path", + d.cfg.Name, staleFor.Seconds()) + d.setMonitoring(qseMonitorReply, true) + d.setMonitoring(qseMonitorZone, true) + // Re-assert source-requested monitoring too so the relay resumes after a + // NWK reply-path reset, not just the built-in zone/reply path. + d.dataMu.Lock() + requested := make([]int, 0, len(d.requestedMonitoring)) + for t := range d.requestedMonitoring { + if t != qseMonitorZone && t != qseMonitorReply { + requested = append(requested, t) + } + } + d.dataMu.Unlock() + for _, t := range requested { + d.setMonitoring(t, true) + } + return + } + log.Infof("[%s] No reply for %.0fs while sending; querying zone 1 to test the link", + d.cfg.Name, staleFor.Seconds()) + d.queryZone(1) +} + +// devicePrefix is the "~DEVICE," prefix for this device's feedback lines. +func (d *Device) devicePrefix() string { + return fmt.Sprintf("~DEVICE,%d", d.cfg.IntegrationID) +} + +// disableSignal is the integration-control-disabled signal line. +func (d *Device) disableSignal() string { + return fmt.Sprintf("~DEVICE,%d,%d,%d", d.cfg.IntegrationID, d.cfg.DisableComponent, qseBtnAction) +} + +// enableSignal is the integration-control-enabled signal line. +func (d *Device) enableSignal() string { + return fmt.Sprintf("~DEVICE,%d,%d,%d", d.cfg.IntegrationID, d.cfg.EnableComponent, qseBtnAction) +} + +// atomicTime is a small mutex-guarded time value shared between loops. +type atomicTime struct { + mu sync.Mutex + t time.Time +} + +func (a *atomicTime) set(t time.Time) { + a.mu.Lock() + a.t = t + a.mu.Unlock() +} + +func (a *atomicTime) get() time.Time { + a.mu.Lock() + defer a.mu.Unlock() + return a.t +} diff --git a/device_test.go b/device_test.go new file mode 100644 index 0000000..1fd16f1 --- /dev/null +++ b/device_test.go @@ -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 +} diff --git a/discover.go b/discover.go new file mode 100644 index 0000000..05171ae --- /dev/null +++ b/discover.go @@ -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 +} diff --git a/flags.go b/flags.go new file mode 100644 index 0000000..45272a7 --- /dev/null +++ b/flags.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b879782 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..01ced64 --- /dev/null +++ b/go.sum @@ -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= diff --git a/lutron-control.service b/lutron-control.service new file mode 100644 index 0000000..63341f3 --- /dev/null +++ b/lutron-control.service @@ -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 diff --git a/main.go b/main.go new file mode 100644 index 0000000..ac8fc11 --- /dev/null +++ b/main.go @@ -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) + } + } +} diff --git a/mqtt.go b/mqtt.go new file mode 100644 index 0000000..9e68ab6 --- /dev/null +++ b/mqtt.go @@ -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 /// +// 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 +} diff --git a/osc.go b/osc.go new file mode 100644 index 0000000..ff6d32f --- /dev/null +++ b/osc.go @@ -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: +// +// /zone//level f|i set zone level (float 0-1, or int 0-255) +// /zone//raise start raising zone +// /zone//lower start lowering zone +// /zone//stop stop raising/lowering zone +// /scene i activate scene +// /scene/off activate the scene-off look +// /shade// drive shade column (open|close|preset|raise|lower|stop) +// /lock/zone i zone lock (0 off, 1 on) +// /lock/scene i scene lock (0 off, 1 on) +// /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//". + 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//..." 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,,,, + 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,,1, + 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,,3, + 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: +// /monitor// 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 + } +} diff --git a/osc_test.go b/osc_test.go new file mode 100644 index 0000000..b9fe2e4 --- /dev/null +++ b/osc_test.go @@ -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//... 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) + } +} diff --git a/sacn.go b/sacn.go new file mode 100644 index 0000000..6ea9d81 --- /dev/null +++ b/sacn.go @@ -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() + } +} diff --git a/source.go b/source.go new file mode 100644 index 0000000..aba9121 --- /dev/null +++ b/source.go @@ -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 +} diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..a6c94f2 --- /dev/null +++ b/transport.go @@ -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}) +}