Refactoring

- Animation in it's own class
- JS to sync state across browsers
This commit is contained in:
Stefan Bethke 2022-07-24 11:43:00 +02:00
parent f4f5458209
commit efb7c20af5
6 changed files with 352 additions and 160 deletions

107
animation.py Normal file
View file

@ -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)]

176
dmx.py
View file

@ -1,14 +1,11 @@
import colorsys
import socket import socket
import struct import struct
import sys import sys
from threading import Thread from threading import Thread
from time import sleep, time from time import sleep
from typing import Union
from animation import Animation, Off, Steady, FadeTo, RotatingRainbow, 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)]
def ledlog(value): def ledlog(value):
@ -50,98 +47,36 @@ class StairvilleLedPar56(RGB):
dmx.set(self.slot + 6, 255) 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: class DMX:
def __init__(self, host, port=0x1936, universe=1, maxchan=512): def __init__(self, host, port=0x1936, universe=1, maxchan=512):
self.host = host self._host = host
self.port = port self._port = port
self.universe = universe self._universe = universe
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP
self.socket.setblocking(False) self._socket.setblocking(False)
self.data = bytearray(maxchan) self._data = bytearray(maxchan)
packet = bytearray() packet = bytearray()
packet.extend(map(ord, "Art-Net")) packet.extend(map(ord, "Art-Net"))
packet.append(0x00) # Null terminate Art-Net packet.append(0x00) # Null terminate Art-Net
packet.extend([0x00, 0x50]) # Opcode ArtDMX 0x5000 (Little endian) packet.extend([0x00, 0x50]) # Opcode ArtDMX 0x5000 (Little endian)
packet.extend([0x00, 0x0e]) # Protocol version 14 packet.extend([0x00, 0x0e]) # Protocol version 14
self.header = packet self._header = packet
self.sequence = 1 self._sequence = 1
self.animation = FadeTo(255, 255, 255) self._color = (0, 0, 0)
self.rgbs = [] self._animation = FadeTo((255, 255, 255))
self.thread = None self._rgbs = []
self.updating = False self._thread = None
self._updating = False
def start(self): def start(self):
if self.thread and self.thread.is_alive(): if self._thread and self._thread.is_alive():
return return
self.thread = Thread(daemon=True, target=self.background) self._thread = Thread(daemon=True, target=self.background)
self.updating = True self._updating = True
self.thread.start() self._thread.start()
def background(self): def background(self):
while self.updating: while self._updating:
self.update() self.update()
# print("updating") # print("updating")
# print(self.data) # print(self.data)
@ -149,38 +84,67 @@ class DMX:
sleep(1.0 / 30) sleep(1.0 / 30)
def update(self): def update(self):
if not self.animation: if not self._animation:
return return
for i in range(0, len(self.rgbs)): for i in range(0, len(self._rgbs)):
self.rgbs[i].rgb(self.animation.update(i, len(self.rgbs))) self._rgbs[i].rgb(self._animation.update(i, len(self._rgbs)))
packet = self.header[:] packet = self._header[:]
packet.append(self.sequence) # Sequence, packet.append(self._sequence) # Sequence,
packet.append(0x00) # Physical packet.append(0x00) # Physical
packet.append(self.universe & 0xFF) # Universe LowByte packet.append(self._universe & 0xFF) # Universe LowByte
packet.append(self.universe >> 8 & 0xFF) # Universe HighByte 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(struct.pack('>h', len(self._data))) # Pack the number of channels Big endian
packet.extend(self.data) packet.extend(self._data)
self.socket.sendto(packet, (self.host, self.port)) self._socket.sendto(packet, (self._host, self._port))
# print(f"sent {len(packet)} bytes, {threading.get_native_id()}") # print(f"sent {len(packet)} bytes, {threading.get_native_id()}")
self.sequence += 1 self._sequence += 1
if self.sequence > 255: if self._sequence > 255:
self.sequence = 1 self._sequence = 1
def set(self, slot, value): def set(self, slot, value):
self.data[slot - 1] = value self._data[slot - 1] = value
def setAnimation(self, animation): @property
if not animation: def animation(self) -> str:
self.updating = False return self._animation.name()
self.thread.join()
@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 # one frame black
self.animation = Steady(0, 0, 0) self._animation = Steady((0, 0, 0))
self.update() self.update()
else: else:
self.animation = animation
self.start() self.start()
print(f"Animation: {animation}", file=sys.stderr) 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

View file

