Compare commits
2 commits
f666e423c2
...
21fe3a1476
| Author | SHA1 | Date | |
|---|---|---|---|
| 21fe3a1476 | |||
| 979c865262 |
7 changed files with 1376 additions and 140 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Local config holds secrets (MQTT password); never commit it.
|
||||
config.yaml
|
||||
260
README.md
260
README.md
|
|
@ -1,48 +1,262 @@
|
|||
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.
|
||||
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.
|
||||
|
||||
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/
|
||||
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/).
|
||||
|
||||
# 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.
|
||||
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.
|
||||
|
||||
Trick to disable all modules except the one you are using.
|
||||
# What you'll need
|
||||
|
||||
```bash
|
||||
sed -i '/enabled\s=/c\enabled = false' ~/.ola/*.conf
|
||||
sed -i '/enabled\s=/c\enabled = true' ~/.ola/ola-e131.conf
|
||||
```
|
||||
- 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 (~1–2 h on a Pi Zero).
|
||||
3. **Install the control service** (step 2) and **configure it** (step 3).
|
||||
|
||||
# Installation
|
||||
|
||||
Install Python/needed modules.
|
||||
## 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:
|
||||
|
||||
```bash
|
||||
apt install python3-pip python3-serial
|
||||
pip3 install ola
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y git
|
||||
git clone https://github.com/GRMrGecko/lutron-dmx-control.git
|
||||
cd lutron-dmx-control
|
||||
```
|
||||
|
||||
Copy lutron-dmx-control@.service and olad@.service to /etc/systemd/system/ and run the following to enable/start.
|
||||
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 1–2 hours; the
|
||||
script adds temporary swap on low-memory boards so the compile does not run out of
|
||||
memory.
|
||||
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl enable olad@pi
|
||||
systemctl start olad@pi
|
||||
systemctl enable lutron-dmx-control@pi
|
||||
systemctl start lutron-dmx-control@pi
|
||||
bash ./install-ola.sh
|
||||
```
|
||||
|
||||
# Recommend
|
||||
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.
|
||||
|
||||
Enable watchdog on the Raspberry Pi to auto reboot upon system crashes.
|
||||
## 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:
|
||||
|
||||
Edit `/boot/config.txt` and add under the `[all]` section.
|
||||
```
|
||||
watchdog=on
|
||||
```
|
||||
|
||||
Edit `/etc/systemd/system.conf` and uncomment `RuntimeWatchdogSec` and set it as follows.
|
||||
Uncomment `RuntimeWatchdogSec` in `/etc/systemd/system.conf` and set it:
|
||||
|
||||
```
|
||||
RuntimeWatchdogSec=10s
|
||||
```
|
||||
|
||||
After configuring, reboot.
|
||||
Reboot to apply.
|
||||
|
|
|
|||
67
config.example.yaml
Normal file
67
config.example.yaml
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# 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
|
||||
149
install-ola.sh
Normal file
149
install-ola.sh
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
#!/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."
|
||||
166
install.sh
Normal file
166
install.sh
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
#!/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
|
||||
|
|
@ -22,151 +22,780 @@
|
|||
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
#
|
||||
|
||||
from ola.ClientWrapper import ClientWrapper
|
||||
import serial
|
||||
import io
|
||||
import _thread
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
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 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
|
||||
# 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
|
||||
# through the use of a QSE-CI-NWK-E. This program uses the serial port for reliability.
|
||||
|
||||
# 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.
|
||||
|
||||
# === 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"
|
||||
QSE_NWK_BAUD = 115200
|
||||
# DMX Universe in OLA that is used.
|
||||
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 = 3
|
||||
# The starting address.
|
||||
DMX_START_ADDRESS = 0
|
||||
|
||||
#Verbosity
|
||||
VERBOSE=1
|
||||
# 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}"
|
||||
|
||||
# Variables used at run time, do not adjust.
|
||||
# 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.
|
||||
serialSession = None
|
||||
currentValues = [0,0,0,0,0,0]
|
||||
serialLock = threading.RLock()
|
||||
# Protects shared zone/MQTT state.
|
||||
dataLock = threading.RLock()
|
||||
|
||||
zoneValues = [0] * QSE_ZONES
|
||||
sentValues = [0] * QSE_ZONES
|
||||
sendAllDataThisTime = True
|
||||
controlDisabled = False
|
||||
lastDMXUniverseUpdate = 0
|
||||
|
||||
# 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()
|
||||
# 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()
|
||||
|
||||
# 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:
|
||||
# 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:
|
||||
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
|
||||
|
||||
# Translate to the command.
|
||||
command = "#DEVICE,1,%d,14,%.2f,00:00" % (zone,round((value/255.00)*100,2))
|
||||
if VERBOSE>=1:
|
||||
print(command)
|
||||
# === 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)
|
||||
|
||||
# Send to the QSE NWK.
|
||||
serialSession.write(bytes(command+"\n\r", 'utf-8'))
|
||||
|
||||
def NewData(data):
|
||||
global sendAllDataThisTime, dataLock
|
||||
# Acquire the lock for the thread to prevent data from overlapping.
|
||||
dataLock.acquire()
|
||||
if VERBOSE>=2:
|
||||
print(data)
|
||||
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)
|
||||
|
||||
# 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])
|
||||
|
||||
# Reset the flag of send all data to false as we would have sent all data this time.
|
||||
sendAllDataThisTime = False
|
||||
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()
|
||||
|
||||
# Allow the next command call to follow through by releasing the lock.
|
||||
dataLock.release()
|
||||
|
||||
# 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_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
|
||||
|
||||
# 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=="":
|
||||
|
||||
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:
|
||||
continue
|
||||
|
||||
# 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
|
||||
with dataLock:
|
||||
sendAllDataThisTime = True
|
||||
|
||||
if VERBOSE>=1:
|
||||
print(line)
|
||||
|
||||
# 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
|
||||
def qse_watchdog():
|
||||
"""Detect a stale serial link and feed the systemd watchdog.
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
# If the serial session is still set to None, we did not correctly connect.
|
||||
if serialSession == None:
|
||||
print("Failed to connect.")
|
||||
exit(1)
|
||||
sd_notify("WATCHDOG=1")
|
||||
time.sleep(WATCHDOG_INTERVAL_SEC)
|
||||
|
||||
# We connected, so we can open the device.
|
||||
serialSession.open()
|
||||
|
||||
# Now that we are ready to roll, we start the read thread.
|
||||
_thread.start_new_thread(QSE_Read, ())
|
||||
# === 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")
|
||||
|
||||
# Start the reset send all data thread.
|
||||
_thread.start_new_thread(sendAllDataReset, ())
|
||||
|
||||
# Connect to the DMX universe with the OLA wrapper.
|
||||
wrapper = ClientWrapper()
|
||||
client = wrapper.Client()
|
||||
client.RegisterUniverse(DMX_UNIVERSE, client.REGISTER, NewData)
|
||||
wrapper.Run()
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
[Unit]
|
||||
Description=Lutron DMX Control
|
||||
After=network.target
|
||||
After=network-online.target olad@%i.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=20
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Type=notify
|
||||
NotifyAccess=main
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/usr/bin/python3 /home/%I/lutron-dmx-control.py
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
WatchdogSec=120
|
||||
User=%I
|
||||
|
||||
[Install]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue