Featureful Python controller code for WS2811/WS2812/NeoPixels
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

271 lines
8.5KB

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