update to support multiple universes per worker

This commit is contained in:
Franzi 2024-08-17 12:19:01 +02:00
parent b4e6851e32
commit 5d7f48d813
Signed by: kunsi
GPG key ID: 12E3D2136B818350
11 changed files with 156 additions and 189 deletions

54
conf.py
View file

@ -7,7 +7,7 @@ except ImportError:
import logging import logging
from sys import exit from sys import exit
LOG = logging.getLogger('Config') LOG = logging.getLogger("Config")
class ConfigWrapper: class ConfigWrapper:
@ -19,45 +19,43 @@ class ConfigWrapper:
def load_and_validate_config(path): def load_and_validate_config(path):
try: try:
with open(path, 'r') as cf: with open(path, "r") as cf:
config = toml_load(cf.read()) config = toml_load(cf.read())
except Exception as e: except Exception as e:
LOG.exception(f'{path} is no valid toml configuration file') LOG.exception(f"{path} is no valid toml configuration file")
exit(1) exit(1)
if not config.get('mqtt', {}).get('host'): if not config.get("mqtt", {}).get("host"):
LOG.error( LOG.error(
f'configuration option "mqtt" "host" is missing in config, but required to exist' f'configuration option "mqtt" "host" is missing in config, but required to exist'
) )
exit(1) exit(1)
universes = {}
used_universes = {}
for universe, uconfig in config.get("universes", {}).items():
universes[universe] = ConfigWrapper(
alert_brightness=max(int(uconfig.get("alert_brightness", 255)), 10),
filters=uconfig.get("filters", []),
lights=uconfig.get("lights", {}),
multicast=bool(uconfig.get("multicast", False) is True),
rainbow_brightness=int(uconfig.get("rainbow_brightness", 150)),
target=uconfig.get("target", "127.0.0.1"),
universe=int(uconfig.get("universe", 1)),
)
if universes[universe].universe in used_universes:
LOG.warning(
f"universe {universes[universe].universe} used by both {universe} and {used_universes[universes[universe]].universe}"
)
conf = ConfigWrapper( conf = ConfigWrapper(
mqtt=ConfigWrapper( mqtt=ConfigWrapper(
host=config['mqtt']['host'], host=config["mqtt"]["host"],
user=config['mqtt'].get('user'), user=config["mqtt"].get("user"),
password=config['mqtt'].get('password'), password=config["mqtt"].get("password"),
topic=config['mqtt'].get('topic', '/voc/alert'), topic=config["mqtt"].get("topic", "/voc/alert"),
), ),
sacn=ConfigWrapper( universes=universes,
multicast=bool(config.get('sacn', {}).get('multicast', False) is True),
target=config.get('sacn', {}).get('target', '127.0.0.1'),
universe=int(config.get('sacn', {}).get('universe', 1)),
),
alerts=ConfigWrapper(
brightness=max(int(config.get('alerts', {}).get('brightness', 255)), 10),
filters=sorted(config.get('alerts', {}).get('filters', set())),
),
rainbow=ConfigWrapper(
enable=bool(config.get('rainbow', {}).get('enable', True) is True),
intensity=max(int(config.get('rainbow', {}).get('intensity', 100)), 10),
brightness=max(int(config.get('rainbow', {}).get('brightness', 150)), 10),
speed=int(config.get('rainbow', {}).get('speed', 25)),
),
lights=config.get('lights', {}),
) )
if conf.alerts.brightness < conf.rainbow.brightness:
LOG.error('alerts brightness must be equal or above rainbow brightness')
exit(1)
return conf return conf

View file

