# 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()