Browse Source

Add Home Assistant support (MQTT)

Includes total refactor of MQTT stack
master
1 changed files with 187 additions and 79 deletions
  1. +187
    -79
      blinky.py

+ 187
- 79
blinky.py View File

@@ -8,22 +8,17 @@ import os
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
import uuid
from threading import Lock, Condition


import pyinotify, threading import pyinotify, threading
import paho.mqtt.subscribe as subscribe
import paho.mqtt.client as mqtt


from pattern import Pattern from pattern import Pattern
from gradients import Gradients




lock_reload = threading.Lock()
cond_reload = threading.Condition(lock_reload)
reload = False
ignore_file = False
config = {}
new_config = None


# Monitor config from file
### Parameters ###


if len(sys.argv) > 1: if len(sys.argv) > 1:
config_file = sys.argv[1] config_file = sys.argv[1]
@@ -33,19 +28,145 @@ else:
if not Path(config_file).is_file(): if not Path(config_file).is_file():
print("WARNING: Config file does not exist:", config_file) print("WARNING: Config file does not exist:", config_file)


def file_thread(file):

# Monitor config from MQTT

class Device:
id = "blinky" #f"{uuid.getnode():0>12x}"

state_topic = f"light/{id}/state"
command_topic = f"light/{id}/cmd"
fx_state_topic = f"light/{id}/fx"

ready = False
changed = False
should_publish = False

power_on = True
effect = None

def on_mqtt_connect(client, device, flags, rc):
if rc == 0:
with device.lock:
print("Connected to MQTT")
client.publish(
f"homeassistant/light/{device.id}/config",
json.dumps({
'unique_id': device.id,
'device': {
'name': "blinky",
'identifiers': [device.id],
},

'state_topic': device.state_topic,
'command_topic': device.command_topic,
'effect_state_topic': device.fx_state_topic,

'schema': "json",

'effect': True,
'effect_list': device.effects,
})
)
client.subscribe(device.command_topic)
device.ready = True
if device.should_publish:
device.publish_state_locked()
device.cond.notify_all()
else:
print(f"MQTT connection failed: {rc}")

def set_mqtt_config(self, data):
with self.lock:
changed = False
if 'state' in data:
device.power_on = data['state'] == "ON"
changed = True
if 'effect' in data:
device.effect = data['effect']
changed = True
if changed:
self.changed = True
self.update_state_locked()

def on_mqtt_message(client, device, msg):
if msg.topic == device.command_topic:
device.set_mqtt_config(json.loads(msg.payload))

def __init__(self, effects=[]):
print(f"Creating device with ID {self.id}")

self.effects = effects

self.lock = Lock()
self.cond = Condition(self.lock)

self.client = mqtt.Client(userdata=self)
self.client.on_connect = Device.on_mqtt_connect
self.client.on_message = Device.on_mqtt_message
self.client.tls_set("/etc/ssl/certs/ca-certificates.crt")
self.client.username_pw_set("blinky", "rainbow")
self.client.connect_async("mqtt.jrhoffa.com", 8883)
self.client.loop_start();

def is_ready(self): return self.ready
def is_changed(self): return self.changed

def wait_ready(self, timeout=None):
with self.lock: self.cond.wait_for(self.is_ready, timeout=timeout)
def wait_changed(self, timeout=None):
with self.lock: self.cond.wait_for(self.is_changed, timeout=timeout)

def publish_state_locked(self):
self.should_publish = not self.ready
if self.ready:
self.client.publish(
device.state_topic,
json.dumps({
'state': "ON" if self.power_on else "OFF",
'effect': self.effect
})
)

def update_state_locked(self):
self.changed = True
self.publish_state_locked()
self.cond.notify_all()

def set_power_state(self, power_on):
with self.lock:
self.power_on = power_on
self.update_state_locked()

def set_effect(self, effect):
with self.lock:
self.effect = effect
self.update_state_locked()

device = Device([gradient for gradient in dir(Gradients) if not gradient.startswith('__')])


# Monitor config from file

file_changed = True
ignore_file = False

config = {}
new_config = None

def file_thread(file, device):
class EventHandler(pyinotify.ProcessEvent): class EventHandler(pyinotify.ProcessEvent):
def process_IN_CLOSE_WRITE(self, event): def process_IN_CLOSE_WRITE(self, event):
global reload, new_config, ignore_file
with lock_reload:
global file_changed, new_config, ignore_file
with device.lock:
if ignore_file: if ignore_file:
print("Ignoring file change") print("Ignoring file change")
ignore_file = False ignore_file = False
else: else:
print("Triggering file reload") print("Triggering file reload")
new_config = None new_config = None
reload = True
cond_reload.notify_all()
file_changed = True
device.cond.notify_all()


wm = pyinotify.WatchManager() wm = pyinotify.WatchManager()
notifier = pyinotify.Notifier(wm, EventHandler()) notifier = pyinotify.Notifier(wm, EventHandler())
@@ -53,101 +174,81 @@ def file_thread(file):


notifier.loop() notifier.loop()


