| @@ -8,22 +8,17 @@ import os | |||
| import sys | |||
| import time | |||
| from pathlib import Path | |||
| import uuid | |||
| from threading import Lock, Condition | |||
| import pyinotify, threading | |||
| import paho.mqtt.subscribe as subscribe | |||
| import paho.mqtt.client as mqtt | |||
| 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: | |||
| config_file = sys.argv[1] | |||
| @@ -33,19 +28,145 @@ else: | |||
| if not Path(config_file).is_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): | |||
| 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: | |||
| print("Ignoring file change") | |||
| ignore_file = False | |||
| else: | |||
| print("Triggering file reload") | |||
| new_config = None | |||
| reload = True | |||
| cond_reload.notify_all() | |||
| file_changed = True | |||
| device.cond.notify_all() | |||
| wm = pyinotify.WatchManager() | |||
| notifier = pyinotify.Notifier(wm, EventHandler()) | |||
| @@ -53,101 +174,81 @@ def file_thread(file): | |||
| 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 ## | |||
| framerate = 30 | |||
| power_on = True | |||
| length = None | |||
| patterns = None | |||
| pixels = None | |||
| with lock_reload: | |||
| with device.lock: | |||
| while True: | |||
| print("Loading config") | |||
| reload = False | |||
| 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: | |||
| new_length = new_config['length'] | |||
| if length != new_length: | |||
| config['length'] = new_length | |||
| length = new_length | |||
| config['length'] = length | |||
| pixels = neopixel.NeoPixel(board.D18, length, pixel_order=neopixel.RGB, auto_write=False) | |||
| #pixels = TermLEDs(length) | |||
| 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: | |||
| print("Failed to load config:", e) | |||
| print("Failed to set config:", e) | |||
| finally: | |||
| print("Saving config") | |||
| ignore_file = True | |||
| 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: | |||
| print("Starting animation") | |||
| 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 | |||
| 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) | |||
| now = time.time() | |||
| 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: | |||
| time.sleep(sleeptime) | |||
| #while not file_changed and not device.is_changed(): | |||
| # device.cond.wait(sleeptime) | |||
| lock_reload.acquire() | |||
| device.lock.acquire() | |||