@ -11,8 +11,8 @@ user = ""
password = "" password = ""
topic = "/voc/alert" topic = "/voc/alert"
# "demo" can be anything you like
[sacn] [universes.demo]
# Wether to enable sACN multicast. Default is off. # Wether to enable sACN multicast. Default is off.
multicast = false multicast = false
@ -23,42 +23,24 @@ target = "127.0.0.1"
# which universe to address # which universe to address
universe = 1 universe = 1
[alerts]
# This specifies the maximum DMX dimmer value that's sent to your lights
# when alerts occur. This must be atleast the same or more as the
# rainbow brightness (see below).
brightness = 255
# Filter by specific components. If this list is non-empty, the message # Filter by specific components. If this list is non-empty, the message
# will get shown if atleast one of these filters match. The filters are # will get shown if atleast one of these filters match. The filters are
# applied by using re.search() on the component part of the message. # applied by using re.search() on the component part of the message.
filters = [] filters = []
# This specifies the maximum DMX dimmer value that's sent to your lights
[rainbow] # when alerts occur. This must be atleast the same or more as the
# Wether to enable the rainbow 'no alerts' loop. If false, all other # rainbow brightness (see below).
# options in here will be ignored. alert_brightness = 255
enable = true
# This directly controls the 'value' part of the HSV equation in the
# rainbow 'no alerts' break loop. Value must be between 10% and 100%.
intensity = 100
# DMX dimmer value when displaying the rainbow pattern. Must be equal # DMX dimmer value when displaying the rainbow pattern. Must be equal
# or below the generic 'brightness' value above. # or below the generic 'brightness' value above. Set to 0 to disable
brightness = 150 # the rainbow
rainbow_brightness = 150
# Speed of the rainbow pattern. This is specified as "miliseconds
# between rotating the hue wheel by 1 degree". Minimum value is 25,
# because sACN does not support more than 40 fps. Setting it any lower
# will disable the animation altogehter, resulting in static lights.
speed = 25
# This contains the DMX start addresses of your light fixtures. You # This contains the DMX start addresses of your light fixtures. You
# have to add atleast one fixture for the software to work. # have to add atleast one fixture for the software to work.
[lights] [universes.demo.lights]
ignition_wal_l710 = [] ignition_wal_l710 = []
stairville_par_56 = [] stairville_par_56 = []
tsss_led_par_rgbw = [] tsss_led_par_rgbw = []

View file

