From efb7c20af539d680e11fcd710b9d6cb3f6eb2053 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 24 Jul 2022 11:43:00 +0200 Subject: [PATCH] Refactoring - Animation in it's own class - JS to sync state across browsers --- animation.py | 107 +++++++++++++++++++++++++++++ dmx.py | 176 +++++++++++++++++++----------------------------- foobaz.py | 56 ++++++++------- static/main.css | 54 ++++++++++++++- static/main.js | 75 ++++++++++++++++++--- views/index.tpl | 44 +++++++----- 6 files changed, 352 insertions(+), 160 deletions(-) create mode 100644 animation.py diff --git a/animation.py b/animation.py new file mode 100644 index 0000000..37356fc --- /dev/null +++ b/animation.py @@ -0,0 +1,107 @@ +import colorsys +from time import time + + +class Animation: + def __init__(self): + pass + + def __str__(self): + return f"{type(self).__name__}" + + def update(self, index, count): + return (0, 0 ,0) + + def name(self): + return "None" + + +class Off(Animation): + def __init__(self): + super(Off, self).__init__() + + def name(self): + return "Off" + + +class Steady(Animation): + def __init__(self, color): + super(Steady, self).__init__() + (r, g, b) = color + self.r = r + self.g = g + self.b = b + + def update(self, index, count): + return (self.r, self.g, self.b) + + def __str__(self): + return f"{type(self).__name__}({self.r}, {self.g}, {self.b})" + + def name(self): + return "steady" + + +class FadeTo(Steady): + def __init__(self, color, t=2.0): + super(FadeTo, self).__init__(color) + self.t = t + self.start = time() + + def update(self, index, count): + h = (time() - self.start) / self.t + h = min(h, 1.0) + return (int(self.r * h), int(self.g * h), int(self.b * h)) + + def __str__(self): + return f"{type(self).__name__}({self.r}, {self.g}, {self.b}, {self.t:.2f})" + + def name(self): + return "fadeTo" + + +class RotatingRainbow(Animation): + def __init__(self, looptime=10.0): + super(RotatingRainbow, self).__init__() + self.looptime = looptime + pass + + def update(self, index, count): + """ + One full round takes self.looptime seconds, each RGB is offset in a circle + :param index: + :param count: + :return: + """ + hue = (time() / self.looptime + (index + 0.0) / count) % 1.0 + rgb = hsv_to_rgb(hue, 1, 1) + return rgb + + def __str__(self): + return f"{type(self).__name__}" + + def name(self): + return "rainbow" + + +class Chase(Steady): + def __init__(self, color, looptime=1.0): + super(Chase, self).__init__(color) + self.looptime = looptime + + def update(self, index, count): + angle = (time() / self.looptime + (index + 0.0) / count) % 1.0 + l = 1 - min(abs(angle - 1 / count) * .9, 1.0 / count) * count + # print(f"f({index}, {angle:.2f}) -> {l:.2f}") + return (int(self.r * l), int(self.g * l), int(self.b * l)) + + def __str__(self): + return f"{type(self).__name__}({self.r}, {self.g}, {self.b}, {self.looptime:.2f})" + + def name(self): + return "chase" + + +def hsv_to_rgb(h, s, v): + (r, g, b) = colorsys.hsv_to_rgb(h, s, v) + return [int(r * 255), int(g * 255), int(b * 255)] \ No newline at end of file diff --git a/dmx.py b/dmx.py index 758fbce..ced57e6 100644 --- a/dmx.py +++ b/dmx.py @@ -1,14 +1,11 @@ -import colorsys import socket import struct import sys from threading import Thread -from time import sleep, time +from time import sleep +from typing import Union - -def hsv_to_rgb(h, s, v): - (r, g, b) = colorsys.hsv_to_rgb(h, s, v) - return [int(r * 255), int(g * 255), int(b * 255)] +from animation import Animation, Off, Steady, FadeTo, RotatingRainbow, Chase def ledlog(value): @@ -50,98 +47,36 @@ class StairvilleLedPar56(RGB): dmx.set(self.slot + 6, 255) -class Steady: - def __init__(self, r, g, b): - self.r = r - self.g = g - self.b = b - - def update(self, index, count): - return (self.r, self.g, self.b) - - def __str__(self): - return f"{type(self).__name__}({self.r}, {self.g}, {self.b})" - - -class FadeTo(Steady): - def __init__(self, r, g, b, t=2.0): - super(FadeTo, self).__init__(r, g, b) - self.t = t - self.start = time() - - def update(self, index, count): - h = (time() - self.start) / self.t - h = min(h, 1.0) - return (int(self.r * h), int(self.g * h), int(self.b * h)) - - def __str__(self): - return f"{type(self).__name__}({self.r}, {self.g}, {self.b}, {self.t:.2f})" - - -class RotatingRainbow: - def __init__(self, looptime=10.0): - self.looptime = looptime - pass - - def update(self, index, count): - """ - One full round takes self.looptime seconds, each RGB is offset in a circle - :param index: - :param count: - :return: - """ - hue = (time() / self.looptime + (index + 0.0) / count) % 1.0 - rgb = hsv_to_rgb(hue, 1, 1) - return rgb - - def __str__(self): - return f"{type(self).__name__}" - - -class Chase(Steady): - def __init__(self, r, g, b, looptime=1.0): - super(Chase, self).__init__(r, g, b) - self.looptime = looptime - - def update(self, index, count): - angle = (time() / self.looptime + (index + 0.0) / count) % 1.0 - l = 1 - min(abs(angle - 2.0 / count), 1.0 / count) * count - # print(f"f({index}, {angle:.2f}) -> {l:.2f}") - return (int(self.r * l), int(self.g * l), int(self.b * l)) - - def __str__(self): - return f"{type(self).__name__}({self.r}, {self.g}, {self.b}, {self.looptime:.2f})" - - class DMX: def __init__(self, host, port=0x1936, universe=1, maxchan=512): - self.host = host - self.port = port - self.universe = universe - self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP - self.socket.setblocking(False) - self.data = bytearray(maxchan) + self._host = host + self._port = port + self._universe = universe + self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP + self._socket.setblocking(False) + self._data = bytearray(maxchan) packet = bytearray() packet.extend(map(ord, "Art-Net")) packet.append(0x00) # Null terminate Art-Net packet.extend([0x00, 0x50]) # Opcode ArtDMX 0x5000 (Little endian) packet.extend([0x00, 0x0e]) # Protocol version 14 - self.header = packet - self.sequence = 1 - self.animation = FadeTo(255, 255, 255) - self.rgbs = [] - self.thread = None - self.updating = False + self._header = packet + self._sequence = 1 + self._color = (0, 0, 0) + self._animation = FadeTo((255, 255, 255)) + self._rgbs = [] + self._thread = None + self._updating = False def start(self): - if self.thread and self.thread.is_alive(): + if self._thread and self._thread.is_alive(): return - self.thread = Thread(daemon=True, target=self.background) - self.updating = True - self.thread.start() + self._thread = Thread(daemon=True, target=self.background) + self._updating = True + self._thread.start() def background(self): - while self.updating: + while self._updating: self.update() # print("updating") # print(self.data) @@ -149,38 +84,67 @@ class DMX: sleep(1.0 / 30) def update(self): - if not self.animation: + if not self._animation: return - for i in range(0, len(self.rgbs)): - self.rgbs[i].rgb(self.animation.update(i, len(self.rgbs))) + for i in range(0, len(self._rgbs)): + self._rgbs[i].rgb(self._animation.update(i, len(self._rgbs))) - packet = self.header[:] - packet.append(self.sequence) # Sequence, + packet = self._header[:] + packet.append(self._sequence) # Sequence, packet.append(0x00) # Physical - packet.append(self.universe & 0xFF) # Universe LowByte - packet.append(self.universe >> 8 & 0xFF) # Universe HighByte + packet.append(self._universe & 0xFF) # Universe LowByte + packet.append(self._universe >> 8 & 0xFF) # Universe HighByte - packet.extend(struct.pack('>h', len(self.data))) # Pack the number of channels Big endian - packet.extend(self.data) - self.socket.sendto(packet, (self.host, self.port)) + packet.extend(struct.pack('>h', len(self._data))) # Pack the number of channels Big endian + packet.extend(self._data) + self._socket.sendto(packet, (self._host, self._port)) # print(f"sent {len(packet)} bytes, {threading.get_native_id()}") - self.sequence += 1 - if self.sequence > 255: - self.sequence = 1 + self._sequence += 1 + if self._sequence > 255: + self._sequence = 1 def set(self, slot, value): - self.data[slot - 1] = value + self._data[slot - 1] = value - def setAnimation(self, animation): - if not animation: - self.updating = False - self.thread.join() + @property + def animation(self) -> str: + return self._animation.name() + + @animation.setter + def animation(self, animation: Union[Animation, str]): + if isinstance(animation, str): + if animation == "off": + animation = Off() + elif animation == "chase": + animation = Chase(self._color) + elif animation == "fade": + animation = FadeTo(self._color) + elif animation == "rainbow": + animation = RotatingRainbow() + elif animation == "steady": + animation = Steady(self._color) + else: + raise ValueError(f"No such animation {animation}") + self._animation = animation + if isinstance(animation, Off): + self._updating = False + if self._thread and self._thread.is_alive(): + self._thread.join() # one frame black - self.animation = Steady(0, 0, 0) + self._animation = Steady((0, 0, 0)) self.update() else: - self.animation = animation self.start() print(f"Animation: {animation}", file=sys.stderr) + + @property + def color(self): + return self._color + + @color.setter + def color(self, color): + if self._color != color: + self._color = color + self.animation = self.animation diff --git a/foobaz.py b/foobaz.py index 0f86e4c..b971bff 100644 --- a/foobaz.py +++ b/foobaz.py @@ -2,10 +2,12 @@ import argparse import sys +from typing import Tuple -from bottle import route, run, static_file, view +from bottle import post, request, route, run, static_file, view -from dmx import DMX, Bar252, StairvilleLedPar56, Steady, RotatingRainbow, REDSpot18RGB, Chase, FadeTo +from animation import Off +from dmx import DMX, Bar252, StairvilleLedPar56, REDSpot18RGB @route('/') @@ -17,23 +19,28 @@ def index(): def static(path): return static_file(path, root='static') -@route('/api/state/') -def update(state): - if state == "off": - dmx.setAnimation(None) - elif state == "white": - dmx.setAnimation(Steady(255, 255, 255)) - elif state == "red": - dmx.setAnimation(Steady(255, 0, 0)) - elif state == "blue": - dmx.setAnimation(Steady(0, 0, 255)) - elif state == "rainbow": - dmx.setAnimation(RotatingRainbow()) - elif state == "chase-blue": - dmx.setAnimation(Chase(0, 0, 255)) - dmx.start() - return {'result': 'ok'} +@route('/api/state') +def state(): + return { + 'animation': dmx.animation, + 'color': rgb_to_hex(dmx.color) + } +@post('/api/state') +def update(): + json = request.json + print(json) + dmx.animation = json["animation"] + dmx.color = hex_to_rgb(json["color"]) + return state() + + +def hex_to_rgb(hex: str) -> tuple[int, ...]: + return tuple(int(hex[i:i+2], 16) for i in (1, 3, 5)) + + +def rgb_to_hex(color: Tuple[int, int, int]) -> str: + return f"#{color[0]:02X}{color[1]:02X}{color[2]:02X}" def main(args): global dmx @@ -47,17 +54,17 @@ def main(args): print(f"Starting DMX via Art-Net to {artnet}", file=sys.stderr) # dmx = DMX("10.31.242.35") - dmx = DMX(artnet, maxchan=64) + dmx = DMX(artnet, maxchan=128) if args.room == "shop": - dmx.rgbs = [ + dmx._rgbs = [ REDSpot18RGB(dmx, 1), REDSpot18RGB(dmx, 5), REDSpot18RGB(dmx, 9), REDSpot18RGB(dmx, 13), ] elif args.room == "big": - dmx.rgbs = [ + dmx._rgbs = [ StairvilleLedPar56(dmx, 1), StairvilleLedPar56(dmx, 8), StairvilleLedPar56(dmx, 15), @@ -70,10 +77,11 @@ def main(args): else: print(f"Unknown room {args.room}", file=sys.stderr) sys.exit(64) - dmx.setAnimation(FadeTo(128, 128, 128)) - dmx.start() + dmx.animation = Off() + dmx.color = (128, 128, 128) + dmx.animation = "fade" - run(host='0.0.0.0', port=8080, reloader=False) + run(host='0.0.0.0', port=8080, reloader=False, debug=True) if __name__ == '__main__': main(sys.argv[1:]) diff --git a/static/main.css b/static/main.css index e3c32fb..1489660 100644 --- a/static/main.css +++ b/static/main.css @@ -1,4 +1,56 @@ body { background: black; color: white; -} \ No newline at end of file +} + +.controls { + display: flex; + width: 40em; +} + +fieldset { + flex: auto; +} + +.buttons { + width: 30em; +} + +input.button_color { + width: 6em; +} + +.button_color_white { + background-color: #ffffff; + color: #000000; +} + +.button_color_red { + background-color: #ff0000; + color: #000000; +} + +.button_color_yellow { + background-color: #ffff00; + color: #000000; +} + +.button_color_green { + background-color: #00ff00; + color: #000000; +} + +.button_color_cyan { + background-color: #00ffff; + color: #000000; +} + +.button_color_blue { + background-color: #0000ff; + color: #ffffff; +} + +.button_color_magenta { + background-color: #ff00ff; + color: #000000; +} diff --git a/static/main.js b/static/main.js index 2fbbf47..be7f63e 100644 --- a/static/main.js +++ b/static/main.js @@ -1,11 +1,64 @@ -function setstate(state) { - var request = new XMLHttpRequest(); - request.onreadystatechange = function() { - if (this.readyState == 4 && this.status == 200) { - // process state here - console.log("state changed to " + state); - } - }; - request.open("GET", "/api/state/" + state, true); - request.send(); -} \ No newline at end of file +(function() { + let state = { + 'animation': 'off', + 'color': "#000000" + } + + let setState = function() { + let request = new XMLHttpRequest(); + request.onreadystatechange = function() { + if (this.readyState == 4 && this.status == 200) { + // process state here + console.log("state changed to " + JSON.stringify(state)); + } + }; + request.open("POST", "/api/state", true); + request.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + request.send(JSON.stringify(state)); + } + + let animation_radios = document.getElementsByClassName("js_animation") + for (let el of animation_radios) { + el.addEventListener("click", function(ev) { + state.animation = el.value; + setState(); + }) + console.log("attached to " + el.value) + } + + let color = document.getElementById("color") + color.addEventListener("change", function(ev) { + state.color = color.value; + setState(); + }) + for (let el of document.getElementsByClassName("js_color")) { + el.addEventListener("click", function(ev) { + color.value = el.dataset.color; + state.color = color.value; + setState(); + }) + console.log("attached to " + el.value) + } + + window.setInterval(function() { + // console.log("Updating state") + let request = new XMLHttpRequest(); + request.onreadystatechange = function(response) { + if (this.readyState == 4 && this.status == 200) { + var json = JSON.parse(this.responseText) + // console.log("state is " + JSON.stringify(json)); + color.value = json.color; + state.color = json.color; + if (state.animation != json.animation) { + console.log("Animation has been updated to " + json.animation) + state.animation = json.animation; + for (let el of animation_radios) { + el.checked = el.value == state.animation; + } + } + } + }; + request.open("GET", "/api/state", true); + request.send(); + }, 500); +})(); diff --git a/views/index.tpl b/views/index.tpl index 36b5f1a..93ea8da 100644 --- a/views/index.tpl +++ b/views/index.tpl @@ -2,36 +2,44 @@ Foo Baz DMX - +

Foo Baz DMX

-
-
- Lights: +
+
+ Animation
- - + +
- - + +
- - + +
- - + + +
+
+
+ Color +
+ +
- - -
-
- - + + + + + + +