lutron-dmx-control/lutron-dmx-control.py

389 lines
15 KiB
Python

# lutron-dmx-control
#
# Copyright (c) 2019, Mr. Gecko's Media (James Coleman)
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
from ola.ClientWrapper import ClientWrapper
import serial
import io
import _thread
import threading
import time
import random
import json
from paho.mqtt import client as mqtt_client
# 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
# 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/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.
DMX_UNIVERSE = 3
# The starting address.
DMX_START_ADDRESS = 0
#Verbosity
VERBOSE=1
# Variables used at run time, do not adjust.
serialSession = None
zoneValues = []
sentValues = []
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()
# 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"
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.
def qse_send_zone_value(zone, value):
global serialSession, VERBOSE
# 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'))
# 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.
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:
continue
# Update the array of sent values.
sentValues[zone] = thisZoneValues[zone]
# Send value via QSE NWK
qse_send_zone_value(zone+1, thisZoneValues[zone])
# Reset the flag of send all data to false as we would have sent all data this time.
sendAllDataThisTime = False
# Release the lock.
sendAllDataThisTImeLock.release()
# Lower CPU usage.
time.sleep(0.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))
# We want to run this forever as we are in a thread.
while True:
# Gets the next available line from the QSE NWK and filter out the QSE prompt and any new line characters.
line = sio.readline().replace("QSE>","").rstrip()
if line=="":
continue
# 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
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()
# Allow the next command call to follow through by releasing the lock.
dataLock.release()
if VERBOSE>=1:
print(line)
# 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.
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:
return
mqttSentLightState = mqttLightState
mqttSentLightBrightness = mqttLightBrightness
# 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")
# Acquire lock to prevent conflict between threads.
dataLock.acquire()
# Check message for brightness and state values/update accordingly.
if "brightness" in data:
mqttLightBrightness = data["brightness"]
if "state" in data:
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:
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
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+"/#")
# 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()
mqtt_publish_state()
else:
print("Failed to connect, return code %d\n", rc)
# The MQTT thread for connection to the MQTT broker.
def mqtt_connect():
global mqtt_conn
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()
# 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
# 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()
# 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, ())
# 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, ())
# 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()