From 979c865262bfb5aa1b6706e53367477342858c97 Mon Sep 17 00:00:00 2001 From: James Coleman Date: Thu, 9 Sep 2021 17:00:22 -0500 Subject: [PATCH] Added a background thread for updating the light zones on the QSE and added MQTT support for Home Assistant. --- Home Assistant/docker-compose.yml | 32 ++ Home Assistant/hass/.HA_VERSION | 1 + .../hass/.storage/core.entity_registry | 25 ++ Home Assistant/hass/.storage/core.uuid | 7 + Home Assistant/hass/.storage/http | 13 + Home Assistant/hass/automations.yaml | 1 + .../homeassistant/motion_light.yaml | 50 ++++ .../homeassistant/notify_leaving_zone.yaml | 43 +++ Home Assistant/hass/configuration.yaml | 49 +++ Home Assistant/hass/core | Bin 0 -> 176128 bytes Home Assistant/hass/groups.yaml | 0 Home Assistant/hass/home-assistant.log | 2 + Home Assistant/hass/home-assistant_v2.db | Bin 0 -> 110592 bytes Home Assistant/hass/scenes.yaml | 0 Home Assistant/hass/scripts.yaml | 0 Home Assistant/hass/secrets.yaml | 5 + Home Assistant/mosquitto/aclfile | 2 + Home Assistant/mosquitto/mosquitto.conf | 6 + Home Assistant/mosquitto/pwfile | 1 + README.md | 45 +-- install.sh | 29 ++ lutron-dmx-control.py | 281 ++++++++++++++++-- 22 files changed, 543 insertions(+), 49 deletions(-) create mode 100644 Home Assistant/docker-compose.yml create mode 100644 Home Assistant/hass/.HA_VERSION create mode 100644 Home Assistant/hass/.storage/core.entity_registry create mode 100644 Home Assistant/hass/.storage/core.uuid create mode 100644 Home Assistant/hass/.storage/http create mode 100644 Home Assistant/hass/automations.yaml create mode 100644 Home Assistant/hass/blueprints/automation/homeassistant/motion_light.yaml create mode 100644 Home Assistant/hass/blueprints/automation/homeassistant/notify_leaving_zone.yaml create mode 100644 Home Assistant/hass/configuration.yaml create mode 100644 Home Assistant/hass/core create mode 100644 Home Assistant/hass/groups.yaml create mode 100644 Home Assistant/hass/home-assistant.log create mode 100644 Home Assistant/hass/home-assistant_v2.db create mode 100644 Home Assistant/hass/scenes.yaml create mode 100644 Home Assistant/hass/scripts.yaml create mode 100644 Home Assistant/hass/secrets.yaml create mode 100644 Home Assistant/mosquitto/aclfile create mode 100644 Home Assistant/mosquitto/mosquitto.conf create mode 100644 Home Assistant/mosquitto/pwfile create mode 100644 install.sh diff --git a/Home Assistant/docker-compose.yml b/Home Assistant/docker-compose.yml new file mode 100644 index 0000000..093d9df --- /dev/null +++ b/Home Assistant/docker-compose.yml @@ -0,0 +1,32 @@ +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 new file mode 100644 index 0000000..92df6ce --- /dev/null +++ b/Home Assistant/hass/.HA_VERSION @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..5e9993e --- /dev/null +++ b/Home Assistant/hass/.storage/core.entity_registry @@ -0,0 +1,25 @@ +{ + "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 new file mode 100644 index 0000000..3410f02 --- /dev/null +++ b/Home Assistant/hass/.storage/core.uuid @@ -0,0 +1,7 @@ +{ + "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 new file mode 100644 index 0000000..88d7fb6 --- /dev/null +++ b/Home Assistant/hass/.storage/http @@ -0,0 +1,13 @@ +{ + "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 new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Home Assistant/hass/automations.yaml @@ -0,0 +1 @@ +[] \ 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 new file mode 100644 index 0000000..c11d22d --- /dev/null +++ b/Home Assistant/hass/blueprints/automation/homeassistant/motion_light.yaml @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..d3a70d7 --- /dev/null +++ b/Home Assistant/hass/blueprints/automation/homeassistant/notify_leaving_zone.yaml @@ -0,0 +1,43 @@ +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 new file mode 100644 index 0000000..beeacbb --- /dev/null +++ b/Home Assistant/hass/configuration.yaml @@ -0,0 +1,49 @@ +# 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 new file mode 100644 index 0000000000000000000000000000000000000000..ea8b4e757c8362eedc41e43bc52dc10c0cdf0db8 GIT binary patch literal 176128 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 /dev/null && pwd )" +cd $SCRIPT_DIR + +# Install Python/needed modules. +apt install -y python3-pip python3-serial python3 +pip3 install ola +pip3 install paho-mqtt + +cp lutron-dmx-control.py /home/pi/lutron-dmx-control.py +chown pi: /home/pi/lutron-dmx-control.py + +# Copy lutron-dmx-control@.service and olad@.service to /etc/systemd/system/ and run the following to enable/start. +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 diff --git a/lutron-dmx-control.py b/lutron-dmx-control.py index a4bc693..2e382de 100644 --- a/lutron-dmx-control.py +++ b/lutron-dmx-control.py @@ -28,7 +28,10 @@ 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 @@ -36,9 +39,11 @@ import time # Configuration # Serial port device to use to communicate with Lutron's QSE NWK. -QSE_NWK_DEVICE = "/dev/ttyUSB0" +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. @@ -49,25 +54,45 @@ VERBOSE=1 # Variables used at run time, do not adjust. serialSession = None -currentValues = [0,0,0,0,0,0] +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 SetZone(zone, value): - global serialSession, currentValues, sendAllDataThisTime, controlDisabled - # We only want to translate a level of it has not already been sent to the zone, - # or if we want to send all data this time. However we do not want to send the level - # if the controls has been disabled by the designated button on the control panel. - if (currentValues[zone-1]==value and not sendAllDataThisTime) or controlDisabled: - return - - # Update the array of current values. - currentValues[zone-1] = value +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)) @@ -77,30 +102,73 @@ def SetZone(zone, value): # Send to the QSE NWK. serialSession.write(bytes(command+"\n\r", 'utf-8')) -def NewData(data): - global sendAllDataThisTime, dataLock +# 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) - # Send the new levels to each zone via the QSE NWK. - SetZone(1,data[DMX_START_ADDRESS+0]) - SetZone(2,data[DMX_START_ADDRESS+1]) - SetZone(3,data[DMX_START_ADDRESS+2]) - SetZone(4,data[DMX_START_ADDRESS+3]) - SetZone(5,data[DMX_START_ADDRESS+4]) - SetZone(6,data[DMX_START_ADDRESS+5]) + # Write the new levels to each zone. + for zone in range(QSE_ZONES): + zoneValues[zone] = data[DMX_START_ADDRESS+zone] - # Reset the flag of send all data to false as we would have sent all data this time. - sendAllDataThisTime = False + # 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 +def qse_read(): + global serialSession, controlDisabled, sendAllDataThisTime, mqttLightBrightness, mqttLightState, VERBOSE # Creates a bufferred reader for the serial input. sio = io.TextIOWrapper(io.BufferedReader(serialSession)) @@ -122,30 +190,172 @@ def QSE_Read(): serialSession.write(bytes("#RESET,0\n\r", 'utf-8')) # If the all zone up button is pressed, we disable control from the program to allow someone to manually control zones. - if line=="~DEVICE,1,74,3": + 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. - if line=="~DEVICE,1,75,3": + 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 sendAllDataReset(): - global sendAllDataThisTime +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: @@ -160,13 +370,20 @@ if serialSession == None: serialSession.open() # Now that we are ready to roll, we start the read thread. -_thread.start_new_thread(QSE_Read, ()) +_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(sendAllDataReset, ()) +_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, NewData) +client.RegisterUniverse(DMX_UNIVERSE, client.REGISTER, dmx_universe_update) wrapper.Run()