From cc35246e66640ca4fd4d16242a3f94fb1a439554 Mon Sep 17 00:00:00 2001 From: jrhoffa Date: Sun, 4 Sep 2022 19:42:35 -0700 Subject: [PATCH] Initial commit --- .gitignore | 1 + LICENSE | 21 +++++++ README | 6 ++ blinky.json | 27 +++++++++ blinky.py | 114 ++++++++++++++++++++++++++++++++++++++ colors.py | 30 ++++++++++ gradients.py | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++ pattern.py | 33 +++++++++++ pixels.py | 18 ++++++ 9 files changed, 403 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README create mode 100644 blinky.json create mode 100755 blinky.py create mode 100644 colors.py create mode 100644 gradients.py create mode 100644 pattern.py create mode 100644 pixels.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..963ca9f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Nathaniel Walizer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README b/README new file mode 100644 index 0000000..ad729d2 --- /dev/null +++ b/README @@ -0,0 +1,6 @@ +Featureful Python controller code for WS2811/WS2812/NeoPixels + - Based on Adafruit CircuitPython NeoPixel library + - Dozens of holiday and other animation presets + - Fully configurable animations + - Multiple animations in different sections + - Service-ready with dynamic MQTT and config file updates diff --git a/blinky.json b/blinky.json new file mode 100644 index 0000000..72349e9 --- /dev/null +++ b/blinky.json @@ -0,0 +1,27 @@ +{ + "length": 50, + "patterns": [ + { + "length": 50, + "gradient": { + "preset": "peacock" + }, + "cycle_length": 20 + }, { + "length": 0, + "gradient": { + "colors": [ + "red", + "orange", + "yellow", + "green", + "blue", + "purple", + "black" + ] + }, + "cycle_length": 7, + "march": true + } + ] +} diff --git a/blinky.py b/blinky.py new file mode 100755 index 0000000..d473737 --- /dev/null +++ b/blinky.py @@ -0,0 +1,114 @@ +#!/usr/bin/python3 + +from pixels import TermLEDs +import board +import neopixel +import json +import os +import time + +import pyinotify, threading +import paho.mqtt.subscribe as subscribe + +from pattern import Pattern + + +lock_reload = threading.Lock() +cond_reload = threading.Condition(lock_reload) +reload = False +config = None + + +# Monitor config from file + +config_file = "blinky.json" + +def file_thread(file): + class EventHandler(pyinotify.ProcessEvent): + def process_IN_CLOSE_WRITE(self, event): + global reload, config + with lock_reload: + print("Triggering reload") + config = None + reload = True + cond_reload.notify_all() + + wm = pyinotify.WatchManager() + notifier = pyinotify.Notifier(wm, EventHandler()) + wdd = wm.add_watch(file, pyinotify.IN_CLOSE_WRITE) + + 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, config + with lock_reload: + config = json.loads(message.payload) + reload = True + cond_reload.notify_all() + + subscribe.callback(on_message, mqtt.topic, hostname=mqtt.hostname, auth=mqtt.auth, port=8883, tls=mqtt.tls) + +threading.Thread(target=mqtt_thread).start() + + +### Entry Point ## + +framerate = 30 + +length = None +patterns = None +pixels = None + +with lock_reload: + while True: + print("Loading config") + reload = False + + try: + config = config or json.load(open(config_file)) + + new_length = config['length'] + + if length != new_length: + print("New string config") + length = new_length + pixels = neopixel.NeoPixel(board.D18, length, pixel_order=neopixel.RGB, auto_write=False) + #pixels = TermLEDs(length) + + patterns = [(pattern['length'], Pattern.from_dict(pattern)) for pattern in config['patterns']] + + except Exception as e: + print("Failed to load config:", e) + + if not pixels or not patterns: + print("Waiting for valid config") + while not reload: + cond_reload.wait() + else: + target = time.time() + while not reload: + lock_reload.release() + + # Generate and show new light pattern + pixels[:] = [x for y in (pattern.step(length) for (length, pattern) in patterns) for x in y] + pixels.show() + + # Go ahead and sleep, maintaining maximum framerate + target += (1 / framerate) + now = time.time() + if target > now: + time.sleep(target - now) + + lock_reload.acquire() diff --git a/colors.py b/colors.py new file mode 100644 index 0000000..0d77097 --- /dev/null +++ b/colors.py @@ -0,0 +1,30 @@ +class Colors: + red = (255, 0, 0) + orange = (78, 25, 0) + yellow = (255, 160, 0) + green = ( 0, 255, 0) + blue = ( 0, 0, 255) + purple = ( 38, 10, 42) + + dark_red = ( 32, 0, 0) + dark_orange = ( 20, 6, 0) + dark_yellow = ( 64, 40, 0) + dark_green = ( 0, 32, 0) + dark_blue = ( 0, 0, 32) + dark_purple = ( 8, 3, 10) + + pale_red = (255, 64, 64) + pale_orange = ( 78, 25, 10) + pale_yellow = (255, 160, 10) + pale_green = ( 64, 255, 25) + pale_blue = ( 70, 70, 255) + pale_purple = ( 42, 20, 42) + + black = ( 0, 0, 0) + white = (255, 255, 255) + + pink = pale_red + peach = pale_orange + teal = ( 0, 85, 35) + magenta = (255, 0, 255) + brown = ( 6, 2, 0) diff --git a/gradients.py b/gradients.py new file mode 100644 index 0000000..835b241 --- /dev/null +++ b/gradients.py @@ -0,0 +1,153 @@ +from random import random + +from colors import Colors +import colors + + +def clamp8(v): + return 255 if v >= 255 else 0 if v <= 0 else round(v) + +class Gradient: + def __init__(self, rgb_colors): + self.colors = rgb_colors[::-1] + + def at(self, x): + i_left = int(x * len(self.colors)) + i_right = i_left + 1 if i_left < len(self.colors) - 1 else 0 + ratio = (x * len(self.colors)) - i_left + return tuple(clamp8(left + ratio * (right - left)) for (left, right) in zip(self.colors[i_left], self.colors[i_right])) + + def from_dict(dict): + if 'preset' in dict: + return getattr(Gradients, dict['preset']) + return Gradient([getattr(Colors, color) if isinstance(color, str) else color for color in dict['colors']]) + + +rainbow = Gradient([ + Colors.red, + Colors.orange, + Colors.yellow, + Colors.green, + Colors.blue, + Colors.purple +]) + + +class Gradients: + # Gradient Presets + + # Fun + + rainbow = rainbow + + dark_rainbow = Gradient([ + Colors.dark_red, + Colors.dark_orange, + Colors.dark_yellow, + Colors.dark_green, + Colors.dark_blue, + Colors.dark_purple + ]) + + pale_rainbow = Gradient([ + Colors.pale_red, + Colors.pale_orange, + Colors.pale_yellow, + Colors.pale_green, + Colors.pale_blue, + Colors.pale_purple + ]) + + random = Gradient([Colors.black if i % 2 else rainbow.at(random()) for i in range(0, 100)]) + + pusheen = Gradient([ + Colors.teal, + Colors.pink + ]) + + peacock = Gradient([ + Colors.teal, + Colors.black, + Colors.purple, + Colors.blue, + ]) + + + # Sports + + kraken = Gradient([ + ( 0, 1, 3), # Navy blue + ( 24, 60, 48), # Light blue + ( 0, 1, 3), # Navy blue (again) + ( 10, 25, 25), # Blue (greenish) + ]) + + + # Holiday + + love = Gradient([ + Colors.pink, + Colors.black, + Colors.red, + Colors.black + ]) + + irish = Gradient([ + Colors.dark_green, + Colors.black, + Colors.green, + Colors.black, + ]) + + bunny = Gradient([ + Colors.pink, + Colors.pale_purple, + Colors.black, + Colors.pale_yellow, + Colors.pale_green, + Colors.black, + ]) + + patriot = Gradient([ + Colors.red, + Colors.black, + Colors.black, + Colors.white, + Colors.black, + Colors.black, + Colors.blue, + Colors.black, + Colors.black, + ]) + + pumpkin = Gradient([ + Colors.orange, + Colors.black, + Colors.purple, + Colors.black, + ]) + + turkey = Gradient([ + Colors.orange, + Colors.black, + Colors.dark_green, + Colors.black, + Colors.brown, + Colors.black, + Colors.dark_yellow, + Colors.black, + ]) + + mazel = Gradient([ + Colors.blue, + Colors.black, + Colors.white, + Colors.black, + ]) + + santa = Gradient([ + Colors.red, + Colors.white, + Colors.green, + Colors.black, + ]) diff --git a/pattern.py b/pattern.py new file mode 100644 index 0000000..f799b12 --- /dev/null +++ b/pattern.py @@ -0,0 +1,33 @@ +import time + +from gradients import Gradient + +class Pattern: + def __init__(self, gradient, cycle_length, cycle_time_s=None, reverse=False, march=False): + self.gradient = gradient + self.cycle_length = cycle_length + self.cycle_time_s = cycle_time_s or (cycle_length / 2 if march else cycle_length) + self.reverse = reverse + self.march = march + self.last = None + self.offset = 0 + + def step(self, length): + now = time.time() + duration = 0 if self.last is None else now - self.last + self.last = now + + offset_delta = self.cycle_length * duration / self.cycle_time_s + self.offset = (self.offset + (offset_delta if self.reverse else -offset_delta)) % self.cycle_length + + offset = self.offset if not self.march else int(self.offset) + return (self.gradient.at(((i + offset) / self.cycle_length + 1) % 1) for i in range(0, length)) + + def from_dict(dict): + return Pattern( + Gradient.from_dict(dict['gradient']), + dict['cycle_length'], + cycle_time_s = dict.get('cycle_time_s'), + reverse = not not dict.get('reverse', False), + march = not not dict.get('march', False) + ) diff --git a/pixels.py b/pixels.py new file mode 100644 index 0000000..f388cad --- /dev/null +++ b/pixels.py @@ -0,0 +1,18 @@ +from colors import Colors + + +def termcolor(r, g, b): + return f"\x1b[48;2;{r};{g};{b}m" + +class TermLEDs: + def __init__(self, length): + self.leds = [Colors.black] * length + + def __getitem__(self, key): + return self.leds[key] + + def __setitem__(self, key, value): + self.leds[key] = value + + def show(self): + print("\r" + " ".join(termcolor(*led) for led in self.leds) + " \x1b[0m", end="") \ No newline at end of file