diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..728d7cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc + +# Local config holds secrets (MQTT password); never commit it. +config.yaml diff --git a/Home Assistant/docker-compose.yml b/Home Assistant/docker-compose.yml deleted file mode 100644 index 093d9df..0000000 --- a/Home Assistant/docker-compose.yml +++ /dev/null @@ -1,32 +0,0 @@ -version: '3' -services: - homeassistant: - container_name: home-assistant - image: homeassistant/home-assistant:stable - volumes: - - ./hass:/config - environment: - - TZ=America/Chicago - restart: always - devices: - - /dev/ttyUSB1:/dev/ttyUSB1 - - /dev/serial/by-id/usb-Silicon_Labs_HubZ_Smart_Home_Controller_415007C7-if01-port0 - network_mode: host - mqtt: - image: eclipse-mosquitto - volumes: - - ./mosquitto:/mosquitto/config - restart: always - network_mode: host - zwave-js: - container_name: zwavejs2mqtt - image: zwavejs/zwavejs2mqtt:latest - volumes: - - ./zwave-js:/usr/src/app/store - devices: - - /dev/ttyUSB0:/dev/ttyUSB0 - - /dev/serial/by-id/usb-Silicon_Labs_HubZ_Smart_Home_Controller_415007C7-if00-port0 - environment: - - TZ=America/Chicago - restart: always - network_mode: host \ No newline at end of file diff --git a/Home Assistant/hass/.HA_VERSION b/Home Assistant/hass/.HA_VERSION deleted file mode 100644 index 92df6ce..0000000 --- a/Home Assistant/hass/.HA_VERSION +++ /dev/null @@ -1 +0,0 @@ -2021.1.5 \ No newline at end of file diff --git a/Home Assistant/hass/.storage/core.entity_registry b/Home Assistant/hass/.storage/core.entity_registry deleted file mode 100644 index 5e9993e..0000000 --- a/Home Assistant/hass/.storage/core.entity_registry +++ /dev/null @@ -1,25 +0,0 @@ -{ - "version": 1, - "key": "core.entity_registry", - "data": { - "entities": [ - { - "entity_id": "binary_sensor.updater", - "config_entry_id": null, - "device_id": null, - "area_id": null, - "unique_id": "updater", - "platform": "updater", - "name": null, - "icon": null, - "disabled_by": null, - "capabilities": null, - "supported_features": 0, - "device_class": null, - "unit_of_measurement": null, - "original_name": "Updater", - "original_icon": null - } - ] - } -} \ No newline at end of file diff --git a/Home Assistant/hass/.storage/core.uuid b/Home Assistant/hass/.storage/core.uuid deleted file mode 100644 index 3410f02..0000000 --- a/Home Assistant/hass/.storage/core.uuid +++ /dev/null @@ -1,7 +0,0 @@ -{ - "version": 1, - "key": "core.uuid", - "data": { - "uuid": "6ae2a5eefe6741829c95a45064c93a0f" - } -} \ No newline at end of file diff --git a/Home Assistant/hass/.storage/http b/Home Assistant/hass/.storage/http deleted file mode 100644 index 88d7fb6..0000000 --- a/Home Assistant/hass/.storage/http +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": 1, - "key": "http", - "data": { - "login_attempts_threshold": -1, - "server_port": 8123, - "cors_allowed_origins": [ - "https://cast.home-assistant.io" - ], - "ip_ban_enabled": true, - "ssl_profile": "modern" - } -} \ No newline at end of file diff --git a/Home Assistant/hass/automations.yaml b/Home Assistant/hass/automations.yaml deleted file mode 100644 index 0637a08..0000000 --- a/Home Assistant/hass/automations.yaml +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/Home Assistant/hass/blueprints/automation/homeassistant/motion_light.yaml b/Home Assistant/hass/blueprints/automation/homeassistant/motion_light.yaml deleted file mode 100644 index c11d22d..0000000 --- a/Home Assistant/hass/blueprints/automation/homeassistant/motion_light.yaml +++ /dev/null @@ -1,50 +0,0 @@ -blueprint: - name: Motion-activated Light - description: Turn on a light when motion is detected. - domain: automation - source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/motion_light.yaml - input: - motion_entity: - name: Motion Sensor - selector: - entity: - domain: binary_sensor - device_class: motion - light_target: - name: Light - selector: - target: - entity: - domain: light - no_motion_wait: - name: Wait time - description: Time to leave the light on after last motion is detected. - default: 120 - selector: - number: - min: 0 - max: 3600 - unit_of_measurement: seconds - -# If motion is detected within the delay, -# we restart the script. -mode: restart -max_exceeded: silent - -trigger: - platform: state - entity_id: !input motion_entity - from: "off" - to: "on" - -action: - - service: light.turn_on - target: !input light_target - - wait_for_trigger: - platform: state - entity_id: !input motion_entity - from: "on" - to: "off" - - delay: !input no_motion_wait - - service: light.turn_off - target: !input light_target diff --git a/Home Assistant/hass/blueprints/automation/homeassistant/notify_leaving_zone.yaml b/Home Assistant/hass/blueprints/automation/homeassistant/notify_leaving_zone.yaml deleted file mode 100644 index d3a70d7..0000000 --- a/Home Assistant/hass/blueprints/automation/homeassistant/notify_leaving_zone.yaml +++ /dev/null @@ -1,43 +0,0 @@ -blueprint: - name: Zone Notification - description: Send a notification to a device when a person leaves a specific zone. - domain: automation - source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml - input: - person_entity: - name: Person - selector: - entity: - domain: person - zone_entity: - name: Zone - selector: - entity: - domain: zone - notify_device: - name: Device to notify - description: Device needs to run the official Home Assistant app to receive notifications. - selector: - device: - integration: mobile_app - -trigger: - platform: state - entity_id: !input person_entity - -variables: - zone_entity: !input zone_entity - # This is the state of the person when it's in this zone. - zone_state: "{{ states[zone_entity].name }}" - person_entity: !input person_entity - person_name: "{{ states[person_entity].name }}" - -condition: - condition: template - value_template: "{{ trigger.from_state.state == zone_state and trigger.to_state.state != zone_state }}" - -action: - domain: mobile_app - type: notify - device_id: !input notify_device - message: "{{ person_name }} has left {{ zone_state }}" diff --git a/Home Assistant/hass/configuration.yaml b/Home Assistant/hass/configuration.yaml deleted file mode 100644 index beeacbb..0000000 --- a/Home Assistant/hass/configuration.yaml +++ /dev/null @@ -1,49 +0,0 @@ -# Configure a default setup of Home Assistant (frontend, api, etc) -default_config: - -homeassistant: - name: Church - latitude: 34.719930 - longitude: -86.704050 - elevation: 470 - unit_system: imperial - time_zone: "America/Chicago" - legacy_templates: false - -logger: - default: info - -# Text to speech -tts: - - platform: google_translate - -group: !include groups.yaml -automation: !include automations.yaml -script: !include scripts.yaml - -zha: - database_path: /config/zigbee.db - enable_quirks: true - -mqtt: - discovery: true - broker: 127.0.0.1 - port: 1883 - username: !secret mqtt_username - password: !secret mqtt_password - birth_message: - topic: 'homeassistant/status' - payload: 'online' - will_message: - topic: 'homeassistant/status' - payload: 'offline' - -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"] diff --git a/Home Assistant/hass/core b/Home Assistant/hass/core deleted file mode 100644 index ea8b4e7..0000000 Binary files a/Home Assistant/hass/core and /dev/null differ diff --git a/Home Assistant/hass/groups.yaml b/Home Assistant/hass/groups.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/Home Assistant/hass/home-assistant.log b/Home Assistant/hass/home-assistant.log deleted file mode 100644 index 7d77f2c..0000000 --- a/Home Assistant/hass/home-assistant.log +++ /dev/null @@ -1,2 +0,0 @@ -2021-09-02 23:56:55 WARNING (MainThread) [homeassistant.components.http.ban] Login attempt or request with invalid authentication from 10.11.0.2 (10.11.0.2) (Mozilla/5.0 (X11; Linux x86_64; rv:90.0) Gecko/20100101 Firefox/90.0) -2021-09-02 23:56:55 WARNING (MainThread) [homeassistant.bootstrap] Support for the running Python version 3.7.3 is deprecated and will be removed in the first release after December 7, 2020. Please upgrade Python to 3.8.0 or higher. diff --git a/Home Assistant/hass/home-assistant_v2.db b/Home Assistant/hass/home-assistant_v2.db deleted file mode 100644 index 2a5abd6..0000000 Binary files a/Home Assistant/hass/home-assistant_v2.db and /dev/null differ diff --git a/Home Assistant/hass/scenes.yaml b/Home Assistant/hass/scenes.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/Home Assistant/hass/scripts.yaml b/Home Assistant/hass/scripts.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/Home Assistant/hass/secrets.yaml b/Home Assistant/hass/secrets.yaml deleted file mode 100644 index ddb9141..0000000 --- a/Home Assistant/hass/secrets.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# Use this file to store secrets like usernames and passwords. -# Learn more at https://home-assistant.io/docs/configuration/secrets/ -mqtt_username: mqtt -mqtt_password: mqtt_password_placeholder - diff --git a/Home Assistant/mosquitto/aclfile b/Home Assistant/mosquitto/aclfile deleted file mode 100644 index 169e43f..0000000 --- a/Home Assistant/mosquitto/aclfile +++ /dev/null @@ -1,2 +0,0 @@ -user mqtt -topic readwrite # diff --git a/Home Assistant/mosquitto/mosquitto.conf b/Home Assistant/mosquitto/mosquitto.conf deleted file mode 100644 index 3a5abec..0000000 --- a/Home Assistant/mosquitto/mosquitto.conf +++ /dev/null @@ -1,6 +0,0 @@ -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 diff --git a/Home Assistant/mosquitto/pwfile b/Home Assistant/mosquitto/pwfile deleted file mode 100644 index d4869a5..0000000 --- a/Home Assistant/mosquitto/pwfile +++ /dev/null @@ -1 +0,0 @@ -mqtt:mqtt_password_placeholder \ No newline at end of file diff --git a/README.md b/README.md index af8dfb4..fd3785f 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,234 @@ -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 a DMX device to a network DMX protocol to control the 6 available zones. This project also supports MQTT messaging for Home Assistant support. +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/). + +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. + +# What you'll need + +- 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 -1. Decide if you want Home Assistant support, if you do not, you can skip to step 5. -2. If you do not already have an home assistant setup, you can view https://www.home-assistant.io/installation/ or use the base configurations in the `Home Assistant` directory to use my home assistant docker setup. The base configuration is designed for the HUSBZB-1 USB adapter, which you can use `ls -lah /dev/serial/by-id/` to see which ttyUSB interfaces are which, and get the correct serial number for your device. -3. If you don't have MQTT setup, you can follow guide at https://cyan-automation.medium.com/setting-up-mqtt-and-mosquitto-in-home-assistant-20eb810a91e6 or use my base configurations in the `Home Assistant` directory to configure mosquitto. Once you setup a username/password in the pwfile, use `mosquitto_passwd -U pwfile` to encrypt the password. -4. Edit the `lutron-dmx-control.py` file to make sure MQTT is enabled, pointed to the proper server, and has the correct password configured. -5. If you are not planning on using MQTT/Home Assistant, you can edit `lutron-dmx-control.py` to change `MQTT_ENABLED` from True to False. -6. Update the serial port for the QSE NWK in `lutron-dmx-control.py`, you can use `ls -lah /dev/serial/by-id/` to determine the device id. -7. Run the bash script install.sh to install services for olad and `lutron-dmx-control.py`. +## 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 +``. 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 @`). Install git and download this project: + +```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 +``` + +All the commands below are run from inside this `lutron-dmx-control` directory. + +> Throughout this guide, replace `` with the username you created above. If +> that username is **not** `pi`, you must also pass it to the installer +> (`TARGET_USER=`, shown in step 2) and substitute it in every +> `systemctl`/`journalctl` command (e.g. `lutron-dmx-control@`, 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 +bash ./install-ola.sh +``` + +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. + +## 2. Install the control service + +`install.sh` installs the Python dependencies, the control script, the config file +and the `olad@` / `lutron-dmx-control@` systemd services. By default it +installs for the `pi` user; pass `TARGET_USER=` for a different user. ```bash sudo bash ./install.sh +# or, for a non-pi user: +sudo TARGET_USER=james bash ./install.sh ``` -# Configuration -Once services are installed we need to stop the olad service to edit configuration files with `sudo systemctl start olad@pi`. We can then configure ola by editing 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 `sudo systemctl start olad@pi` 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. +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. -Trick to disable all modules except the one you are using. +> Note: the systemd unit runs `/home//lutron-dmx-control.py`, so ``'s +> home must be `/home/`. 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 -sed -i '/enabled\s=/c\enabled = false' ~/.ola/*.conf -sed -i '/enabled\s=/c\enabled = true' ~/.ola/ola-e131.conf +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 ``` -# Home Assistant MQTT config +## Receiving sACN (patching the universe) -If you have your own Home Assistant install, the configuration for this project is below. +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: @@ -42,18 +242,21 @@ light: supported_color_modes: ["brightness"] ``` -# Recommend +# Recommended: watchdog -Enable watchdog on the Raspberry Pi to auto reboot upon system crashes. +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. \ No newline at end of file +Reboot to apply. diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..e549223 --- /dev/null +++ b/config.example.yaml @@ -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 diff --git a/install-ola.sh b/install-ola.sh new file mode 100644 index 0000000..4c02d55 --- /dev/null +++ b/install-ola.sh @@ -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 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@ and lutron-dmx-control@ services." diff --git a/install.sh b/install.sh index 664f40e..d598053 100644 --- a/install.sh +++ b/install.sh @@ -1,29 +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:-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= 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 +cd "$SCRIPT_DIR" # Install Python/needed modules. -apt install -y python3-pip python3-serial python3 -pip3 install ola -pip3 install paho-mqtt +# 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 -cp lutron-dmx-control.py /home/pi/lutron-dmx-control.py -chown pi: /home/pi/lutron-dmx-control.py +# 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 -# Copy lutron-dmx-control@.service and olad@.service to /etc/systemd/system/ and run the following to enable/start. +# 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 -systemctl enable olad@pi -systemctl start olad@pi -systemctl enable lutron-dmx-control@pi -systemctl start lutron-dmx-control@pi \ No newline at end of file + +# 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 diff --git a/lutron-dmx-control.py b/lutron-dmx-control.py index 2e382de..d42eea8 100644 --- a/lutron-dmx-control.py +++ b/lutron-dmx-control.py @@ -22,368 +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 random -import json -from paho.mqtt import client as mqtt_client +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. + +# === 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" -# Set baud rate on Lutron's QSE NWK. QSE_NWK_BAUD = 115200 -# Number of zones on GRAFIK Eye QS Control panel -QSE_ZONES = 6 -# 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 -zoneValues = [] -sentValues = [] +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() -# 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. -sendAllDataThisTImeLock = threading.Lock() - -# MQTT Configurations -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_password_placeholder' - -# MQTT light state -mqttLightState = "OFF" +# MQTT state. +mqttLightState = MQTT_LIGHT_OFF mqttLightBrightness = 0 mqttSentLightState = "" mqttSentLightBrightness = 0 - -# MQTT state values -MQTT_LIGHT_ON = "ON" -MQTT_LIGHT_OFF = "OFF" - -# MQTT Connection mqtt_conn = None -# 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. +# 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) + + +# === 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) + + +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 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() + + +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 + + +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 per the protocol doc. +QSE_TERMINATOR = "\r\n" + + def qse_send_zone_value(zone, value): - global serialSession, VERBOSE + 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")) - # 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 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, 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) + -# This function receives data when a DMX update occurs. def dmx_universe_update(data): - global dataLock, zoneValues, lastDMXUniverseUpdate, QSE_ZONES, VERBOSE - # Acquire the lock for the thread to prevent data from overlapping. - dataLock.acquire() - if VERBOSE>=2: - print(data) - - # Write the new levels to each zone. - for zone in range(QSE_ZONES): - zoneValues[zone] = data[DMX_START_ADDRESS+zone] - - # Keep up to date with the last update to determine rather or not to unlock mqtt support. - lastDMXUniverseUpdate = time.time() - - # Allow the next command call to follow through by releasing the lock. - dataLock.release() - -# This function is a thread that writes any changes to the QSE controller. -def qse_write_zone_values(): - global sendAllDataThisTImeLock, dataLock, sendAllDataThisTime, zoneValues, sentValues, QSE_ZONES - while True: - - # If control is disabled, we won't check this time. - if controlDisabled: - # Prevent CPU overload and wait half a second before continuing. - time.sleep(0.5) - continue - - - # Acquire lock to prevent conflict between threads. - dataLock.acquire() - - # Copy zone values locally to allow changes by other threads while we send. - thisZoneValues = zoneValues.copy() - - # Allow the next command call to follow through by releasing the lock. - dataLock.release() - - # Acquire send all data this time lock. - sendAllDataThisTImeLock.acquire() - - # Check for changes in zones values and send it. + global lastDMXUniverseUpdate + with dataLock: for zone in range(QSE_ZONES): - # If zone value is the same and we're not sending all zone data this time, skip sending. - if thisZoneValues[zone]==sentValues[zone] and not sendAllDataThisTime: + 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 - # Update the array of sent values. - sentValues[zone] = thisZoneValues[zone] - - # Send value via QSE NWK - qse_send_zone_value(zone+1, thisZoneValues[zone]) + with dataLock: + thisZoneValues = list(zoneValues) + resendAll = sendAllDataThisTime + sendAllDataThisTime = False - # Reset the flag of send all data to false as we would have sent all data this time. - 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] - # Release the lock. - sendAllDataThisTImeLock.release() - - # Lower CPU usage. - time.sleep(0.1) + time.sleep(WRITE_INTERVAL_SEC) + except Exception: + log.exception("qse_write_zone_values loop error") + time.sleep(1) -# 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, sendAllDataThisTime, mqttLightBrightness, mqttLightState, VERBOSE - # Creates a bufferred reader for the serial input. - sio = io.TextIOWrapper(io.BufferedReader(serialSession)) + """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 - # 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=="": + 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," (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,,,14,. + 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. - elif 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. - elif line=="~DEVICE,1,75,3": - if VERBOSE>=2: - print("Received enable signal.") - controlDisabled = False + with dataLock: sendAllDataThisTime = True - # If none of the above, and is a device notification, we parse. - elif line.startswith("~DEVICE,1"): - data = line.split(",") - # If brightness notice and zone 1, let's update MQTT. - if data[3]=="14" and data[2]=="1": - # Acquire lock to prevent conflict between threads. - dataLock.acquire() - # Convert brightness to MQTT light state. - mqttLightBrightness = round((float(data[4])/100.00)*255,0) - if mqttLightBrightness==0: - # If control was disabled, and brightness is now 0, disable the control disablement. - if controlDisabled: - controlDisabled = False - mqttLightState = MQTT_LIGHT_OFF - else: - mqttLightState = MQTT_LIGHT_ON - - # Publish current state to MQTT. - mqtt_publish_state() +def qse_watchdog(): + """Detect a stale serial link and feed the systemd watchdog. - # Allow the next command call to follow through by releasing the lock. - dataLock.release() + 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 VERBOSE>=1: - print(line) + sd_notify("WATCHDOG=1") + time.sleep(WATCHDOG_INTERVAL_SEC) -# Reset the send all data flag every 10 seconds to ensure all zones have the correct value set. -def qse_reset_sendAllDataThisTime(): - global sendAllDataThisTImeLock, sendAllDataThisTime, VERBOSE - while True: - # Wait 10 seconds before running. - time.sleep(10) - # Acquire send all data this time lock. - sendAllDataThisTImeLock.acquire() - - # Reset - if VERBOSE>=3: - print("Resetting flag to send all data") - sendAllDataThisTime = True - - # Release the lock. - sendAllDataThisTImeLock.release() - -# Sends the current MQTT light state to MQTT. +# === MQTT === def mqtt_publish_state(): - global mqtt_conn, mqttLightState, mqttLightBrightness, mqttSentLightState, mqttSentLightBrightness, VERBOSE - # If we already sent this message, no duplicates. - if mqttLightState==mqttSentLightState and mqttLightBrightness==mqttSentLightBrightness: + 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") - # Generate json format of current state. - msg = json.dumps({"brightness": mqttLightBrightness,"state": mqttLightState}) - # Send message. - result = mqtt_conn.publish(MQTT_TOPIC, msg) - # result: [0, 1] - status = result[0] - if status == 0 and VERBOSE>=2: - print(f"Send `{msg}` to topic `{MQTT_TOPIC}`") - if status != 0: - print(f"Failed to send message to topic {MQTT_TOPIC}") -# Receives MQTT messages from subscribed topics. def mqtt_on_message(client, userdata, msg): - global mqttLightState, mqttLightBrightness, mqttSentLightState, mqttSentLightBrightness, lastDMXUniverseUpdate, VERBOSE - # If message received is to the JSON set topic, update light state. - if msg.topic==MQTT_TOPIC_SET: - # Decode JSON from the message. - decoded_message=str(msg.payload.decode("utf-8")) - data = json.loads(decoded_message) - if VERBOSE>=2: - print(f"Received `{data}` from `{msg.topic}` topic") + 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) - # Acquire lock to prevent conflict between threads. - dataLock.acquire() - - # Check message for brightness and state values/update accordingly. + with dataLock: if "brightness" in data: mqttLightBrightness = data["brightness"] if "state" in data: - if mqttLightState!=data["state"]: + if mqttLightState != data["state"]: mqttLightState = data["state"] - # If light state is on, but brightness value is off, set brightness to 50%. - if mqttLightState==MQTT_LIGHT_ON and mqttLightBrightness==0: + # Turning on with brightness 0 -> default to ~50%. + if mqttLightState == MQTT_LIGHT_ON and mqttLightBrightness == 0: mqttLightBrightness = 127 - - # Check to see if it has been more than 5 seconds since the last DMX universe update, if it has been, we're allowed to control the lights. - durationSinceLastDMXUniverseUpdate = time.time() - lastDMXUniverseUpdate - if durationSinceLastDMXUniverseUpdate>5: - # If state is on, set brightness levels to all zones. - if mqttLightState==MQTT_LIGHT_ON: - for zone in range(QSE_ZONES): - zoneValues[zone] = mqttLightBrightness - else: # If state is off, set brightness level of 0. - for zone in range(QSE_ZONES): - zoneValues[zone] = 0 - else: - # If locked due to DMX control, force values to first zone value. - mqttLightBrightness = zoneValues[0] - if mqttLightBrightness==0: - mqttLightState = MQTT_LIGHT_OFF - else: - mqttLightState = MQTT_LIGHT_ON + 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 - # Publish current state to MQTT. mqtt_publish_state() - # Allow the next command call to follow through by releasing the lock. - dataLock.release() - elif msg.topic!=MQTT_TOPIC: - print(f"Received unknown message `{msg.payload.decode()}` from `{msg.topic}` topic") -# Subscribes to the MQTT topic for this light. -def mqtt_subscribe(): - global mqtt_conn - mqtt_conn.subscribe(MQTT_TOPIC+"/#") +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("_") -# When the MQTT broker is connected, this function is called. -def mqtt_on_connect(client, userdata, flags, rc): - if rc == 0: - print("Connected to MQTT Broker!") - # New connection means we must publish state and subscribe to our topic. - mqtt_subscribe() + +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: - print("Failed to connect, return code %d\n", rc) + log.error("MQTT connect failed, rc=%s", reason_code) -# The MQTT thread for connection to the MQTT broker. -def mqtt_connect(): - global mqtt_conn + +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: - mqtt_conn = mqtt_client.Client(MQTT_CLIENT_ID) - 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) - mqtt_conn.loop_forever() - except: - print("MQTT Connection Failed, trying again in 10 seconds.\n") - time.sleep(10.0) - mqtt_connect() + 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) -# Build array with 0% in each zone. -for zone in range(QSE_ZONES): - zoneValues.append(0) - sentValues.append(0) -# 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 +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) -# 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() +# === Startup === +def start_thread(target, name): + t = threading.Thread(target=target, name=name, daemon=True) + t.start() + return t -# Now that we are ready to roll, we start the read thread. -_thread.start_new_thread(qse_read, ()) -# Start the write thread. -_thread.start_new_thread(qse_write_zone_values, ()) +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") -# Start the reset send all data thread. -_thread.start_new_thread(qse_reset_sendAllDataThisTime, ()) -# Start the MQTT light thread. -if MQTT_ENABLED: - _thread.start_new_thread(mqtt_connect, ()) +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) -# Connect to the DMX universe with the OLA wrapper. -wrapper = ClientWrapper() -client = wrapper.Client() -client.RegisterUniverse(DMX_UNIVERSE, client.REGISTER, dmx_universe_update) -wrapper.Run() + +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() diff --git a/lutron-dmx-control@.service b/lutron-dmx-control@.service index 6006c26..95ed627 100644 --- a/lutron-dmx-control@.service +++ b/lutron-dmx-control@.service @@ -1,13 +1,19 @@ [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] -WantedBy=multi-user.target \ No newline at end of file +WantedBy=multi-user.target