threading.Thread(target=file_thread, args=[config_file]).start()


# Monitor config from MQTT

class mqtt:
topic = "blinky/config"
hostname = "mqtt.jrhoffa.com"
auth = {'username':"blinky", 'password':"rainbow"}
tls = {'ca_certs':"/etc/ssl/certs/ca-certificates.crt"}

def mqtt_thread():
def on_message(client, userdata, message):
global reload, new_config
with lock_reload:
print("Triggering MQTT reload")
try:
new_config = json.loads(message.payload)
reload = True
cond_reload.notify_all()
except:
print("Invalid MQTT config")

global reload, new_config
while True:
print(f"Subscribing to MQTT topic {mqtt.topic}")
try:
message = subscribe.simple(mqtt.topic, hostname=mqtt.hostname, auth=mqtt.auth, port=8883, tls=mqtt.tls)
except Exception as e:
print("MQTT subscription error:", e)
message = None
if not message:
print("Subscription failed - retrying")
time.sleep(1)
else:
with lock_reload:
print("Triggering MQTT reload")
try:
new_config = json.loads(message.payload)
reload = True
cond_reload.notify_all()
except:
print("Invalid MQTT config")


threading.Thread(target=mqtt_thread).start()
threading.Thread(target=file_thread, args=[config_file, device]).start()




### Entry Point ## ### Entry Point ##


framerate = 30 framerate = 30


power_on = True
length = None length = None
patterns = None patterns = None
pixels = None pixels = None


with lock_reload:
with device.lock:
while True: while True:
print("Loading config")
reload = False

try: try:
# If it wasn't delivered, load from file
new_config = new_config or json.load(open(config_file, 'r'))
if file_changed:
print("Loading config file")
new_config = json.load(open(config_file, 'r'))
file_changed = False
elif device.changed:
print("Using new config from MQTT")
new_config = {'power_on': device.power_on}
if device.effect:
new_config['patterns'] = [{
'gradient': {'preset': device.effect},
'cycle_length': 20
}]
device.changed = False


if 'length' in new_config: if 'length' in new_config:
new_length = new_config['length'] new_length = new_config['length']
if length != new_length: if length != new_length:
config['length'] = new_length
length = new_length length = new_length
config['length'] = length
pixels = neopixel.NeoPixel(board.D18, length, pixel_order=neopixel.RGB, auto_write=False) pixels = neopixel.NeoPixel(board.D18, length, pixel_order=neopixel.RGB, auto_write=False)
#pixels = TermLEDs(length) #pixels = TermLEDs(length)
print("String configured") print("String configured")


patterns = [(pattern.get('length', length), Pattern.from_dict(pattern)) for pattern in new_config['patterns']]
config['patterns'] = new_config['patterns']
print("Pattern configured")
if 'power_on' in new_config:
config['power_on'] = new_config['power_on']
power_on = config['power_on']

if 'patterns' in new_config:
config['patterns'] = new_config['patterns']

# Don't regenerate the patterns if this is just a power command
if 'length' in new_config or 'patterns' in new_config:
patterns = [(pattern.get('length', length), Pattern.from_dict(pattern)) for pattern in config['patterns']]
if file_changed and len(patterns) > 0 and 'gradient' in config['patterns'][0]:
device.set_effect(config['patterns'][0]['gradient'].get('preset', None))
print("Pattern configured")


except Exception as e: except Exception as e:
print("Failed to load config:", e)
print("Failed to set config:", e)


finally: finally:
print("Saving config") print("Saving config")
ignore_file = True ignore_file = True
json.dump(config, open(config_file, 'w')) json.dump(config, open(config_file, 'w'))


if not length or not pixels or not patterns:
print("Waiting for valid config")
while not reload:
cond_reload.wait()
if not device.power_on or not length or not pixels or not patterns:
if not device.power_on:
pixels[:] = [0, 0, 0] * length
pixels.show()
print("Power off, waiting ...")
else:
print("Waiting for valid config")
while not file_changed and not device.is_changed():
device.cond.wait()

else: else:
print("Starting animation") print("Starting animation")
last = target = time.time() last = target = time.time()
while not reload:
lock_reload.release()
while not file_changed and not device.is_changed():
device.lock.release()


# Generate and show new light pattern # Generate and show new light pattern
pixels[:] = [x for y in (pattern.step(length) for (length, pattern) in patterns) for x in y] pixels[:] = [x for y in (pattern.step(length) for (length, pattern) in patterns) for x in y]
@@ -157,7 +258,14 @@ with lock_reload:
target += (1 / framerate) target += (1 / framerate)
now = time.time() now = time.time()
sleeptime = target - now sleeptime = target - now

# Release before sleep because we're using the condition variable
# to wake up early in case there's an important message
#device.lock.acquire()

if sleeptime > 0: if sleeptime > 0:
time.sleep(sleeptime) time.sleep(sleeptime)
#while not file_changed and not device.is_changed():
# device.cond.wait(sleeptime)


lock_reload.acquire()
device.lock.acquire()

Loading…
Cancel
Save