Featureful Python controller code for WS2811/WS2812/NeoPixels
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

283 lines
8.6KB

  1. #!/usr/bin/python3
  2. from pixels import TermLEDs
  3. import board
  4. import neopixel
  5. import json
  6. import os
  7. import sys
  8. import time
  9. from pathlib import Path
  10. import uuid
  11. from threading import Lock, Condition
  12. import pyinotify, threading
  13. import paho.mqtt.client as mqtt
  14. from pattern import Pattern
  15. from gradients import Gradients
  16. ### Parameters ###
  17. if len(sys.argv) > 1:
  18. config_file = sys.argv[1]
  19. else:
  20. config_file = "blinky.json"
  21. if not Path(config_file).is_file():
  22. print("WARNING: Config file does not exist:", config_file)
  23. # Monitor config from MQTT
  24. class Device:
  25. id = "blinky" #f"{uuid.getnode():0>12x}"
  26. state_topic = f"light/{id}/state"
  27. command_topic = f"light/{id}/cmd"
  28. fx_state_topic = f"light/{id}/fx"
  29. ha_topic = 'homeassistant/status'
  30. ready = False
  31. changed = False
  32. should_publish = False
  33. power_on = True
  34. effect = None
  35. def advertise_locked(client, device):
  36. client.publish(
  37. f"homeassistant/light/{device.id}/config",
  38. json.dumps({
  39. 'unique_id': device.id,
  40. 'device': {
  41. 'name': "blinky",
  42. 'identifiers': [device.id],
  43. },
  44. 'state_topic': device.state_topic,
  45. 'command_topic': device.command_topic,
  46. 'effect_state_topic': device.fx_state_topic,
  47. 'schema': "json",
  48. 'effect': True,
  49. 'effect_list': device.effects,
  50. })
  51. )
  52. device.publish_state_locked()
  53. def on_mqtt_connect(client, device, flags, rc):
  54. if rc == 0:
  55. with device.lock:
  56. print("Connected to MQTT")
  57. device.ready = True
  58. Device.advertise_locked(client, device)
  59. client.subscribe(device.command_topic)
  60. client.subscribe(device.ha_topic)
  61. device.cond.notify_all()
  62. else:
  63. print(f"MQTT connection failed: {rc}")
  64. def set_mqtt_config(self, data):
  65. with self.lock:
  66. changed = False
  67. if 'state' in data:
  68. device.power_on = data['state'] == "ON"
  69. changed = True
  70. if 'effect' in data:
  71. device.effect = data['effect']
  72. changed = True
  73. if changed:
  74. self.changed = True
  75. self.update_state_locked()
  76. def on_mqtt_message(client, device, msg):
  77. if msg.topic == device.command_topic:
  78. device.set_mqtt_config(json.loads(msg.payload))
  79. elif msg.topic == device.ha_topic:
  80. with device.lock:
  81. Device.advertise_locked(client, device)
  82. def __init__(self, effects=[]):
  83. print(f"Creating device with ID {self.id}")
  84. self.effects = effects
  85. self.lock = Lock()
  86. self.cond = Condition(self.lock)
  87. self.client = mqtt.Client(userdata=self)
  88. self.client.on_connect = Device.on_mqtt_connect
  89. self.client.on_message = Device.on_mqtt_message
  90. self.client.tls_set("/etc/ssl/certs/ca-certificates.crt")
  91. self.client.username_pw_set("blinky", "rainbow")
  92. self.client.connect_async("mqtt.jrhoffa.com", 8883)
  93. self.client.loop_start();
  94. def is_ready(self): return self.ready
  95. def is_changed(self): return self.changed
  96. def wait_ready(self, timeout=None):
  97. with self.lock: self.cond.wait_for(self.is_ready, timeout=timeout)
  98. def wait_changed(self, timeout=None):
  99. with self.lock: self.cond.wait_for(self.is_changed, timeout=timeout)
  100. def publish_state_locked(self):
  101. self.should_publish = not self.ready
  102. if self.ready:
  103. self.client.publish(
  104. device.state_topic,
  105. json.dumps({
  106. 'state': "ON" if self.power_on else "OFF",
  107. 'effect': self.effect
  108. })
  109. )
  110. def update_state_locked(self):
  111. self.changed = True
  112. self.publish_state_locked()
  113. self.cond.notify_all()
  114. def set_power_state(self, power_on):
  115. with self.lock:
  116. self.power_on = power_on
  117. self.update_state_locked()
  118. def set_effect(self, effect):
  119. with self.lock:
  120. self.effect = effect
  121. self.update_state_locked()
  122. device = Device([gradient for gradient in dir(Gradients) if not gradient.startswith('__')])
  123. # Monitor config from file
  124. file_changed = True
  125. ignore_file = False
  126. config = {}
  127. new_config = None
  128. def file_thread(file, device):
  129. class EventHandler(pyinotify.ProcessEvent):
  130. def process_IN_CLOSE_WRITE(self, event):
  131. global file_changed, new_config, ignore_file
  132. with device.lock:
  133. if ignore_file:
  134. print("Ignoring file change")
  135. ignore_file = False
  136. else:
  137. print("Triggering file reload")
  138. new_config = None
  139. file_changed = True
  140. device.cond.notify_all()
  141. wm = pyinotify.WatchManager()
  142. notifier = pyinotify.Notifier(wm, EventHandler())
  143. wdd = wm.add_watch(file, pyinotify.IN_CLOSE_WRITE)
  144. notifier.loop()
  145. threading.Thread(target=file_thread, args=[config_file, device]).start()
  146. ### Entry Point ##
  147. framerate = 30
  148. power_on = True
  149. length = None
  150. patterns = None
  151. pixels = None
  152. with device.lock:
  153. while True:
  154. try:
  155. if file_changed:
  156. print("Loading config file")
  157. new_config = json.load(open(config_file, 'r'))
  158. file_changed = False
  159. elif device.changed:
  160. print("Using new config from MQTT")
  161. new_config = {'power_on': device.power_on}
  162. if device.effect:
  163. new_config['patterns'] = [{
  164. 'gradient': {'preset': device.effect},
  165. 'cycle_length': 20
  166. }]
  167. device.changed = False
  168. if 'length' in new_config:
  169. new_length = new_config['length']
  170. if length != new_length:
  171. config['length'] = new_length
  172. length = new_length
  173. pixels = neopixel.NeoPixel(board.D18, length, pixel_order=neopixel.RGB, auto_write=False)
  174. #pixels = TermLEDs(length)
  175. print("String configured")
  176. if 'power_on' in new_config:
  177. config['power_on'] = new_config['power_on']
  178. power_on = config['power_on']
  179. if 'patterns' in new_config:
  180. config['patterns'] = new_config['patterns']
  181. # Don't regenerate the patterns if this is just a power command
  182. if 'length' in new_config or 'patterns' in new_config:
  183. patterns = [(pattern.get('length', length), Pattern.from_dict(pattern)) for pattern in config['patterns']]
  184. if file_changed and len(patterns) > 0 and 'gradient' in config['patterns'][0]:
  185. device.set_effect(config['patterns'][0]['gradient'].get('preset', None))
  186. print("Pattern configured")
  187. except Exception as e:
  188. print("Failed to set config:", e)
  189. finally:
  190. print("Saving config")
  191. ignore_file = True
  192. json.dump(config, open(config_file, 'w'))
  193. if not device.power_on or not length or not pixels or not patterns:
  194. if not device.power_on:
  195. pixels[:] = [0, 0, 0] * length
  196. pixels.show()
  197. print("Power off, waiting ...")
  198. else:
  199. print("Waiting for valid config")
  200. while not file_changed and not device.is_changed():
  201. device.cond.wait()
  202. else:
  203. print("Starting animation")
  204. last = target = time.time()
  205. while not file_changed and not device.is_changed():
  206. device.lock.release()
  207. # Generate and show new light pattern
  208. pixels[:] = [x for y in (pattern.step(length) for (length, pattern) in patterns) for x in y]
  209. pixels.show()
  210. # Go ahead and sleep, maintaining maximum framerate
  211. target += (1 / framerate)
  212. now = time.time()
  213. sleeptime = target - now
  214. # Release before sleep because we're using the condition variable
  215. # to wake up early in case there's an important message
  216. #device.lock.acquire()
  217. if sleeptime > 0:
  218. time.sleep(sleeptime)
  219. #while not file_changed and not device.is_changed():
  220. # device.cond.wait(sleeptime)
  221. device.lock.acquire()