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