Compare commits

..

No commits in common. "21fe3a1476ed6fd72160bbdee1dad41d1b999ccb" and "f666e423c2fba8ccd862ff8ac054af2202c36b17" have entirely different histories.

7 changed files with 140 additions and 1376 deletions

5
.gitignore vendored
View file

@ -1,5 +0,0 @@
__pycache__/
*.pyc
# Local config holds secrets (MQTT password); never commit it.
config.yaml

260
README.md
View file

@ -1,262 +1,48 @@
This project controls the GRAFIK Eye QS control panel via a QSE-CI-NWK-E over its
serial interface. It uses the [OLA](https://www.openlighting.org/) project to take
a DMX device or a network DMX protocol (e.g. sACN/E1.31) and drive the 6 available
zones. It also speaks MQTT for Home Assistant control, with MQTT auto discovery so
the light appears automatically.
This project is designed to control the GRAFIK Eye QS Control panel via the QSE-CI-NWK-E with the serial interface. The project is designed to use the OLA (https://www.openlighting.org/) project to use either a DMX device to a network DMX protocol to control the 6 available zones.
I run this on a Raspberry Pi (a Pi Zero works) on **Raspberry Pi OS / Raspbian 13
(Trixie)**. OLA is no longer packaged for recent Debian/Raspbian releases, so it is
built from source at the `0.10.9` release tag with `install-ola.sh` (see below),
following the [OLA build guide](https://www.openlighting.org/ola/linuxinstall/).
I designed this software for use on a Raspberry Pi using the 2019-07-10-raspbian-buster-lite release and OLA at https://github.com/OpenLightingProject/ola/tree/dc40569a7ef2512c7c9459a94c9dc4292d809262 compiled and installed using instructions at https://www.openlighting.org/ola/linuxinstall/
DMX and MQTT are independent, optional components. Serial control of the QSE is
always active; you can run with DMX only, MQTT only, or both.
# Configuration
Once OLA is installed, run it using `olad -l 3` and then edit the configuration files in `.ola/` to disable the modules which are not used as some of them will take the serial device. Once configured, run `olad -l 3` again and visit the raspberry pi's IP address at port 9090 in your browser to configure the DMX universe you are going to use. Once configured, you can then test this software by changing the configuration portion of the code.
# What you'll need
Trick to disable all modules except the one you are using.
- A **Raspberry Pi** running Raspberry Pi OS (a Pi Zero is enough; a Pi Zero **W**
or any model with networking is needed for sACN/MQTT). These instructions assume
**Raspberry Pi OS / Raspbian 13 (Trixie)**.
- A **USB-to-serial adapter** wired to the QSE-CI-NWK-E's serial terminals (the
config example uses a Prolific PL2303-style adapter; any 3.3 V / RS-232 adapter
that matches your wiring works).
- A **GRAFIK Eye QS** with a QSE-CI-NWK-E network/serial interface.
- For DMX: a lighting console or software sending **sACN/E1.31** on your network.
- For MQTT / Home Assistant: a running **MQTT broker** (the Docker setup below
includes one).
# Overview
The setup is three steps once the Pi is ready:
1. **Prepare the Pi and get the code** (step 0) — flash the OS, get a terminal, clone this repo.
2. **Install OLA** (step 1) — only if you use DMX. This is the slow part (~12 h on a Pi Zero).
3. **Install the control service** (step 2) and **configure it** (step 3).
```bash
sed -i '/enabled\s=/c\enabled = false' ~/.ola/*.conf
sed -i '/enabled\s=/c\enabled = true' ~/.ola/ola-e131.conf
```
# Installation
## 0. Prepare the Pi and get the code
If you're starting from scratch, flash **Raspberry Pi OS** with the
[Raspberry Pi Imager](https://www.raspberrypi.com/software/). In the imager's
settings (the gear / "Edit settings"), **set a username and password and enable
SSH** — remember the username you choose; you'll use it everywhere below as
`<user>`. Modern Raspberry Pi OS no longer defaults to the `pi` user, so don't
assume it; use whatever name you set here.
Boot the Pi, then open a terminal on it (directly, or over SSH:
`ssh <user>@<pi-address>`). Install git and download this project:
Install Python/needed modules.
```bash
sudo apt-get update
sudo apt-get install -y git
git clone https://github.com/GRMrGecko/lutron-dmx-control.git
cd lutron-dmx-control
apt install python3-pip python3-serial
pip3 install ola
```
All the commands below are run from inside this `lutron-dmx-control` directory.
> Throughout this guide, replace `<user>` with the username you created above. If
> that username is **not** `pi`, you must also pass it to the installer
> (`TARGET_USER=<user>`, shown in step 2) and substitute it in every
> `systemctl`/`journalctl` command (e.g. `lutron-dmx-control@<user>`, not `@pi`).
## 1. Install OLA (only if using DMX)
If you set `dmx.enabled: false`, skip this step — OLA does not need to be installed.
Otherwise build and install OLA (the daemon plus the Python client bindings the
control script uses). On a single-core Pi Zero this takes roughly 12 hours; the
script adds temporary swap on low-memory boards so the compile does not run out of
memory.
Copy lutron-dmx-control@.service and olad@.service to /etc/systemd/system/ and run the following to enable/start.
```bash
bash ./install-ola.sh
systemctl daemon-reload
systemctl enable olad@pi
systemctl start olad@pi
systemctl enable lutron-dmx-control@pi
systemctl start lutron-dmx-control@pi
```
This installs the build dependencies, clones OLA at the `0.10.9` tag, and builds and
installs `olad` plus the `ola.ClientWrapper` Python module. Override the version or
build directory with `OLA_VERSION=` / `BUILD_DIR=` if needed.
# Recommend
## 2. Install the control service
`install.sh` installs the Python dependencies, the control script, the config file
and the `olad@<user>` / `lutron-dmx-control@<user>` systemd services. By default it
installs for the `pi` user; pass `TARGET_USER=<name>` for a different user.
```bash
sudo bash ./install.sh
# or, for a non-pi user:
sudo TARGET_USER=james bash ./install.sh
```
The service is **enabled** (starts on boot) but, on a first install, is **not
started immediately** — the freshly installed config still has placeholder values.
The installer prints the exact edit-then-start steps; see step 3 below. On a re-run
with an existing config it restarts the service to pick up the new version.
> Note: the systemd unit runs `/home/<user>/lutron-dmx-control.py`, so `<user>`'s
> home must be `/home/<user>`. If it lives elsewhere, the installer warns you to
> adjust `ExecStart` in `lutron-dmx-control@.service`.
## 3. Configure
Edit `/etc/lutron-dmx-control/config.yaml` (installed from `config.example.yaml`) and set:
- `serial.device` — your serial device (use `ls -lah /dev/serial/by-id/`).
- `qse.integration_id` and `qse.zones` — to match your GRAFIK Eye unit.
- `dmx.enabled` / `dmx.universe` / `dmx.start_address` — for your DMX layout.
`dmx.lockout_sec` (default `5`) sets how long an active DMX signal locks out MQTT
control. Set `dmx.enabled: false` to run without OLA/DMX.
- `mqtt.broker`, `mqtt.username`, `mqtt.password` — if using MQTT. Set
`mqtt.enabled: false` to run without MQTT/Home Assistant; `paho-mqtt` is then not
required.
The config is searched for at `--config PATH`, then `$LUTRON_CONFIG`, then `config.yaml`
next to the script, then `~/.config/lutron-dmx-control/config.yaml`, then
`/etc/lutron-dmx-control/config.yaml`. It holds the MQTT password, so it is `chmod 600`
and excluded from git (`config.yaml` in `.gitignore`); only `config.example.yaml` is
committed.
Then start (first install) or restart (after edits) the service:
`sudo systemctl start lutron-dmx-control@pi` (use `restart` if it is already running).
Check it came up cleanly with `journalctl -u lutron-dmx-control@pi -f`.
# OLA / DMX configuration
`install.sh` configures OLA for **network DMX only (E1.31/sACN)** by default: it
disables every OLA plugin except `e131`. This matters because olad's serial/USB
device plugins (e.g. `usbserial`) otherwise auto-probe and grab the QSE's serial
adapter (`/dev/ttyUSB*`), conflicting with this program. The plugin configs live in
`~/.ola/` if you want to change this later.
To enable a different/extra plugin, stop olad, flip its config, and restart:
```bash
sudo systemctl stop olad@pi
sed -i '/^enabled\s*=/c\enabled = true' ~/.ola/ola-artnet.conf # example: also accept Art-Net
sudo systemctl start olad@pi
```
## Receiving sACN (patching the universe)
For olad to actually receive sACN, an **E1.31 input port must be patched to your OLA
universe** — the OLA universe number is the sACN universe (e.g. universe `3` =
multicast `239.255.0.3`). Registering the universe from the client is not enough;
without a patched input port olad never joins the sACN multicast group.
`install.sh` does this automatically: it patches the E1.31 input port to the
`dmx.universe` from your `config.yaml`. To do it (or change it) by hand:
```bash
# Find the E1.31 device id, then patch input port 0 to your universe (here 3):
ola_dev_info
ola_patch --device 1 --port 0 --input --universe 3
# Confirm the multicast join on your active interface (eth0 wired, wlan0 on a Pi Zero W):
ip maddr show dev eth0 | grep 239.255.0.3
curl -s http://localhost:9090/get_dmx?u=3 # confirm DMX values are arriving
```
You can also patch from the olad web UI at the Pi's IP, port `9090`. The patch is
saved in `~/.ola/` and survives restarts/reboots.
> Note: on the console/desktop sending sACN, a "changes only" / "send on change"
> option means it only transmits when levels change. Prefer a continuous stream so
> olad has data immediately after a restart.
# Home Assistant & MQTT (Docker)
I run Home Assistant and the Mosquitto MQTT broker in Docker via `docker compose`.
A 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 discover the broker and the control script
publish to it on `127.0.0.1:1883`. Bring it up with `docker compose up -d`.
## Mosquitto config
Mosquitto needs a config and a password in the mounted `./mosquitto` directory.
`./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` (grant the `mqtt` user full access):
```
user mqtt
topic readwrite #
```
Create the password file (use the same `mqtt` user/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`, the `mqtt` user/password).
With `mqtt.discovery: true` (the default in `config.yaml`), the light is published via
Home Assistant MQTT discovery and appears automatically — no YAML needed. To disable
discovery, set `mqtt.discovery: false` and add the light manually:
```yaml
light:
- platform: mqtt
schema: json
name: lutron_qse_nwk
state_topic: "lutron/qse-nwk"
command_topic: "lutron/qse-nwk/set"
brightness: true
color_mode: true
supported_color_modes: ["brightness"]
```
# Recommended: watchdog
Enable the hardware watchdog on the Pi to auto-reboot on a system crash.
Add to `/boot/firmware/config.txt` (or `/boot/config.txt` on older images) under the
`[all]` section:
Enable watchdog on the Raspberry Pi to auto reboot upon system crashes.
Edit `/boot/config.txt` and add under the `[all]` section.
```
watchdog=on
```
Uncomment `RuntimeWatchdogSec` in `/etc/systemd/system.conf` and set it:
Edit `/etc/systemd/system.conf` and uncomment `RuntimeWatchdogSec` and set it as follows.
```
RuntimeWatchdogSec=10s
```
Reboot to apply.
After configuring, reboot.

View file

@ -1,67 +0,0 @@
# Configuration for lutron-dmx-control.
#
# Copy this file to config.yaml (kept out of git) and edit it:
# sudo install -d /etc/lutron-dmx-control
# sudo cp config.example.yaml /etc/lutron-dmx-control/config.yaml
# sudo chmod 600 /etc/lutron-dmx-control/config.yaml # contains the MQTT password
#
# The program searches for the config in this order:
# 1. --config PATH on the command line
# 2. $LUTRON_CONFIG
# 3. config.yaml next to the script
# 4. ~/.config/lutron-dmx-control/config.yaml
# 5. /etc/lutron-dmx-control/config.yaml
# --- QSE NWK serial link ---
serial:
# Find yours with: ls -lah /dev/serial/by-id/
device: /dev/serial/by-id/usb-Prolific_Technology_Inc._USB-Serial_Controller-if00-port0
# Must match the dipswitch on the QSE-CI-NWK-E (9600/19200/38400/115200).
baud: 115200
# --- GRAFIK Eye QS ---
qse:
integration_id: 1 # Integration ID bound to the GRAFIK Eye main unit
zones: 6 # Controllable zones on your model (max 24)
fade: "00:00" # Fade sent with each level command; "00:00" = instant (OLA fades)
# --- DMX (OLA) ---
# Optional. Set enabled: false to run without DMX (e.g. MQTT-only control); when
# disabled, OLA does not need to be installed.
dmx:
enabled: true
universe: 3
start_address: 0 # 0-indexed offset of zone 1 within the universe
# While a DMX signal is active, MQTT commands are locked out so DMX stays in
# control. This is how long (seconds) after the last DMX universe update the
# lockout persists; once it elapses MQTT can drive the lights again.
lockout_sec: 5
# --- 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
# --- Logging ---
logging:
level: INFO # DEBUG for verbose TX/RX tracing
# --- MQTT / Home Assistant ---
# Optional. Set enabled: false to disable; when disabled, paho-mqtt does not
# need to be installed.
mqtt:
enabled: true
broker: 127.0.0.1
port: 1883
topic: lutron/qse-nwk
username: mqtt
password: change-me
client_id: null # null -> auto-generated
# Publish a Home Assistant MQTT discovery config so the light appears
# automatically (no manual configuration.yaml entry needed).
discovery: true
discovery_prefix: homeassistant
device_name: Lutron QSE NWK

View file

@ -1,149 +0,0 @@
#!/bin/bash
#
# install-ola.sh - Build and install the Open Lighting Architecture (OLA) from
# source on Debian/Raspbian. Verified on Raspberry Pi OS / Raspbian 13 (Trixie)
# on a single-core ARMv6 Pi (Pi Zero / Pi 1).
#
# OLA is not packaged for recent Debian/Raspbian releases, so we build the
# 0.10.9 release tag from git. This installs the build dependencies, the C++
# daemon (olad) and the Python client bindings (ola.ClientWrapper) that
# lutron-dmx-control.py needs when DMX is enabled.
#
# Usage (run as a normal user that can sudo, or as root):
# bash ./install-ola.sh
#
# Override the version, build location, or parallel-make jobs if needed:
# OLA_VERSION=0.10.9 BUILD_DIR=~/ola-build JOBS=4 bash ./install-ola.sh
#
# Build the OLA docs reference: https://www.openlighting.org/ola/linuxinstall/
set -e
OLA_VERSION="${OLA_VERSION:-0.10.9}"
BUILD_DIR="${BUILD_DIR:-$HOME/ola-build}"
OLA_REPO="${OLA_REPO:-https://github.com/OpenLightingProject/ola.git}"
# Use sudo only when we are not already root.
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
else
SUDO="sudo"
if ! command -v sudo >/dev/null 2>&1; then
echo "This script needs root (apt + make install). Install sudo or run as root."
exit 1
fi
fi
echo "==> Installing build dependencies"
$SUDO apt-get update
# Dev packages pull in the matching runtime libraries automatically. The Python
# client bindings need python3-protobuf and python3-dev.
$SUDO apt-get install -y \
git build-essential libtool autoconf automake pkg-config \
bison flex make g++ \
libcppunit-dev uuid-dev zlib1g-dev libncurses5-dev \
protobuf-compiler libprotobuf-dev libprotoc-dev \
libmicrohttpd-dev libftdi1-dev libusb-1.0-0-dev \
libavahi-client-dev \
python3 python3-dev python3-protobuf python3-numpy
# On low-memory boards (e.g. the 512MB Pi Zero) the protobuf-heavy C++ files
# exhaust RAM and the compiler is OOM-killed. Add temporary swap for the build
# and remove it afterwards. Skipped when there is already plenty of RAM+swap.
SWAPFILE="/swapfile-ola-build"
ADDED_SWAP=0
mem_kb=$(awk '/MemTotal/ {print $2}' /proc/meminfo)
swap_kb=$(awk '/SwapTotal/ {print $2}' /proc/meminfo)
LOW_MEM=0
if [ "$((mem_kb + swap_kb))" -lt 2097152 ]; then
LOW_MEM=1
if [ ! -f "$SWAPFILE" ]; then
echo "==> Low memory detected ($(((mem_kb + swap_kb) / 1024))MB RAM+swap); adding 2G temporary build swap"
$SUDO fallocate -l 2G "$SWAPFILE" || $SUDO dd if=/dev/zero of="$SWAPFILE" bs=1M count=2048
$SUDO chmod 600 "$SWAPFILE"
$SUDO mkswap "$SWAPFILE" >/dev/null
$SUDO swapon "$SWAPFILE"
ADDED_SWAP=1
fi
fi
# Pick the parallel-make job count. On a low-memory board, a full -j<cores> build
# of the protobuf-heavy C++ thrashes swap and can still get OOM-killed, so cap it
# (a single-core Pi is unaffected -- nproc is 1 there anyway). Override with JOBS=.
if [ -n "$JOBS" ]; then
:
elif [ "$LOW_MEM" -eq 1 ]; then
JOBS=$([ "$(nproc)" -gt 2 ] && echo 2 || nproc)
else
JOBS=$(nproc)
fi
cleanup_swap() {
if [ "$ADDED_SWAP" -eq 1 ]; then
echo "==> Removing temporary build swap"
$SUDO swapoff "$SWAPFILE" 2>/dev/null || true
$SUDO rm -f "$SWAPFILE"
fi
}
trap cleanup_swap EXIT
# If an already-configured tree exists, resume it instead of starting over -
# make picks up where it left off. Re-running after an interrupted build (the
# Pi is slow; the compile can take 1-2 hours) just continues.
if [ -f "$BUILD_DIR/Makefile" ] && [ -f "$BUILD_DIR/config.status" ]; then
echo "==> Found a configured build in $BUILD_DIR; resuming"
cd "$BUILD_DIR"
else
echo "==> Fetching OLA $OLA_VERSION into $BUILD_DIR"
if [ -d "$BUILD_DIR/.git" ]; then
git -C "$BUILD_DIR" fetch --depth 1 origin "refs/tags/$OLA_VERSION:refs/tags/$OLA_VERSION"
git -C "$BUILD_DIR" checkout -f "$OLA_VERSION"
else
rm -rf "$BUILD_DIR"
git clone --depth 1 --branch "$OLA_VERSION" "$OLA_REPO" "$BUILD_DIR"
fi
cd "$BUILD_DIR"
echo "==> Generating the build system (autoreconf)"
autoreconf -i
# --enable-python-libs: build/install the Python client (ola.ClientWrapper).
# --disable-fatal-warnings: OLA 0.10.9 predates GCC 14; without this the new
# default warnings are treated as errors (-Werror).
# --disable-osc: OLA 0.10.9's OSC plugin uses an old liblo API that
# no longer compiles against Trixie's liblo. We do
# not use OSC (DMX/sACN only), so disable it.
echo "==> Configuring"
PYTHON=python3 ./configure --enable-python-libs --disable-fatal-warnings --disable-osc
fi
echo "==> Building with -j$JOBS (this is slow on a single-core Pi; ~1-2 hours on a Pi Zero)"
make -j"$JOBS"
echo "==> Installing"
$SUDO make install
$SUDO ldconfig
# OLA installs its Python module to .../site-packages, but Debian's python3 only
# searches .../dist-packages, so 'import ola' fails out of the box. Link the
# installed package into the dist-packages dir that is actually on sys.path.
SITE_OLA=$(find /usr/local/lib/python3*/site-packages -maxdepth 1 -name ola -type d 2>/dev/null | head -1)
if [ -n "$SITE_OLA" ] && ! python3 -c "import ola" 2>/dev/null; then
PYDIR=$(dirname "$(dirname "$SITE_OLA")")
DIST="$PYDIR/dist-packages"
echo "==> Linking OLA Python module into $DIST (Debian dist-packages)"
$SUDO mkdir -p "$DIST"
$SUDO ln -sfn ../site-packages/ola "$DIST/ola"
fi
echo "==> Verifying"
olad --version || true
if python3 -c "import ola.ClientWrapper" 2>/dev/null; then
echo " Python bindings (ola.ClientWrapper) import OK"
else
echo " WARNING: 'import ola.ClientWrapper' failed - check the install log above."
fi
echo
echo "OLA $OLA_VERSION installed. olad lives at $(command -v olad 2>/dev/null || echo /usr/local/bin/olad)."
echo "Next: install.sh sets up the olad@<user> and lutron-dmx-control@<user> services."

View file

@ -1,166 +0,0 @@
#!/bin/bash
set -e
USER=$(whoami)
if [ "$USER" != "root" ]; then
echo "Please use sudo with this install script to ensure right permissions for installation."
exit 1
fi
# Service user (matches the systemd template instance: lutron-dmx-control@<TARGET_USER>).
TARGET_USER="${TARGET_USER:-pi}"
TARGET_HOME=$(getent passwd "$TARGET_USER" | cut -d: -f6)
if [ -z "$TARGET_HOME" ]; then
echo "Target user '$TARGET_USER' does not exist. Re-run with TARGET_USER=<name> sudo ./install.sh"
exit 1
fi
# The systemd template (lutron-dmx-control@.service) runs
# /home/%i/lutron-dmx-control.py, so it expects the script at /home/$TARGET_USER.
# Warn if this user's home is elsewhere -- the service would fail to start.
if [ "$TARGET_HOME" != "/home/$TARGET_USER" ]; then
echo "WARNING: $TARGET_USER's home is '$TARGET_HOME', but the systemd unit runs"
echo " /home/$TARGET_USER/lutron-dmx-control.py. Edit ExecStart in"
echo " lutron-dmx-control@.service (and re-copy it) to point at \$TARGET_HOME,"
echo " or the service will not start."
fi
# Get the script directory.
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
cd "$SCRIPT_DIR"
# Install Python/needed modules.
# Note: OLA's Python bindings come from the OLA install itself (built from
# source per the README), not from PyPI. The PyPI 'ola' package is unrelated.
# Refresh the package lists first; on a fresh image the cache may be empty/stale
# and the install would otherwise fail with "Unable to locate package".
apt-get update
apt-get install -y python3-pip python3-serial python3 python3-paho-mqtt python3-yaml
# Install the script, but don't clobber an existing one (it may have local edits
# prior to the env-file migration).
if [ ! -f "$TARGET_HOME/lutron-dmx-control.py" ]; then
cp lutron-dmx-control.py "$TARGET_HOME/lutron-dmx-control.py"
chown "$TARGET_USER:" "$TARGET_HOME/lutron-dmx-control.py"
else
echo "$TARGET_HOME/lutron-dmx-control.py already exists; not overwriting."
echo " Delete it and re-run to install the new version."
fi
# Install config file (only if not already present, to preserve secrets).
CONFIG_DIR=/etc/lutron-dmx-control
CONFIG_FILE="$CONFIG_DIR/config.yaml"
# Owned by the service user so the service (running as $TARGET_USER) can read it.
install -d -o "$TARGET_USER" -g "$TARGET_USER" -m 700 "$CONFIG_DIR"
# Track whether we just laid down a fresh (unedited) config so we can prompt the
# user to edit it before the first start instead of crash-looping on placeholders.
NEW_CONFIG=0
if [ ! -f "$CONFIG_FILE" ]; then
cp config.example.yaml "$CONFIG_FILE"
chown "$TARGET_USER:" "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
NEW_CONFIG=1
echo "Installed $CONFIG_FILE - edit before starting the service."
else
echo "$CONFIG_FILE already exists; leaving in place."
fi
# Copy systemd units.
cp olad@.service /etc/systemd/system/
cp lutron-dmx-control@.service /etc/systemd/system/
systemctl daemon-reload
# olad (OLA) is built from source by install-ola.sh and is only needed for DMX.
# Set it up when present; MQTT-only setups can run without it.
if command -v olad >/dev/null 2>&1; then
systemctl enable "olad@$TARGET_USER"
systemctl start "olad@$TARGET_USER"
# Default OLA to network DMX only (E1.31/sACN). Out of the box olad also loads
# its serial/USB device plugins, which grab the QSE's serial adapter
# (/dev/ttyUSB*) and conflict with this program. Wait for olad to generate its
# per-plugin configs, then disable every plugin and re-enable only e131.
OLA_DIR="$TARGET_HOME/.ola"
for _ in $(seq 1 15); do
[ -f "$OLA_DIR/ola-e131.conf" ] && break
sleep 1
done
if [ -f "$OLA_DIR/ola-e131.conf" ]; then
systemctl stop "olad@$TARGET_USER"
for f in "$OLA_DIR"/ola-*.conf; do
# ola-server.conf / ola-universe.conf are not plugin configs.
case "$(basename "$f")" in
ola-server.conf|ola-universe.conf) continue ;;
esac
if grep -q '^enabled' "$f"; then
sed -i '/^enabled[[:space:]]*=/c\enabled = false' "$f"
else
printf '\nenabled = false\n' >> "$f"
fi
done
sed -i '/^enabled[[:space:]]*=/c\enabled = true' "$OLA_DIR/ola-e131.conf"
chown -R "$TARGET_USER:" "$OLA_DIR"
systemctl start "olad@$TARGET_USER"
echo "Configured OLA for network DMX only (E1.31/sACN); serial/USB plugins disabled."
# Patch the E1.31 input port to the DMX universe from config.yaml so olad
# actually receives sACN. Registering the universe from the client is not
# enough -- without a patched input port olad never joins the sACN
# multicast group (sACN universe == OLA universe number). Skipped when DMX
# is disabled in the config.
UNIVERSE=$(python3 -c "import yaml; d=(yaml.safe_load(open('$CONFIG_FILE')) or {}).get('dmx',{}); print(d.get('universe','') if d.get('enabled', True) else '')" 2>/dev/null)
if [ -n "$UNIVERSE" ] && command -v ola_patch >/dev/null 2>&1; then
# Wait for olad's RPC + the E1.31 device, then resolve its device id.
DEV=""
for _ in $(seq 1 10); do
DEV=$(ola_dev_info 2>/dev/null | sed -n 's/^Device \([0-9]*\): E1\.31.*/\1/p' | head -1)
[ -n "$DEV" ] && break
sleep 1
done
if [ -n "$DEV" ]; then
ola_patch --device "$DEV" --port 0 --input --universe "$UNIVERSE" \
&& echo "Patched E1.31 input port 0 -> universe $UNIVERSE (sACN reception)."
else
echo "WARNING: E1.31 device not found; patch universe $UNIVERSE to an E1.31 input port manually (olad web UI :9090)."
fi
fi
else
echo "WARNING: $OLA_DIR/ola-e131.conf not generated; configure OLA plugins manually (see README)."
fi
else
echo "WARNING: 'olad' not found. If you use DMX, run ./install-ola.sh first, then re-run this script."
fi
# Always enable so the service starts on boot. Whether we start it now depends on
# whether the config is freshly installed (still has placeholder values).
systemctl enable "lutron-dmx-control@$TARGET_USER"
if [ "$NEW_CONFIG" -eq 1 ]; then
# Fresh config: serial.device, MQTT password, etc. are still placeholders, so
# starting now would just crash-loop. Walk the user through editing + starting.
SVC="lutron-dmx-control@$TARGET_USER"
echo
echo "============================================================"
echo " Almost done. The service is enabled but NOT started yet."
echo
echo " 1. Edit your config (at minimum: serial.device,"
echo " qse.integration_id/zones, and -- if used -- the dmx.* and"
echo " mqtt.broker/username/password settings):"
echo
echo " sudo nano $CONFIG_FILE"
echo
echo " 2. Start the service:"
echo
echo " sudo systemctl start $SVC"
echo
echo " 3. Check it came up cleanly:"
echo
echo " systemctl status $SVC"
echo " journalctl -u $SVC -f"
echo "============================================================"
else
# Existing config: (re)start to pick up the new script/units.
systemctl restart "lutron-dmx-control@$TARGET_USER"
echo "Restarted lutron-dmx-control@$TARGET_USER."
fi

View file

@ -22,780 +22,151 @@
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
import argparse
import json
import logging
import os
import random
import signal
import socket
import sys
from ola.ClientWrapper import ClientWrapper
import serial
import io
import _thread
import threading
import time
import serial
import yaml
# OLA (DMX) and paho-mqtt are imported lazily in main()/_new_mqtt_client() so
# the matching component can be disabled in config.yaml without the dependency
# being installed. Serial control of the QSE is always required.
# Documentation
# This program is designed to use the Open Lighting Architecture (OLA) to receive a DMX signal
# and translate to commands to control the 6 dimmable zones on the Lutron GRAFIK Eye QS Control panel
# This program is designed to use the Open Lighting Arcretechture (OLA) to receive a DMX signal
# and translate to commands to control the 6 dimiable zones on the Lutron GRAFIK Eye QS Control panel
# through the use of a QSE-CI-NWK-E. This program uses the serial port for reliability.
# === Configuration ===
# All runtime settings live in config.yaml (see config.example.yaml).
# These module-level names are populated by apply_config() at startup; the
# values below are only fallbacks used if a key is omitted from the file.
# Search order for the config file when --config is not given:
# 1. $LUTRON_CONFIG (explicit override)
# 2. config.yaml next to this script
# 3. ~/.config/lutron-dmx-control/config.yaml ($XDG_CONFIG_HOME honored)
# 4. /etc/lutron-dmx-control/config.yaml
_XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
DEFAULT_CONFIG_PATHS = (
os.environ.get("LUTRON_CONFIG"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml"),
os.path.join(_XDG_CONFIG_HOME, "lutron-dmx-control", "config.yaml"),
"/etc/lutron-dmx-control/config.yaml",
)
# Serial / DMX / QSE.
QSE_NWK_DEVICE = "/dev/serial/by-id/usb-Prolific_Technology_Inc._USB-Serial_Controller-if00-port0"
# Configuration
# Serial port device to use to communicate with Lutron's QSE NWK.
QSE_NWK_DEVICE = "/dev/ttyUSB0"
# Set baud rate on Lutron's QSE NWK.
QSE_NWK_BAUD = 115200
QSE_ZONES = 6 # hardware constant for GRAFIK Eye QS (model dependent, max 24)
QSE_INTEGRATION_ID = 1
QSE_FADE = "00:00" # fade sent with each zone-level command; OLA handles smoothing
DMX_ENABLED = True
# DMX Universe in OLA that is used.
DMX_UNIVERSE = 3
# The starting address.
DMX_START_ADDRESS = 0
# QSE serial protocol constants (not user-configurable).
QSE_ACTION_ZONE_LEVEL = 14
QSE_BTN_DISABLE = 74
QSE_BTN_ENABLE = 75
QSE_BTN_ACTION = 3
# Derived from QSE_INTEGRATION_ID; rebuilt in apply_config().
QSE_DEVICE_PREFIX = f"~DEVICE,{QSE_INTEGRATION_ID}"
QSE_DISABLE_SIGNAL = f"~DEVICE,{QSE_INTEGRATION_ID},{QSE_BTN_DISABLE},{QSE_BTN_ACTION}"
QSE_ENABLE_SIGNAL = f"~DEVICE,{QSE_INTEGRATION_ID},{QSE_BTN_ENABLE},{QSE_BTN_ACTION}"
#Verbosity
VERBOSE=1
# Reliability tuning.
QSE_RX_TIMEOUT_SEC = 60
WATCHDOG_INTERVAL_SEC = 15
RECONNECT_BACKOFF_MIN_SEC = 1
RECONNECT_BACKOFF_MAX_SEC = 30
WRITE_INTERVAL_SEC = 0.1 # write loop tick (not user-configurable)
SEND_ALL_INTERVAL_SEC = 10
# How long after the last DMX universe update MQTT control stays locked out.
# While a DMX signal is active, MQTT commands are ignored and the current zone
# levels are mirrored back to MQTT instead.
DMX_LOCKOUT_SEC = 5
# Logging.
LOG_LEVEL = "INFO"
# MQTT.
MQTT_ENABLED = True
MQTT_BROKER = "127.0.0.1"
MQTT_PORT = 1883
MQTT_TOPIC = "lutron/qse-nwk"
MQTT_TOPIC_SET = MQTT_TOPIC + "/set"
MQTT_CLIENT_ID = f"lutron-qse-nwk-{random.randint(0, 1000)}"
MQTT_USERNAME = "mqtt"
MQTT_PASSWORD = ""
MQTT_DISCOVERY = True
MQTT_DISCOVERY_PREFIX = "homeassistant"
MQTT_DEVICE_NAME = "Lutron QSE NWK"
# MQTT state values.
MQTT_LIGHT_ON = "ON"
MQTT_LIGHT_OFF = "OFF"
log = logging.getLogger("lutron-dmx-control")
def find_config_path(cli_path=None):
"""Return the first existing config path, or None."""
for path in (cli_path,) + DEFAULT_CONFIG_PATHS:
if path and os.path.isfile(path):
return path
return None
def load_config(cli_path=None):
"""Load and apply config.yaml. Exits with a clear message if not found."""
path = find_config_path(cli_path)
if path is None:
searched = ", ".join(p for p in (cli_path,) + DEFAULT_CONFIG_PATHS if p)
raise SystemExit(
"No config file found (looked in: %s).\n"
"Copy config.example.yaml to /etc/lutron-dmx-control/config.yaml "
"(or pass --config PATH) and edit it." % searched
)
with open(path, "r") as fh:
cfg = yaml.safe_load(fh) or {}
apply_config(cfg)
return path
def apply_config(cfg):
"""Populate module-level settings from a parsed YAML mapping."""
global QSE_NWK_DEVICE, QSE_NWK_BAUD, QSE_ZONES, QSE_INTEGRATION_ID, QSE_FADE
global DMX_ENABLED, DMX_UNIVERSE, DMX_START_ADDRESS
global QSE_DEVICE_PREFIX, QSE_DISABLE_SIGNAL, QSE_ENABLE_SIGNAL
global QSE_RX_TIMEOUT_SEC, WATCHDOG_INTERVAL_SEC
global RECONNECT_BACKOFF_MIN_SEC, RECONNECT_BACKOFF_MAX_SEC, SEND_ALL_INTERVAL_SEC
global DMX_LOCKOUT_SEC
global LOG_LEVEL
global MQTT_ENABLED, MQTT_BROKER, MQTT_PORT, MQTT_TOPIC, MQTT_TOPIC_SET
global MQTT_CLIENT_ID, MQTT_USERNAME, MQTT_PASSWORD
global MQTT_DISCOVERY, MQTT_DISCOVERY_PREFIX, MQTT_DEVICE_NAME
global zoneValues, sentValues
def section(name):
s = cfg.get(name)
return s if isinstance(s, dict) else {}
serial_cfg = section("serial")
QSE_NWK_DEVICE = serial_cfg.get("device", QSE_NWK_DEVICE)
QSE_NWK_BAUD = int(serial_cfg.get("baud", QSE_NWK_BAUD))
qse_cfg = section("qse")
QSE_INTEGRATION_ID = int(qse_cfg.get("integration_id", QSE_INTEGRATION_ID))
QSE_ZONES = int(qse_cfg.get("zones", QSE_ZONES))
QSE_FADE = str(qse_cfg.get("fade", QSE_FADE))
dmx_cfg = section("dmx")
DMX_ENABLED = bool(dmx_cfg.get("enabled", DMX_ENABLED))
DMX_UNIVERSE = int(dmx_cfg.get("universe", DMX_UNIVERSE))
DMX_START_ADDRESS = int(dmx_cfg.get("start_address", DMX_START_ADDRESS))
DMX_LOCKOUT_SEC = float(dmx_cfg.get("lockout_sec", DMX_LOCKOUT_SEC))
rel = section("reliability")
QSE_RX_TIMEOUT_SEC = int(rel.get("rx_timeout_sec", QSE_RX_TIMEOUT_SEC))
WATCHDOG_INTERVAL_SEC = int(rel.get("watchdog_interval_sec", WATCHDOG_INTERVAL_SEC))
RECONNECT_BACKOFF_MIN_SEC = int(
rel.get("reconnect_backoff_min_sec", RECONNECT_BACKOFF_MIN_SEC))
RECONNECT_BACKOFF_MAX_SEC = int(
rel.get("reconnect_backoff_max_sec", RECONNECT_BACKOFF_MAX_SEC))
SEND_ALL_INTERVAL_SEC = int(rel.get("send_all_interval_sec", SEND_ALL_INTERVAL_SEC))
LOG_LEVEL = str(section("logging").get("level", LOG_LEVEL)).upper()
mqtt = section("mqtt")
MQTT_ENABLED = bool(mqtt.get("enabled", MQTT_ENABLED))
MQTT_BROKER = mqtt.get("broker", MQTT_BROKER)
MQTT_PORT = int(mqtt.get("port", MQTT_PORT))
MQTT_TOPIC = mqtt.get("topic", MQTT_TOPIC)
MQTT_TOPIC_SET = MQTT_TOPIC + "/set"
MQTT_USERNAME = mqtt.get("username", MQTT_USERNAME)
MQTT_PASSWORD = mqtt.get("password", MQTT_PASSWORD)
client_id = mqtt.get("client_id")
if client_id:
MQTT_CLIENT_ID = client_id
MQTT_DISCOVERY = bool(mqtt.get("discovery", MQTT_DISCOVERY))
MQTT_DISCOVERY_PREFIX = mqtt.get("discovery_prefix", MQTT_DISCOVERY_PREFIX)
MQTT_DEVICE_NAME = mqtt.get("device_name", MQTT_DEVICE_NAME)
# Rebuild integration-ID-derived constants.
QSE_DEVICE_PREFIX = f"~DEVICE,{QSE_INTEGRATION_ID}"
QSE_DISABLE_SIGNAL = f"~DEVICE,{QSE_INTEGRATION_ID},{QSE_BTN_DISABLE},{QSE_BTN_ACTION}"
QSE_ENABLE_SIGNAL = f"~DEVICE,{QSE_INTEGRATION_ID},{QSE_BTN_ENABLE},{QSE_BTN_ACTION}"
# (Re)size the zone state to match the configured zone count.
zoneValues = [0] * QSE_ZONES
sentValues = [0] * QSE_ZONES
def configure_logging():
logging.basicConfig(
level=LOG_LEVEL,
format="%(asctime)s %(levelname)s %(threadName)s: %(message)s",
stream=sys.stdout,
)
# === Shared state ===
# Owned by the serial supervisor; readers may inspect .is_open without the lock,
# but every write/close/replacement must hold serialLock.
# Variables used at run time, do not adjust.
serialSession = None
serialLock = threading.RLock()
# Protects shared zone/MQTT state.
dataLock = threading.RLock()
zoneValues = [0] * QSE_ZONES
sentValues = [0] * QSE_ZONES
currentValues = [0,0,0,0,0,0]
sendAllDataThisTime = True
controlDisabled = False
lastDMXUniverseUpdate = 0
# Watchdog state.
lastQSEResponseTime = time.time()
lastQSEWriteTime = 0.0
# Rate-limit the #RESET,0 recovery: a wedged NWK floods ~Error,6, and we must not
# answer every one with a reset.
lastQSEResetTime = 0.0
QSE_RESET_COOLDOWN_SEC = 10
reconnectRequested = threading.Event()
running = threading.Event()
running.set()
# To prevent data from overlapping which is known to crash the QSE NWK, we implement a thread lock which must be released before being obtained.
dataLock = threading.Lock()
# MQTT state.
mqttLightState = MQTT_LIGHT_OFF
mqttLightBrightness = 0
mqttSentLightState = ""
mqttSentLightBrightness = 0
mqtt_conn = None
# OLA wrapper, set in main() so the signal handler can stop it.
ola_wrapper = None
# === sd_notify (no external dependency) ===
def sd_notify(message):
"""Send a notification to systemd via $NOTIFY_SOCKET, if set."""
addr = os.environ.get('NOTIFY_SOCKET')
if not addr:
# This fucnction translates the 0-255 signal from DMX to 0.00 to 100.00 signal used by Lutron,
# and it sends the appropiate command to the QSE NWK to change the brightness level of a zone.
def SetZone(zone, value):
global serialSession, currentValues, sendAllDataThisTime, controlDisabled
# We only want to translate a level of it has not already been sent to the zone,
# or if we want to send all data this time. However we do not want to send the level
# if the controls has been disabled by the designated button on the control panel.
if (currentValues[zone-1]==value and not sendAllDataThisTime) or controlDisabled:
return
if addr[0] == '@':
addr = '\0' + addr[1:]
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock:
sock.connect(addr)
sock.sendall(message.encode("utf-8"))
except OSError as e:
log.warning("sd_notify(%r) failed: %s", message, e)
# Update the array of current values.
currentValues[zone-1] = value
# === Serial supervision ===
def _close_serial_locked(ser):
if ser is None:
return
try:
ser.close()
except Exception as e:
log.debug("Ignoring error while closing serial: %s", e)
# Translate to the command.
command = "#DEVICE,1,%d,14,%.2f,00:00" % (zone,round((value/255.00)*100,2))
if VERBOSE>=1:
print(command)
# Send to the QSE NWK.
serialSession.write(bytes(command+"\n\r", 'utf-8'))
def ensure_serial_connected():
"""Block until serialSession is a usable, open port. Exponential backoff."""
global serialSession, sendAllDataThisTime, lastQSEResponseTime
backoff = RECONNECT_BACKOFF_MIN_SEC
while running.is_set():
with serialLock:
if serialSession is not None and serialSession.is_open:
reconnectRequested.clear()
return
_close_serial_locked(serialSession)
serialSession = None
try:
log.info("Opening serial port %s @ %d", QSE_NWK_DEVICE, QSE_NWK_BAUD)
# exclusive=True prevents another process (e.g. a stray OLA plugin)
# from grabbing the same /dev/ttyUSBx.
serialSession = serial.Serial(
QSE_NWK_DEVICE, QSE_NWK_BAUD, timeout=2, exclusive=True
)
with dataLock:
sendAllDataThisTime = True
lastQSEResponseTime = time.time()
reconnectRequested.clear()
log.info("Serial connection established.")
return
except (serial.SerialException, OSError) as e:
log.error("Serial open failed: %s; retry in %ds", e, backoff)
serialSession = None
# Sleep outside the lock so other threads can observe the closed state.
time.sleep(backoff)
backoff = min(backoff * 2, RECONNECT_BACKOFF_MAX_SEC)
def NewData(data):
global sendAllDataThisTime, dataLock
# Acquire the lock for the thread to prevent data from overlapping.
dataLock.acquire()
if VERBOSE>=2:
print(data)
# Send the new levels to each zone via the QSE NWK.
SetZone(1,data[DMX_START_ADDRESS+0])
SetZone(2,data[DMX_START_ADDRESS+1])
SetZone(3,data[DMX_START_ADDRESS+2])
SetZone(4,data[DMX_START_ADDRESS+3])
SetZone(5,data[DMX_START_ADDRESS+4])
SetZone(6,data[DMX_START_ADDRESS+5])
def request_reconnect(reason):
"""Close the current port and wake the supervisor to reopen it."""
log.warning("Reconnect requested: %s", reason)
with serialLock:
_close_serial_locked(serialSession)
reconnectRequested.set()
# Reset the flag of send all data to false as we would have sent all data this time.
sendAllDataThisTime = False
# Allow the next command call to follow through by releasing the lock.
dataLock.release()
def serial_write(payload):
"""Write bytes to the serial port under lock. Returns True on success."""
global lastQSEWriteTime
with serialLock:
ser = serialSession
if ser is None or not ser.is_open:
return False
try:
ser.write(payload)
lastQSEWriteTime = time.time()
return True
except (serial.SerialException, OSError) as e:
log.error("Serial write failed: %s", e)
request_reconnect("write error")
return False
# This function reads the serial data from the QSE NWK line by line and performs a few functions based on response.
def QSE_Read():
global serialSession, controlDisabled
# Creates a bufferred reader for the serial input.
sio = io.TextIOWrapper(io.BufferedReader(serialSession))
def serial_supervisor():
"""Single owner of the reconnect path. Other threads request and wait."""
while running.is_set():
ensure_serial_connected()
# Sync our view of zone levels with the panel after every (re)connect.
qse_query_zone_levels()
# Block until something requests a reconnect.
reconnectRequested.wait()
# === QSE protocol ===
# Lutron integration commands are terminated with <CR><LF> per the protocol doc.
QSE_TERMINATOR = "\r\n"
def qse_send_zone_value(zone, value):
pct = round((value / 255.00) * 100, 2)
command = "#DEVICE,%d,%d,%d,%.2f,%s" % (
QSE_INTEGRATION_ID, zone, QSE_ACTION_ZONE_LEVEL, pct, QSE_FADE
)
log.debug("TX %s", command)
return serial_write((command + QSE_TERMINATOR).encode("utf-8"))
def qse_query_zone_levels():
"""Ask the QSE for each zone's current level so our view matches reality
after a (re)connect. Responses are handled in qse_read()."""
for zone in range(1, QSE_ZONES + 1):
query = "?DEVICE,%d,%d,%d" % (
QSE_INTEGRATION_ID, zone, QSE_ACTION_ZONE_LEVEL
)
log.debug("TX %s", query)
serial_write((query + QSE_TERMINATOR).encode("utf-8"))
# ~ERROR,<n> codes from the Lutron integration protocol (doc 040249).
QSE_ERROR_DESCRIPTIONS = {
"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",
}
def _handle_qse_error(line):
"""Log a ~Error response and, for the known bad-state error, reset the NWK."""
global lastQSEResetTime
code = line.split(",", 1)[1].strip() if "," in line else ""
desc = QSE_ERROR_DESCRIPTIONS.get(code, "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":
now = time.time()
# A wedged NWK errors every command; reset at most once per cooldown.
if now - lastQSEResetTime >= QSE_RESET_COOLDOWN_SEC:
lastQSEResetTime = now
log.warning("QSE NWK returned %s (%s); sending #RESET,0", line, desc)
serial_write(("#RESET,0" + QSE_TERMINATOR).encode("utf-8"))
else:
log.warning("QSE NWK returned %s (%s)", line, desc)
def dmx_universe_update(data):
global lastDMXUniverseUpdate
with dataLock:
for zone in range(QSE_ZONES):
zoneValues[zone] = data[DMX_START_ADDRESS + zone]
lastDMXUniverseUpdate = time.time()
def qse_write_zone_values():
"""Periodically push any changed zone values out the serial port."""
global sendAllDataThisTime
while running.is_set():
try:
if controlDisabled:
time.sleep(0.5)
continue
ser = serialSession
if ser is None or not ser.is_open:
time.sleep(0.2)
continue
with dataLock:
thisZoneValues = list(zoneValues)
resendAll = sendAllDataThisTime
sendAllDataThisTime = False
for zone in range(QSE_ZONES):
if thisZoneValues[zone] == sentValues[zone] and not resendAll:
continue
if not qse_send_zone_value(zone + 1, thisZoneValues[zone]):
# Write failed: mark for retry next cycle and let the
# supervisor reopen the port.
with dataLock:
sendAllDataThisTime = True
break
sentValues[zone] = thisZoneValues[zone]
time.sleep(WRITE_INTERVAL_SEC)
except Exception:
log.exception("qse_write_zone_values loop error")
time.sleep(1)
def qse_read():
"""Read responses from the QSE NWK and dispatch state updates."""
global controlDisabled, sendAllDataThisTime, mqttLightBrightness, mqttLightState
global lastQSEResponseTime
while running.is_set():
try:
ser = serialSession
if ser is None or not ser.is_open:
time.sleep(0.2)
continue
try:
# Use pyserial's native line reader, not ser.readline(): pyserial
# 3.x has no readline() of its own, so the inherited io.IOBase one
# calls read() with a non-int size and raises TypeError. read_until
# reads up to the LF (or the 2s port timeout) via read(1).
raw = ser.read_until(b"\n")
except (serial.SerialException, OSError) as e:
log.error("Serial read failed: %s", e)
request_reconnect("read error")
time.sleep(0.5)
continue
except TypeError:
# The supervisor closed the port mid-read. pyserial's close() nulls
# the fd before clearing is_open, so a read can slip past the
# is_open check above and hit os.read(None, ...). Harmless: the
# supervisor is already reconnecting, so just re-check and retry.
time.sleep(0.1)
continue
if not raw:
continue
line = raw.decode("utf-8", errors="replace").replace("QSE>", "").rstrip()
if not line:
continue
lastQSEResponseTime = time.time()
log.debug("RX %s", line)
# The NWK sends "~Error,<n>" (mixed case), so match case-insensitively.
if line.upper().startswith("~ERROR"):
_handle_qse_error(line)
elif line == QSE_DISABLE_SIGNAL:
log.info("Received disable signal.")
with dataLock:
controlDisabled = True
elif line == QSE_ENABLE_SIGNAL:
log.info("Received enable signal.")
with dataLock:
controlDisabled = False
sendAllDataThisTime = True
elif line.startswith(QSE_DEVICE_PREFIX):
# Zone-level feedback: ~DEVICE,<intid>,<zone>,14,<level%>.
fields = line.split(",")
if (len(fields) >= 5
and fields[1] == str(QSE_INTEGRATION_ID)
and fields[3] == str(QSE_ACTION_ZONE_LEVEL)):
try:
zone = int(fields[2])
brightness = int(round((float(fields[4]) / 100.0) * 255))
except ValueError:
continue
# The MQTT light tracks zone 1 as the aggregate state.
if zone == 1:
with dataLock:
mqttLightBrightness = brightness
if mqttLightBrightness == 0:
if controlDisabled:
controlDisabled = False
mqttLightState = MQTT_LIGHT_OFF
else:
mqttLightState = MQTT_LIGHT_ON
mqtt_publish_state()
except Exception:
log.exception("qse_read loop error")
time.sleep(1)
def qse_reset_send_all():
"""Force a periodic full resend so the QSE NWK can't drift out of sync."""
global sendAllDataThisTime
while running.is_set():
time.sleep(SEND_ALL_INTERVAL_SEC)
# Skip resends when the link looks unhealthy. The watchdog will trigger
# a reconnect, and ensure_serial_connected() already sets
# sendAllDataThisTime on success — no need to spam the bus in the meantime.
if (time.time() - lastQSEResponseTime) > QSE_RX_TIMEOUT_SEC:
# We want to run this forever as we are in a thread.
while True:
# Gets the next available line from the QSE NWK and filter out the QSE prompt and any new line characters.
line = sio.readline().replace("QSE>","").rstrip()
if line=="":
continue
with dataLock:
# If the command not found error is returned, this is either due to
# the Lutron GRAFIK Eye QS Control panel not being assigned an integration ID of 1,
# or due to a bug which needs the QSE NWK rebooted to fix. We attempt to reboot
# to attempt to automatically fix the bug.
if line=="~ERROR,6":
if VERBOSE>=2:
print("Error occurred, rebooting QSE NWK.")
# Send the reboot command.
serialSession.write(bytes("#RESET,0\n\r", 'utf-8'))
# If the all zone up button is pressed, we disable control from the program to allow someone to manually control zones.
if line=="~DEVICE,1,74,3":
if VERBOSE>=2:
print("Received disable signal.")
controlDisabled = True
# If the all zone down button is pressed, we re-enable the programs control of the zones.
if line=="~DEVICE,1,75,3":
if VERBOSE>=2:
print("Received enable signal.")
controlDisabled = False
sendAllDataThisTime = True
if VERBOSE>=1:
print(line)
def qse_watchdog():
"""Detect a stale serial link and feed the systemd watchdog.
# Reset the send all data flag every 10 seconds to ensure all zones have the correct value set.
def sendAllDataReset():
global sendAllDataThisTime
while True:
time.sleep(10)
if VERBOSE>=3:
print("Resetting flag to send all data")
sendAllDataThisTime = True
The QSE only replies to a #DEVICE write when it actually changes a level, so a
quiet RX while we write (e.g. periodic resends of unchanged values) does NOT
mean the link is dead. Before reconnecting we actively probe with a ?DEVICE
query, which always gets a ~DEVICE reply on a healthy link; only if that probe
also goes unanswered do we treat the link as stale and reconnect."""
probed = False
while running.is_set():
now = time.time()
# Only meaningful if we've been writing recently.
wrote_recently = (now - lastQSEWriteTime) < QSE_RX_TIMEOUT_SEC
rx_stale = (now - lastQSEResponseTime) > QSE_RX_TIMEOUT_SEC
if wrote_recently and rx_stale and not reconnectRequested.is_set():
if not probed:
# Actively probe; the reply (if any) lands in qse_read and
# refreshes lastQSEResponseTime before the next pass.
log.info("QSE RX stale for %.0fs; probing with a zone query",
now - lastQSEResponseTime)
qse_query_zone_levels()
probed = True
else:
request_reconnect(
"no QSE RX for %.0fs (query probe unanswered)"
% (now - lastQSEResponseTime)
)
probed = False
else:
probed = False
# Connect to the QSE NWK by using the serial port.
print("Connecting to QSE NWK at: "+QSE_NWK_DEVICE)
with serial.Serial(QSE_NWK_DEVICE, QSE_NWK_BAUD, timeout=2) as ser:
serialSession = ser
sd_notify("WATCHDOG=1")
time.sleep(WATCHDOG_INTERVAL_SEC)
# If the serial session is still set to None, we did not correctly connect.
if serialSession == None:
print("Failed to connect.")
exit(1)
# We connected, so we can open the device.
serialSession.open()
# === MQTT ===
def mqtt_publish_state():
global mqttSentLightState, mqttSentLightBrightness
if mqtt_conn is None:
return
if (mqttLightState == mqttSentLightState
and mqttLightBrightness == mqttSentLightBrightness):
return
mqttSentLightState = mqttLightState
mqttSentLightBrightness = mqttLightBrightness
msg = json.dumps({"brightness": mqttLightBrightness, "state": mqttLightState})
try:
result = mqtt_conn.publish(MQTT_TOPIC, msg)
if result[0] != 0:
log.warning("Failed to publish to MQTT topic %s", MQTT_TOPIC)
else:
log.debug("Published %s to %s", msg, MQTT_TOPIC)
except Exception:
log.exception("MQTT publish error")
# Now that we are ready to roll, we start the read thread.
_thread.start_new_thread(QSE_Read, ())
# Start the reset send all data thread.
_thread.start_new_thread(sendAllDataReset, ())
def mqtt_on_message(client, userdata, msg):
global mqttLightState, mqttLightBrightness, mqttSentLightState, mqttSentLightBrightness
if msg.topic != MQTT_TOPIC_SET:
log.warning("Unknown MQTT topic %s payload %r", msg.topic, msg.payload)
return
try:
data = json.loads(msg.payload.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as e:
log.warning("Bad MQTT payload on %s: %s", msg.topic, e)
return
log.debug("MQTT RX %s", data)
with dataLock:
if "brightness" in data:
mqttLightBrightness = data["brightness"]
if "state" in data:
if mqttLightState != data["state"]:
mqttLightState = data["state"]
# Turning on with brightness 0 -> default to ~50%.
if mqttLightState == MQTT_LIGHT_ON and mqttLightBrightness == 0:
mqttLightBrightness = 127
durationSinceLastDMXUniverseUpdate = time.time() - lastDMXUniverseUpdate
if durationSinceLastDMXUniverseUpdate > DMX_LOCKOUT_SEC:
target = mqttLightBrightness if mqttLightState == MQTT_LIGHT_ON else 0
for zone in range(QSE_ZONES):
zoneValues[zone] = target
else:
# DMX is in control; mirror current zone 1 back out to MQTT and
# force the next publish to actually go out.
mqttLightBrightness = zoneValues[0]
mqttLightState = MQTT_LIGHT_OFF if mqttLightBrightness == 0 else MQTT_LIGHT_ON
mqttSentLightState = ""
mqttSentLightBrightness = 0
mqtt_publish_state()
def mqtt_slug():
"""Stable identifier derived from the base topic, for HA unique IDs."""
return "".join(c if c.isalnum() else "_" for c in MQTT_TOPIC).strip("_")
def mqtt_publish_discovery(client):
"""Publish a Home Assistant MQTT discovery config so the light appears
automatically. Retained so HA picks it up whenever it (re)connects."""
slug = mqtt_slug()
topic = "%s/light/%s/config" % (MQTT_DISCOVERY_PREFIX, slug)
payload = json.dumps({
"schema": "json",
"name": MQTT_DEVICE_NAME,
"unique_id": slug,
"state_topic": MQTT_TOPIC,
"command_topic": MQTT_TOPIC_SET,
"brightness": True,
"supported_color_modes": ["brightness"],
"device": {
"identifiers": [slug],
"name": MQTT_DEVICE_NAME,
"manufacturer": "Lutron",
"model": "GRAFIK Eye QS (QSE-CI-NWK-E)",
},
})
try:
client.publish(topic, payload, retain=True)
log.info("Published Home Assistant discovery to %s", topic)
except Exception:
log.exception("MQTT discovery publish error")
def mqtt_on_connect(client, userdata, flags, reason_code, properties=None):
# paho-mqtt 1.x passes an int rc; 2.x (v2 callbacks) passes a ReasonCode and
# an extra properties arg. Normalize both to a success/failure check.
failed = (reason_code.is_failure if hasattr(reason_code, "is_failure")
else reason_code != 0)
if not failed:
log.info("Connected to MQTT broker.")
client.subscribe(MQTT_TOPIC_SET)
if MQTT_DISCOVERY:
mqtt_publish_discovery(client)
mqtt_publish_state()
else:
log.error("MQTT connect failed, rc=%s", reason_code)
def _new_mqtt_client():
"""Construct an MQTT client compatible with paho-mqtt 1.x and 2.x.
paho-mqtt 2.0 made the callback API version a required argument. We use the
v2 callbacks (avoiding the deprecation warning); mqtt_on_connect accepts both
signatures and mqtt_on_message is unchanged between versions. On paho-mqtt 1.x
(no CallbackAPIVersion) we fall back to the legacy constructor."""
from paho.mqtt import client as mqtt_client
try:
from paho.mqtt.enums import CallbackAPIVersion
return mqtt_client.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
client_id=MQTT_CLIENT_ID,
)
except ImportError:
return mqtt_client.Client(MQTT_CLIENT_ID)
def mqtt_loop():
global mqtt_conn
backoff = 1
while running.is_set():
try:
mqtt_conn = _new_mqtt_client()
if MQTT_USERNAME:
mqtt_conn.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
mqtt_conn.on_connect = mqtt_on_connect
mqtt_conn.on_message = mqtt_on_message
mqtt_conn.connect(MQTT_BROKER, MQTT_PORT)
backoff = 1
mqtt_conn.loop_forever()
except Exception:
log.exception("MQTT loop error; reconnecting in %ds", backoff)
finally:
mqtt_conn = None
time.sleep(backoff)
backoff = min(backoff * 2, 60)
# === Startup ===
def start_thread(target, name):
t = threading.Thread(target=target, name=name, daemon=True)
t.start()
return t
def _handle_shutdown(signum, frame):
log.info("Received signal %d, shutting down", signum)
sd_notify("STOPPING=1")
running.clear()
reconnectRequested.set()
if ola_wrapper is not None:
try:
ola_wrapper.Stop()
except Exception:
log.exception("Error stopping OLA wrapper")
def parse_args(argv=None):
parser = argparse.ArgumentParser(description="Lutron GRAFIK Eye QS DMX bridge")
parser.add_argument(
"-c", "--config",
help="Path to config.yaml (default: $LUTRON_CONFIG, "
"/etc/lutron-dmx-control/config.yaml, or ./config.yaml)",
)
return parser.parse_args(argv)
def main():
global ola_wrapper
args = parse_args()
config_path = load_config(args.config)
configure_logging()
log.info("Lutron DMX Control starting (PID %d)", os.getpid())
log.info("Loaded configuration from %s", config_path)
signal.signal(signal.SIGTERM, _handle_shutdown)
signal.signal(signal.SIGINT, _handle_shutdown)
# Block until the first connect succeeds (or we're shutting down).
ensure_serial_connected()
if not DMX_ENABLED and not MQTT_ENABLED:
log.warning(
"Both DMX and MQTT are disabled; nothing will drive the zones.")
log.info("Components: DMX=%s, MQTT=%s",
"on" if DMX_ENABLED else "off", "on" if MQTT_ENABLED else "off")
start_thread(serial_supervisor, "serial-sup")
start_thread(qse_read, "qse-read")
start_thread(qse_write_zone_values, "qse-write")
start_thread(qse_reset_send_all, "qse-resend")
start_thread(qse_watchdog, "qse-watchdog")
if MQTT_ENABLED:
start_thread(mqtt_loop, "mqtt")
sd_notify("READY=1")
sd_notify("STATUS=Running")
try:
if DMX_ENABLED:
# OLA owns the main thread; its callback feeds zone values.
from ola.ClientWrapper import ClientWrapper
ola_wrapper = ClientWrapper()
client = ola_wrapper.Client()
client.RegisterUniverse(
DMX_UNIVERSE, client.REGISTER, dmx_universe_update)
ola_wrapper.Run()
else:
# No DMX: keep the main thread alive until a signal clears running.
while running.is_set():
time.sleep(1)
finally:
running.clear()
reconnectRequested.set()
sd_notify("STOPPING=1")
if __name__ == "__main__":
main()
# Connect to the DMX universe with the OLA wrapper.
wrapper = ClientWrapper()
client = wrapper.Client()
client.RegisterUniverse(DMX_UNIVERSE, client.REGISTER, NewData)
wrapper.Run()

View file

@ -1,18 +1,12 @@
[Unit]
Description=Lutron DMX Control
After=network-online.target olad@%i.service
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=20
After=network.target
[Service]
Type=notify
NotifyAccess=main
Environment=PYTHONUNBUFFERED=1
Type=simple
ExecStart=/usr/bin/python3 /home/%I/lutron-dmx-control.py
Restart=always
RestartSec=5
WatchdogSec=120
Restart=on-failure
RestartSec=10
User=%I
[Install]