@ -5,14 +5,20 @@ from time import sleep
from sacn import sACNsender from sacn import sACNsender
LOG = logging.getLogger('DMXQueue') import lights
class DMXQueue: class DMXQueue:
def __init__(self, config, queue, lights): def __init__(self, config, universe, queue):
self.config = config self.log = logging.getLogger(f"DMXQueue {universe}")
self.config = config.universes[universe]
self.queue = queue self.queue = queue
self.lights = lights
self.lights = []
for classname, addrs in self.config.lights.items():
cls = getattr(lights, classname)
for addr in addrs:
self.lights.append(cls(addr))
self.worker = Thread(target=self._worker) self.worker = Thread(target=self._worker)
self.worker_should_be_running = False self.worker_should_be_running = False
@ -23,11 +29,11 @@ class DMXQueue:
def start(self): def start(self):
self.sacn.start() self.sacn.start()
self.sacn.activate_output(self.config.sacn.universe) self.sacn.activate_output(self.config.universe)
self.sacn[self.config.sacn.universe].multicast = self.config.sacn.multicast self.sacn[self.config.universe].multicast = self.config.multicast
if not self.config.sacn.multicast: if not self.config.multicast:
self.sacn[self.config.sacn.universe].destination = self.config.sacn.target self.sacn[self.config.universe].destination = self.config.target
self.dmx_data = 512 * [0] self.dmx_data = 512 * [0]
@ -35,14 +41,14 @@ class DMXQueue:
self.worker.start() self.worker.start()
def stop(self): def stop(self):
LOG.info('Waiting for worker to terminate ...') self.log.info("Waiting for worker to terminate ...")
self.worker_should_be_running = False self.worker_should_be_running = False
self.worker.join() self.worker.join()
self.sacn.stop() self.sacn.stop()
def _dmx(self, addr, data): def _dmx(self, addr, data):
self.dmx_data[addr - 1] = data self.dmx_data[addr - 1] = data
self.sacn[self.config.sacn.universe].dmx_data = tuple(self.dmx_data) self.sacn[self.config.universe].dmx_data = tuple(self.dmx_data)
def _bulk(self, start_addr, values): def _bulk(self, start_addr, values):
for idx, value in enumerate(values): for idx, value in enumerate(values):
@ -58,29 +64,39 @@ class DMXQueue:
self._bulk(*light.dump()) self._bulk(*light.dump())
def _worker(self): def _worker(self):
LOG.info('Worker startup') self.log.info("Worker startup")
rotation = 0 rotation = 0
while self.worker_should_be_running: while self.worker_should_be_running:
try: try:
level, component, text = self.queue.get_nowait() level, component, text = self.queue.get_nowait()
LOG.info(f'Got queue item: {level} {component} : {text}') if self.config.filters:
filtered = True
for f in self.config.filters:
if re.search(f, component, re.IGNORECASE):
filtered = False
break # no point in searching further
if filtered:
# no alert for filtered messages
continue
self.log.info(f"Got queue item: {level} {component} : {text}")
# effect duration should be between 1s and 1.5s # effect duration should be between 1s and 1.5s
if level == 'error': if level == "error":
self._update_all(0, 0, 0, 0, 0) self._update_all(0, 0, 0, 0, 0)
sleep(0.2) sleep(0.2)
# three instances of two flashes each # three instances of two flashes each
for i in range(3): for i in range(3):
for j in range(2): for j in range(2):
self._update_all( self._update_all(
self.config.alerts.brightness, 255, 0, 0, 50 self.config.alert_brightness, 255, 0, 0, 50
) )
sleep(0.1) sleep(0.1)
self._update_all(0, 255, 0, 0, 50) self._update_all(0, 255, 0, 0, 50)
sleep(0.1) sleep(0.1)
sleep(0.2) sleep(0.2)
elif level == 'warn': elif level == "warn":
self._update_all(0, 0, 0, 0, 0) self._update_all(0, 0, 0, 0, 0)
sleep(0.2) sleep(0.2)
# warning: blink alternate, but slow # warning: blink alternate, but slow
@ -92,7 +108,7 @@ class DMXQueue:
light.white = 50 light.white = 50
if (idx + i) % 2: if (idx + i) % 2:
light.intensity = self.config.alerts.brightness light.intensity = self.config.alert_brightness
else: else:
light.intensity = 0 light.intensity = 0
@ -100,25 +116,25 @@ class DMXQueue:
sleep(0.2) sleep(0.2)
self._update_all(0, 0, 0, 0, 0) self._update_all(0, 0, 0, 0, 0)
sleep(0.2) sleep(0.2)
elif level == 'info': elif level == "info":
forward = list(range(15)) forward = list(range(15))
reverse = list(range(15)) reverse = list(range(15))
reverse.reverse() reverse.reverse()
if self.config.rainbow.enable: if self.config.rainbow_brightness > 0:
diff = ( diff = (
self.config.alerts.brightness self.config.alert_brightness
- self.config.rainbow.brightness - self.config.rainbow_brightness
) )
LOG.debug(diff) self.log.debug(diff)
if diff >= 50: if diff >= 50:
for idx in forward + reverse: for idx in forward + reverse:
LOG.debug(idx) self.log.debug(idx)
LOG.debug(diff * idx) self.log.debug(diff * idx)
self._update_all( self._update_all(
int( int(
self.config.rainbow.brightness self.config.rainbow_brightness
+ ((diff / len(forward)) * idx) + ((diff / len(forward)) * idx)
), ),
0, 0,
@ -129,10 +145,10 @@ class DMXQueue:
sleep(0.025) sleep(0.025)
else: else:
for idx in forward + reverse: for idx in forward + reverse:
LOG.debug(idx) self.log.debug(idx)
self._update_all( self._update_all(
int( int(
(self.config.alerts.brightness / len(forward)) * idx (self.config.alert_brightness / len(forward)) * idx
), ),
0, 0,
50, 50,
@ -143,24 +159,23 @@ class DMXQueue:
self._update_all(0, 0, 0, 0, 0) self._update_all(0, 0, 0, 0, 0)
self.queue.task_done() self.queue.task_done()
except Empty: except Empty:
if self.config.rainbow.enable: if self.config.rainbow_brightness > 0:
for idx, light in enumerate(self.lights): for idx, light in enumerate(self.lights):
self._bulk(*light.rainbow( self._bulk(
idx, *light.rainbow(
rotation, idx,
len(self.lights), rotation,
self.config.rainbow.intensity, len(self.lights),
self.config.rainbow.brightness, 100,
)) self.config.rainbow_brightness,
)
)
if self.config.rainbow.speed >= 25: rotation = rotation + 1
rotation = rotation + 1 if rotation >= 360:
if rotation >= 360: rotation = 0
rotation = 0
sleep(self.config.rainbow.speed / 1000) sleep(0.03)
else:
sleep(0.2)
else: else:
sleep(0.2) sleep(0.2)
LOG.info('Worker shutdown') self.log.info("Worker shutdown")

View file

@ -0,0 +1,7 @@
from .ignition_wal_l710 import IgnitionWALL710
from .pulsar_chromaflood_200 import PulsarChromaflood200
from .sheds_30w_cob_rgb import Sheds30WCOBRGB
from .stairville_par_56 import StairvillePar56
from .tsss_led_par_rgbw import TSSS_LED_PAR_RGBW
from .varytec_hero_wash_zoom_712 import VarytecHeroWashZoom712
from .wled import WLED

View file

@ -1,7 +1,7 @@
import logging import logging
from colorsys import hsv_to_rgb from colorsys import hsv_to_rgb
LOG = logging.getLogger('DMX') LOG = logging.getLogger("DMX")
class BaseDMXLight: class BaseDMXLight:
@ -14,20 +14,18 @@ class BaseDMXLight:
self.white = 0 self.white = 0
def __str__(self): def __str__(self):
return f'{self.name} ({self.address})' return f"{self.name} ({self.address})"
def _dump(self): def _dump(self):
raise NotImplementedError raise NotImplementedError
def dump(self): def dump(self):
ret = self._dump() ret = self._dump()
LOG.debug(f'{str(self)} -> {ret[1]}') LOG.debug(f"{str(self)} -> {ret[1]}")
return ret return ret
def rainbow(self, idx, angle, number_of_lights, intensity, brightness): def rainbow(self, idx, angle, number_of_lights, intensity, brightness):
my_degrees_dec = ( my_degrees_dec = (angle + (idx * (360 / number_of_lights))) % 360 / 360
(angle + (idx * (360 / number_of_lights))) % 360 / 360
)
r, g, b = hsv_to_rgb( r, g, b = hsv_to_rgb(
my_degrees_dec, my_degrees_dec,
1, 1,

View file

@ -1,4 +1,5 @@
from colorsys import hsv_to_rgb from colorsys import hsv_to_rgb
from .common import BaseDMXLight from .common import BaseDMXLight
@ -10,7 +11,11 @@ class PulsarChromaflood200(BaseDMXLight):
self.red, self.red,
self.green, self.green,
self.blue, self.blue,
0,0,0, # chase 1 0,
0,0,0, # chase 2 0,
self.intensity 0, # chase 1
0,
0,
0, # chase 2
self.intensity,
] ]

View file

@ -10,7 +10,7 @@ class Sheds30WCOBRGB(BaseDMXLight):
self.red, self.red,
self.green, self.green,
self.blue, self.blue,
0, # strobe 0, # strobe
0, # mode 0, # mode
0, # speed 0, # speed
] ]

View file

@ -7,9 +7,9 @@ class StairvillePar56(BaseDMXLight):
def _dump(self): def _dump(self):
offset = self.intensity / 255 offset = self.intensity / 255
return self.address, [ return self.address, [
0, # RGB mode 0, # RGB mode
int(self.red * offset), int(self.red * offset),
int(self.green * offset), int(self.green * offset),
int(self.blue * offset), int(self.blue * offset),
0, # speed 0, # speed
] ]

View file

@ -6,9 +6,9 @@ class TSSS_LED_PAR_RGBW(BaseDMXLight):
def _dump(self): def _dump(self):
return self.address, [ return self.address, [
0, # function 0, # function
0, # mode 0, # mode
0, # speed 0, # speed
self.intensity, self.intensity,
self.red, self.red,
self.green, self.green,

71
main.py
View file

@ -8,82 +8,53 @@ from time import sleep
from conf import load_and_validate_config from conf import load_and_validate_config
from dmx_queue import DMXQueue from dmx_queue import DMXQueue
from lights.ignition_wal_l710 import IgnitionWALL710
from lights.pulsar_chromaflood_200 import PulsarChromaflood200
from lights.sheds_30w_cob_rgb import Sheds30WCOBRGB
from lights.stairville_par_56 import StairvillePar56
from lights.varytec_hero_wash_zoom_712 import VarytecHeroWashZoom712
from lights.tsss_led_par_rgbw import TSSS_LED_PAR_RGBW
from lights.wled import WLED
from mqtt_queue import MQTTQueue from mqtt_queue import MQTTQueue
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format='%(asctime)s %(name)20s [%(levelname)-8s] %(message)s', format="%(asctime)s %(name)20s [%(levelname)-8s] %(message)s",
) )
LOG = logging.getLogger('main') LOG = logging.getLogger("main")
def main(): def main():
parser = ArgumentParser() parser = ArgumentParser()
parser.add_argument( parser.add_argument(
'--config', "--config",
default='config.toml', default="config.toml",
) )
args = parser.parse_args() args = parser.parse_args()
config = load_and_validate_config(args.config) config = load_and_validate_config(args.config)
LOG.info('Welcome to Voc2DMX') LOG.info("Welcome to Voc2DMX")
queue = Queue() queues = {}
dmx_workers = {}
lights = [] LOG.info("Initializing worker queues ...")
for addr in config.lights.get('ignition_wal_l710', []):
lights.append(IgnitionWALL710(addr))
for addr in config.lights.get('pulsar_chromaflood_200', []):
lights.append(PulsarChromaflood200(addr))
for addr in config.lights.get('sheds_30w_cob_rgb', []):
lights.append(Sheds30WCOBRGB(addr))
for addr in config.lights.get('stairville_par_56', []):
lights.append(StairvillePar56(addr))
for addr in config.lights.get('tsss_led_par_rgbw', []):
lights.append(TSSS_LED_PAR_RGBW(addr))
for addr in config.lights.get('varytec_hero_wash_712_zoom', []):
lights.append(VarytecHeroWashZoom712(addr))
for addr in config.lights.get('wled_multi_rgb', []):
lights.append(WLED(addr))
if not lights:
LOG.error('No lights configured, please add atleast one fixture')
exit(1)
LOG.info('')
LOG.info('Configured lights:')
for light in lights:
LOG.info(light)
LOG.info('')
LOG.info('Initializing worker queues ...')
mqttq = MQTTQueue(config, queue)
dmxq = DMXQueue(config, queue, lights)
mqttq = MQTTQueue(config, queues)
mqttq.start() mqttq.start()
dmxq.start()
LOG.info('initialization done, now running. Press Ctrl-C to stop') for universe in config.universes:
queues[universe] = Queue()
dmx_workers[universe] = DMXQueue(config, universe, queues[universe])
dmx_workers[universe].start()
LOG.info("initialization done, now running. Press Ctrl-C to stop")
try: try:
while True: while True:
sleep(1) sleep(1)
except KeyboardInterrupt: finally:
LOG.warning('Got interrupt, stopping queues ...') LOG.warning("Got interrupt, stopping queues ...")
mqttq.stop() mqttq.stop()
dmxq.stop() for universe in config.universes:
dmx_workers[universe].stop()
LOG.info('Bye!') LOG.info("Bye!")
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View file

@ -5,14 +5,14 @@ from queue import Queue
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
LOG = logging.getLogger('MQTTQueue') LOG = logging.getLogger("MQTTQueue")
class MQTTQueue: class MQTTQueue:
def __init__(self, config, queue): def __init__(self, config, queues):
self.config = config self.config = config
self.client = mqtt.Client() self.client = mqtt.Client()
self.queue = queue self.queues = queues
self.client.on_connect = self.on_connect self.client.on_connect = self.on_connect
self.client.on_disconnect = self.on_disconnect self.client.on_disconnect = self.on_disconnect
@ -32,38 +32,29 @@ class MQTTQueue:
self.client.disconnect() self.client.disconnect()
def on_connect(self, client, userdata, flags, rc): def on_connect(self, client, userdata, flags, rc):
LOG.info(f'Connected to {self.config.mqtt.host} with code {rc}') LOG.info(f"Connected to {self.config.mqtt.host} with code {rc}")
self.client.subscribe(self.config.mqtt.topic) self.client.subscribe(self.config.mqtt.topic)
LOG.info(f'Subscribed') LOG.info(f"Subscribed")
def on_disconnect(self, client, userdata, rc): def on_disconnect(self, client, userdata, rc):
LOG.warning(f'Disconnected from {self.config.mqtt.host} with code {rc}') LOG.warning(f"Disconnected from {self.config.mqtt.host} with code {rc}")
def on_mqtt_message(self, client, userdata, msg): def on_mqtt_message(self, client, userdata, msg):
try: try:
data = json.loads(msg.payload.decode('utf-8')) data = json.loads(msg.payload.decode("utf-8"))
text = re.sub(r"\<[a-z\/]+\>", "", data["msg"])
text = re.sub(r'\<[a-z\/]+\>', '', data['msg']) for queue in self.queues:
self.queues[queue].put(
add_to_queue = True
if self.config.alerts.filters:
add_to_queue = False
for f in self.config.alerts.filters:
if re.search(f, data['component'], re.IGNORECASE):
add_to_queue = True
break # no point in searching further
if add_to_queue:
self.queue.put(
( (
data['level'].lower(), data["level"].lower(),
data['component'], data["component"],
text, text,
) )
) )
LOG.info(f'Put queue item: {data["level"].lower()} {data["component"]} : {text}') LOG.info(
else: f'Put queue {queue} item: {data["level"].lower()} {data["component"]} : {text}'
LOG.info(f'Ignoring message for {data["component"]} because it was filtered') )
except Exception as e: except Exception as e:
LOG.exception(msg.payload) LOG.exception(msg.payload)