@ -2,10 +2,12 @@
import argparse import argparse
import sys 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('/') @route('/')
@ -17,23 +19,28 @@ def index():
def static(path): def static(path):
return static_file(path, root='static') return static_file(path, root='static')
@route('/api/state/<state>') @route('/api/state')
def update(state): def state():
if state == "off": return {
dmx.setAnimation(None) 'animation': dmx.animation,
elif state == "white": 'color': rgb_to_hex(dmx.color)
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'}
@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): def main(args):
global dmx global dmx
@ -47,17 +54,17 @@ def main(args):
print(f"Starting DMX via Art-Net to {artnet}", file=sys.stderr) print(f"Starting DMX via Art-Net to {artnet}", file=sys.stderr)
# dmx = DMX("10.31.242.35") # dmx = DMX("10.31.242.35")
dmx = DMX(artnet, maxchan=64) dmx = DMX(artnet, maxchan=128)
if args.room == "shop": if args.room == "shop":
dmx.rgbs = [ dmx._rgbs = [
REDSpot18RGB(dmx, 1), REDSpot18RGB(dmx, 1),
REDSpot18RGB(dmx, 5), REDSpot18RGB(dmx, 5),
REDSpot18RGB(dmx, 9), REDSpot18RGB(dmx, 9),
REDSpot18RGB(dmx, 13), REDSpot18RGB(dmx, 13),
] ]
elif args.room == "big": elif args.room == "big":
dmx.rgbs = [ dmx._rgbs = [
StairvilleLedPar56(dmx, 1), StairvilleLedPar56(dmx, 1),
StairvilleLedPar56(dmx, 8), StairvilleLedPar56(dmx, 8),
StairvilleLedPar56(dmx, 15), StairvilleLedPar56(dmx, 15),
@ -70,10 +77,11 @@ def main(args):
else: else:
print(f"Unknown room {args.room}", file=sys.stderr) print(f"Unknown room {args.room}", file=sys.stderr)
sys.exit(64) sys.exit(64)
dmx.setAnimation(FadeTo(128, 128, 128)) dmx.animation = Off()
dmx.start() 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__': if __name__ == '__main__':
main(sys.argv[1:]) main(sys.argv[1:])

View file

@ -1,4 +1,56 @@
body { body {
background: black; background: black;
color: white; color: white;
} }
.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;
}

View file

@ -1,11 +1,64 @@
function setstate(state) { (function() {
var request = new XMLHttpRequest(); let state = {
request.onreadystatechange = function() { 'animation': 'off',
if (this.readyState == 4 && this.status == 200) { 'color': "#000000"
// process state here }
console.log("state changed to " + state);
} let setState = function() {
}; let request = new XMLHttpRequest();
request.open("GET", "/api/state/" + state, true); request.onreadystatechange = function() {
request.send(); 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);
})();

View file

@ -2,36 +2,44 @@
<head> <head>
<title>Foo Baz DMX</title> <title>Foo Baz DMX</title>
<link rel="stylesheet" href="static/main.css"> <link rel="stylesheet" href="static/main.css">
<script src="static/main.js"></script> <script src="static/main.js" defer></script>
</head> </head>
<body> <body>
<h1>Foo Baz DMX</h1> <h1>Foo Baz DMX</h1>
<div class="buttons"> <div class="controls">
<fieldset> <fieldset class="buttons">
<legend>Lights:</legend> <legend>Animation</legend>
<div> <div>
<input type="radio" name="state" id="state_off" value="off" onclick="setstate('off')"/> <input type="radio" class="js_animation" name="state" id="animation_off" value="off"/>
<label for="state_off">Off</label> <label for="animation_off">Off</label>
</div> </div>
<div> <div>
<input type="radio" name="state" id="state_white" value="white" onclick="setstate('white')"/> <input type="radio" class="js_animation" name="state" id="animation_white" value="steady"/>
<label for="state_white">White</label> <label for="animation_white">Steady</label>
</div> </div>
<div> <div>
<input type="radio" name="state" id="state_red" value="red" onclick="setstate('red')"/> <input type="radio" class="js_animation" name="state" id="animation_chase" value="chase"/>
<label for="state_red">Red</label> <label for="animation_chase">Chase</label>
</div> </div>
<div> <div>
<input type="radio" name="state" id="state_blue" value="red" onclick="setstate('blue')"/> <input type="radio" class="js_animation" name="state" id="animation_rainbow" value="rainbow"/>
<label for="state_blue">Blue</label> <label for="animation_rainbow">Rainbow</label>
</div>
</fieldset>
<fieldset class="colors">
<legend>Color</legend>
<div>
<input type="color" name="color" id="color" value="#0000ff"/>
<label for="color">Color</label>
</div> </div>
<div> <div>
<input type="radio" name="state" id="state_rainbow" value="rainbow" onclick="setstate('rainbow')"/> <input type="button" class="button_color button_color_white js_color" name="white" value="White" data-color="#ffffff"/>
<label for="state_rainbow">Rainbow</label> <input type="button" class="button_color button_color_red js_color" name="red" value="Red" data-color="#ff0000"/>
</div> <input type="button" class="button_color button_color_yellow js_color" name="yellow" value="Yellow" data-color="#ffff00"/>
<div> <input type="button" class="button_color button_color_green js_color" name="green" value="Green" data-color="#00ff00"/>
<input type="radio" name="state" id="state_chase_blue" value="chase-blue" onclick="setstate('chase-blue')"/> <input type="button" class="button_color button_color_cyan js_color" name="cyan" value="Cyan" data-color="#00ffff"/>
<label for="state_chase_blue">Blue Chase</label> <input type="button" class="button_color button_color_blue js_color" name="blue" value="Blue" data-color="#0000ff"/>
<input type="button" class="button_color button_color_magenta js_color" name="magenta" value="Magenta" data-color="#ff00ff"/>
</div> </div>
</fieldset> </fieldset>
</div> </div>