Add initial support for DMX lighting

This commit is contained in:
2025-08-08 03:36:23 +01:00
parent 99ddcfbc6b
commit cfb6c517c1
4 changed files with 326 additions and 8 deletions

210
dmx.py Normal file
View File

@@ -0,0 +1,210 @@
# dmx.py
from ola.ClientWrapper import ClientWrapper
from ola.DMXConstants import (DMX_MIN_SLOT_VALUE, DMX_MAX_SLOT_VALUE, DMX_UNIVERSE_SIZE)
from array import array
import time
DMX_DATA_SIZE = DMX_UNIVERSE_SIZE
class CompatibleArray(array):
def tostring(self):
return self.tobytes()
class DMX:
def __init__(self, universe, client_wrapper, dmx_data_size=DMX_UNIVERSE_SIZE):
dmx_data_size = min(dmx_data_size, DMX_UNIVERSE_SIZE)
self._universe = universe
self._data = CompatibleArray('B', [DMX_MIN_SLOT_VALUE] * dmx_data_size)
self._wrapper = client_wrapper
self._client = client_wrapper.Client()
def set_channel(self, channel, value):
self._data[channel] = value
def submit(self):
self._client.SendDmx(self._universe, self._data)
class FixtureType:
def __init__(self, channels):
self._channels = channels
def max_channels(self):
return max([channel.get('channel', 0), channel.get('fine-channel', 0)] for channel in self._channels)
def is_channel(self, channel):
return channel in self._channels
def can_fade(self, channel):
return self._channels[channel]['fade']
def parse_value(self, value):
name = None
if isinstance(value, str):
values = value.split('/')
if len(values) > 2:
raise ValueError('{} is not a valid value'.format(value))
elif len(values) > 1:
(name, number) = values
else:
number = values[0]
else:
number = value
return (name, number)
def channel(self, name):
if name not in self._channels:
return KeyError('Channel {} not found in fixture type'.format(name))
return self._channels[name]
def range_lookup(self, channel, name, number):
channel = self.channel(channel)
range = channel['range']
if isinstance(range, dict):
if name not in range:
raise KeyError('Range {} not found'.format(name))
(a, b) = range['range']
if number < a or number > b:
raise ValueError('DMX value out of range')
return a + number
else:
if name is not None:
raise KeyError('Range {} not found'.format(name))
return number
def update(self, **kwargs):
output = []
for channel in kwargs:
value = kwargs[channel]
(name, number) = self.parse_value(value)
dmxvalue = self.range_lookup(channel, name, number)
ch = self.channel(channel)
if 'fine-channel' in ch and ch['fine-channel'] is not None:
output.extend([(ch['channel'], dmxvalue // 256), (ch['fine-channel'], dmxvalue % 256)])
else:
output.extend([(ch['channel'], dmxvalue)])
return output
class Fixture:
def __init__(self, dmx, type):
self._dmx = dmx
self._type = type
self._dmxoutput = None
self._values = {}
self._target = {}
self._velocity = {}
self._fadetime = 0
self._t = 0
def get(self, channel):
return self._values.get(channel, 0)
def set(self, channel, value):
self._values[channel] = value
def set_dmx(self, dmx):
self._dmxoutput = dmx
def is_channel(self, name):
return self._type.is_channel(name)
def set_fadetime(self, fadetime):
self._fadetime = fadetime
def update(self, **kwargs):
for channel in kwargs:
(name, number) = self._type.parse_value(kwargs[channel])
self._target[channel] = self._type.range_lookup(channel, name, number)
diff = self._target[channel] - self.get(channel)
self._velocity[channel] = diff
self._t = 0
self.tick(0)
def tick(self, dt):
def reached(value, target, velocity):
if velocity < 0:
return value <= target
else:
return value >= target
self._t += dt
for channel in self._target.keys():
if self._type.can_fade(channel) and self._fadetime > 0 and self._t <= self._fadetime:
# print("Fading: t = {}, ft = {}".format(self._t, self._fadetime))
value = self.get(channel)
value += self._velocity[channel] * dt / self._fadetime
if reached(value, self._target[channel], self._velocity[channel]):
self._velocity[channel] = 0
value = self._target[channel]
self.set(channel, value)
else:
# print("Not fading: t = {}, ft = {}".format(self._t, self._fadetime))
self.set(channel, self._target[channel])
output = self._type.update(**self._values)
output = [(ch-1+self._dmx-1, value) for (ch, value) in output]
if self._dmxoutput is None:
return
for (ch, value) in output:
self._dmxoutput.set_channel(ch, int(value))
class Group:
def __init__(self, objects):
self._objects = objects
def is_channel(self, name):
return any([x.is_channel(name) for x in self._objects])
def set_fadetime(self, fadetime):
for object in self._objects:
object.set_fadetime(fadetime)
def update(self, **kwargs):
for object in self._objects:
object.update(**kwargs)
class Scene:
def __init__(self, objects):
self._objects = objects
def set_fadetime(self, fadetime):
for object in self._objects:
(obj, settings) = self._objects[object]
obj.set_fadetime(fadetime)
def activate(self, fadetime=0):
self.set_fadetime(fadetime)
for object in self._objects:
(obj, settings) = self._objects[object]
obj.update(**settings)
class FixtureOutput:
def __init__(self, fixtures, dmx):
self._fixtures = fixtures
self._dmx = dmx
for fixture in self._fixtures:
fixture.set_dmx(dmx)
def tick(self, dt):
for fixture in self._fixtures:
fixture.tick(dt)
self._dmx.submit()
def main():
wrapper = ClientWrapper()
universe = 0
dmx = DMX(universe, wrapper, DMX_DATA_SIZE)
value = 0
while True:
dmx.set_channel(0, 255)
dmx.set_channel(1, int((value * 3) % 256))
dmx.set_channel(2, int((value * 4) % 256))
dmx.set_channel(3, int((value * 5) % 256))
dmx.submit()
value += 0.1
time.sleep(0.1)
if __name__ == "__main__":
main()

View File

@@ -193,3 +193,14 @@ class Lyrics(Event):
if message == 'newdisplay': if message == 'newdisplay':
self._eventrunner.finished(self) self._eventrunner.finished(self)
class LightScene(Event):
def __init__(self, scene, fade=0, *args, **kwargs):
super().__init__(*args, **kwargs)
self._scene = scene
self._fade = fade
def start(self):
scenes = self._eventrunner.get_config('scenes')
scene = scenes[self._scene]
scene.activate(self._fade)

View File

@@ -2,6 +2,7 @@
import events import events
from item import Item from item import Item
import dmx
class MakeItems: class MakeItems:
def __new__(cls, config): def __new__(cls, config):
@@ -45,3 +46,73 @@ class MakeSet:
raise KeyError('{} not in items'.format(item)) raise KeyError('{} not in items'.format(item))
return config return config
class MakeFixtureTypes:
def __new__(cls, config):
def parse_range(src):
(a, b) = src.split('-')
return (int(a), int(b))
types = {}
for ftype in config:
channels = {}
for channel in config[ftype].keys():
dmxchannel = int(config[ftype][channel]['channel'])
finechannel = config[ftype][channel].get('fine-channel')
if finechannel is not None:
finechannel = int(finechannel)
fade = config[ftype][channel].get('fade', True)
rangesrc = config[ftype][channel]['range']
if isinstance(rangesrc, dict):
range = {}
for entry in rangesrc:
range[entry] = parse_range(rangesrc[entry])
else:
range = parse_range(rangesrc)
channels[channel] = {'channel' : dmxchannel, 'fine-channel' : finechannel, 'range' : range, 'fade' : fade}
types[ftype] = dmx.FixtureType(channels)
return types
class MakeFixtures:
def __new__(cls, config, fixture_types):
fixtures = {}
for fixture in config:
dmxch = config[fixture]['dmx']
type = config[fixture]['type']
if type not in fixture_types:
raise KeyError('{} not in fixture_types'.format(type))
fixtures[fixture] = dmx.Fixture(dmxch, fixture_types[type])
return fixtures
class MakeGroups:
def __new__(cls, config, fixtures):
groups = {}
for group in config:
fobjects = []
for fixture in config[group]:
if fixture not in fixtures:
raise KeyError('{} not in fixtures'.format(fixture))
fobjects.append(fixtures[fixture])
# XXX process modifiers
groups[group] = dmx.Group(fobjects)
return groups
class MakeScenes:
def __new__(cls, config, fixtures, groups):
scenes = {}
for scene in config:
objects = {}
for name in config[scene].keys():
if name in groups:
obj = groups[name]
elif name in fixtures:
obj = fixtures[name]
else:
raise KeyError('{} is not a group or a fixture'.format(name))
for channel in config[scene][name].keys():
if not obj.is_channel(channel):
raise KeyError('{} is not a channel within {}'.format(channel, name))
objects[name] = (obj, config[scene][name])
scenes[scene] = dmx.Scene(objects)
return scenes

42
gig.py
View File

@@ -10,12 +10,16 @@ from pyglet.window import key
import factory import factory
import display import display
import dmx
import yaml import yaml
import time import time
import dbus import dbus
from ola.ClientWrapper import ClientWrapper
from ola.DMXConstants import (DMX_MIN_SLOT_VALUE, DMX_MAX_SLOT_VALUE, DMX_UNIVERSE_SIZE)
class EventRunner: class EventRunner:
def __init__(self, gigconfig): def __init__(self, gigconfig):
self._gigconfig = gigconfig self._gigconfig = gigconfig
@@ -31,6 +35,9 @@ class EventRunner:
for item in self._gigconfig['items']: for item in self._gigconfig['items']:
self._gigconfig['items'][item].set_audiencecontent(audiencecontent) self._gigconfig['items'][item].set_audiencecontent(audiencecontent)
def get_config(self, section):
return self._gigconfig[section]
def select(self, pos): def select(self, pos):
self._selected = pos self._selected = pos
self._playing = None self._playing = None
@@ -335,14 +342,22 @@ class AudienceWindow(pyglet.window.Window):
self.audiencebatch.draw() self.audiencebatch.draw()
def process_config(config): def process_config(config):
items = factory.MakeItems(config['items']) items = factory.MakeItems(config.get('items'))
setlist = factory.MakeSet(config['set'], items) setlist = factory.MakeSet(config.get('set'), items)
preshow = factory.MakeSet(config['preshow'], items) defaults = config.get('defaults')
if 'defaults' in config: fixture_types = factory.MakeFixtureTypes(config.get('fixture_types'))
defaults = config['defaults'] fixtures = factory.MakeFixtures(config.get('fixtures'), fixture_types)
else: groups = factory.MakeGroups(config.get('groups'), fixtures)
defaults = {} scenes = factory.MakeScenes(config.get('scenes'), fixtures, groups)
return {'items' : items, 'setlist' : setlist, 'preshow' : preshow} return {
'items' : items,
'setlist' : setlist,
'defaults' : defaults,
'fixture_types' : fixture_types,
'fixtures' : fixtures,
'groups' : groups,
'scenes' : scenes,
}
def load_config(filename): def load_config(filename):
with open(filename, encoding='utf8') as file: with open(filename, encoding='utf8') as file:
@@ -360,6 +375,7 @@ def main():
parser = argparse.ArgumentParser(prog='gig.py', description='Gig showrunning') parser = argparse.ArgumentParser(prog='gig.py', description='Gig showrunning')
parser.add_argument('filename') parser.add_argument('filename')
parser.add_argument('-w', '--window', action='store_true', help='Run in a window') parser.add_argument('-w', '--window', action='store_true', help='Run in a window')
parser.add_argument('-u', '--universe', default=0, type=int, help='DMX universe')
args = parser.parse_args() args = parser.parse_args()
config = load_config(args.filename) config = load_config(args.filename)
display = pyglet.display.get_display() display = pyglet.display.get_display()
@@ -376,6 +392,16 @@ def main():
audiencecontent = AudienceContent(gigconfig=config, eventrunner=eventrunner) audiencecontent = AudienceContent(gigconfig=config, eventrunner=eventrunner)
# try:
wrapper = ClientWrapper()
DMX_DATA_SIZE = DMX_UNIVERSE_SIZE
dmx_object = dmx.DMX(args.universe, wrapper, DMX_DATA_SIZE)
# except Exception:
# pass
fixtureoutput = dmx.FixtureOutput(fixtures=[config['fixtures'][x] for x in config['fixtures']], dmx=dmx_object)
pyglet.clock.schedule_interval(fixtureoutput.tick, 1/30.0)
if audiencescreen is not None: if audiencescreen is not None:
window2 = AudienceWindow(audiencecontent=audiencecontent, fullscreen=False, screen=audiencescreen, resizable=True) window2 = AudienceWindow(audiencecontent=audiencecontent, fullscreen=False, screen=audiencescreen, resizable=True)
if not args.window: if not args.window: