diff options
author | Ben Connors <benconnors@outlook.com> | 2019-09-24 22:20:51 -0400 |
---|---|---|
committer | Ben Connors <benconnors@outlook.com> | 2019-09-24 22:22:19 -0400 |
commit | 7f85bd8ed84b23fc4e683ab90fc7babe288f1a27 (patch) | |
tree | 5d0d7439040ace59388cdbbf8e2fd18408f9aa68 |
Initial commit
- Basic functionality
- Can save/load to XML format (see examples folder)
- Can create and edit fixtures and channels
- Can create and edit Scenes and Audios
- Live updates through callbacks
-rw-r--r-- | .gitignore | 125 | ||||
-rw-r--r-- | __init__.py | 0 | ||||
-rw-r--r-- | constants.py | 13 | ||||
-rw-r--r-- | examples/workspace.xml | 38 | ||||
-rw-r--r-- | exceptions.py | 4 | ||||
-rw-r--r-- | functions/__init__.py | 0 | ||||
-rw-r--r-- | functions/audio.py | 118 | ||||
-rw-r--r-- | functions/function.py | 88 | ||||
-rw-r--r-- | functions/scene.py | 144 | ||||
-rw-r--r-- | interfaces.py | 47 | ||||
-rw-r--r-- | topology.py | 135 | ||||
-rw-r--r-- | workspace.py | 248 |
12 files changed, 960 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f717486 --- /dev/null +++ b/.gitignore @@ -0,0 +1,125 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/__init__.py diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..7008ffc --- /dev/null +++ b/constants.py @@ -0,0 +1,13 @@ +"""Constants module. + +Contains some constants used throughout. +""" + +INFTY = -1 +AUTO = -2 + +BXW = "{http://unsuspicious.services/bxw}" + +SCENE = "Scene" +AUDIO = "Audio" +CHASER = "Chaser" diff --git a/examples/workspace.xml b/examples/workspace.xml new file mode 100644 index 0000000..8be8bbe --- /dev/null +++ b/examples/workspace.xml @@ -0,0 +1,38 @@ +<workspace xmlns="http://unsuspicious.services/bxw"> + <name>Test Workspace</name> + <author>Test Author</author> + <version>1</version> + <modified>2019-09-24T20:59:37.521229</modified> + + <fixtures> + <fixture name="RGB Fixture" id="0"> + <channel name="R"> + <ola universe="1" address="0"/> + </channel> + <channel name="G"> + <ola universe="1" address="1"/> + </channel> + <channel name="B"> + <ola universe="1" address="2"/> + </channel> + </fixture> + + <fixture name="Basic Fixture" id="1"> + <channel name="Intensity"> + <ola universe="1" address="3"/> + </channel> + </fixture> + </fixtures> + + <functions> + <function type="Scene" id="0" name="Middle White"> + <value fixture="0" channel="0">127</value> + <value fixture="0" channel="1">127</value> + <value fixture="0" channel="2">127</value> + </function> + + <function type="Audio" id="1" name="Intro" fade-in="0" fade-out="0"> + <filename>test.wav</filename> + </function> + </functions> +</workspace> diff --git a/exceptions.py b/exceptions.py new file mode 100644 index 0000000..1b950e8 --- /dev/null +++ b/exceptions.py @@ -0,0 +1,4 @@ +"""Module containing common exceptions.""" + +class LoadError(Exception): + pass diff --git a/functions/__init__.py b/functions/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/functions/__init__.py diff --git a/functions/audio.py b/functions/audio.py new file mode 100644 index 0000000..d0e599d --- /dev/null +++ b/functions/audio.py @@ -0,0 +1,118 @@ +"""Audio function module. + +Contains the definition of the Audio, the audio primitive. +""" + +import xml.etree.ElementTree as et + +from .function import Function + +from ..constants import AUDIO, BXW +from ..exceptions import LoadError + +class Audio(Function): + """Class representing an audio cue. + + This is the primitive for audio, and all sound cues must be based in some manner off of + Audio. This function merely plays a single audio file once, starting at t=0. + + The duration of the audio is automatically determined and is zero if the file does not + exist or is unsupported by ffmpeg. + """ + scope = () + audio_scope = frozenset() + actual_duration = 0 + + def __init__(self, w, id_ = None, name = None, fade_in = 0, fade_out = 0, + filename: str = None): + super().__init__(w=w, type_=AUDIO, id_=id_, name=name, duration=0, + fade_in=fade_in, fade_out=fade_out) + + self._filename = filename + + if filename is not None: + self.duration = self.w.get_audio_length(filename) + self.audio_scope = frozenset(((filename,),)) + else: + self.duration = 0 + self.audio_scope = frozenset() + + self.actual_duration = self.duration + + def get_data(self): + return None + + def copy_data(self, data): + return None + + def render(self, t, data = None): + return ((), + ((self.id, self._filename, 0, self.fade_in, self.duration, self.fade_out),), + None) + + @property + def filename(self): + """Return the current audio filename.""" + return self._filename + + @filename.setter + def filename(self, value: str): + """Set the current audio filename, updating the duration.""" + if value is not None: + self.duration = self.w.get_audio_length(value) + self.audio_scope = frozenset(((value,),)) + else: + self.duration = 0 + self.audio_scope = frozenset() + + self._filename = value + self.w.function_changed(self) + + def serialize(self) -> et.Element: + e = et.Element(BXW+"function") + e.set("type", self.type) + e.set("id", str(self.id)) + e.set("name", self.name) + e.set("fade-in", str(self.fade_in)) + e.set("fade-out", str(self.fade_out)) + if self.filename is not None: + filename = et.SubElement(e, BXW+"filename") + filename.text = self.filename + + return e + + @classmethod + def deserialize(cls, w, e): + if e.tag != BXW+"function": + raise LoadError("Invalid function tag") + elif e.get("type") != AUDIO: + raise LoadError("Load delegated to wrong class (this is a bug)") + + id_ = cls.int_or_none(e.get("id")) + if id_ is None: + raise LoadError("Function tag has invalid/missing ID") + + name = e.get("name") + + fade_in = e.get("fade-in") + try: + fade_in = int(fade_in) if fade_in else 0 + except ValueError: + raise LoadError("Invalid fade in") + + fade_out = e.get("fade-out") + try: + fade_out = int(fade_out) if fade_out else 0 + except ValueError: + raise LoadError("Invalid fade out") + + if len(e) > 1: + raise LoadError("Audio tag can have at most one filename") + elif len(e) == 1: + filename, = e + filename = filename.text + else: + filename = None + + return cls(w=w, id_=id_, name=name, filename=filename, fade_in=fade_in, + fade_out=fade_out) diff --git a/functions/function.py b/functions/function.py new file mode 100644 index 0000000..97c98f7 --- /dev/null +++ b/functions/function.py @@ -0,0 +1,88 @@ +"""Base function module. + +Contains the generic Function interface. +""" + +from abc import ABCMeta, abstractmethod, abstractproperty +from typing import Set, Any + +from ..topology import Fixture +from ..constants import INFTY +from ..interfaces import XMLSerializable + +class Function(XMLSerializable, metaclass=ABCMeta): + """Class representing a generic function. + + Many of the properties here should not be implemented as properties for performance + reasons. + + Functions and properties required for rendering, e.g. ``Function.render`` and + ``Function.actual_duration``, should be written to be performant. + + Any change to the scope, audio scope, or actual duration of the function must be + reported using ``Workspace.function_changed`` with the new values. This should also be + extended to any change in e.g. values for lighting and audio primitives. + """ + def __init__(self, w: "Workspace", type_: str, id_: int = None, name: str = None, + duration: int = INFTY, fade_in: int = 0, fade_out: int = 0): + self.w = w + self.id = id_ if id_ is not None else w.next_function_id + self.name = name if name else "%s %s" % (type_, self.id) + self.type = type_ + self.duration = duration + self.fade_in = fade_in + self.fade_out = fade_out + + self.w.register_function(self) + + def delete(self): + """Delete the function from the Workspace.""" + self.w.function_deleted(self) + + @abstractproperty + def scope(self) -> Set[Fixture.Channel]: + """Return the set of channels affected by this function.""" + + @abstractproperty + def audio_scope(self) -> Set[str]: + """Return the set of audio filenames that may be used by this function.""" + + @abstractmethod + def get_data(self) -> Any: + """Return the default data for this function.""" + + @abstractmethod + def copy_data(self, data: Any) -> Any: + """Duplicate the given data.""" + + @abstractproperty + def actual_duration(self): + """Return the actual duration of the function, including all fades.""" + + @abstractmethod + def render(self, t: int, data: Any = None): + """Render the function at the given time. + + This function must return a 3-tuple: + + (light_cues, audio_cues, new_data) + + Where ``light_cues`` is a iterable of (channel, value) pairs. It is an error for + ``light_cues`` to contain channels other than exactly this Function's scope. + ``audio_cues`` is an iterable of audio cues of the form: + + (guid, filename, start_time, fade_in, end_time, fade_out) + + The ``guid`` is globally unique: that is, it is unique to that specific audio cue, + even if the same file is played multiple times or in different places. Note that + ``end_time`` may be ``INFTY``, in which case the file should be played to its end. + In the case of infinite-duration chasers, the ``end_time`` may change over + subsequent calls to this function. + + Note that ``data`` is not necessarily mutable: ``render`` should be called like: + + lights, sound, data = f.render(t, data) + + :param t: the time to render at, in milliseconds + :param data: the function data to use + """ diff --git a/functions/scene.py b/functions/scene.py new file mode 100644 index 0000000..958138f --- /dev/null +++ b/functions/scene.py @@ -0,0 +1,144 @@ +"""Scene function module. + +Contains the definition of the Scene, the lighting primitive. +""" + +import xml.etree.ElementTree as et +from typing import Mapping + +from .function import Function +from ..topology import Fixture +from ..constants import INFTY, SCENE, BXW +from ..exceptions import LoadError + +class Scene(Function): + """Class representing a lighting scene. + + This is the primitive for lighting, and all light cues must be based in some manner off + of Scene. Scenes are simple, having no fading and infinite duration. + + Modifying the scene can be done in a few ways: the easiest is using the dictionary- + style accesses, e.g. setting the values using: + + scene[channel] = value + + Values may be removed from the scene using the ``del`` operator and retrieved in the + same fashion. Alternatively, values can be updated in bulk using ``Scene.update`` or + set in bulk with ``Scene.set``. + """ + audio_scope = () + scope = frozenset() + actual_duration = INFTY + values = frozenset() + + def __init__(self, w, id_ = None, name = None, values: Mapping[Fixture.Channel, int] = None): + super().__init__(w=w, type_=SCENE, id_=id_, name=name, duration=INFTY, fade_in=0, + fade_out=0) + self._values = {} + self._render = () + + self._update(values, changed=False) + + def _update_render(self, changed=True): + self._render = tuple(self._values.items()) + self.scope = frozenset(self._values.keys()) + self.values = self._render + + if changed: + self.w.function_changed(self) + + def set(self, v): + """Set the scene's values to the given ones. + + Equivalent to update, except that all non-given values are deleted from the scene. + """ + self._values = {} + self.update(v) + + def _update(self, v, changed=True): + if isinstance(v, dict): + v = v.items() + vn = [] + for c, val in v: + val = int(val) + if val < 0 or val > 255: + raise ValueError("Values must be integers on [0,256)") + vn.append((c, val)) + for c, val in vn: + self._values[c] = val + self._update_render(changed=changed) + + def update(self, v): + """Update the scene's values.""" + self._update(v=v, changed=True) + + def __getitem__(self, c: Fixture.Channel): + return self._values[c] if c in self._values else None + + def __setitem__(self, c: Fixture.Channel, v: int): + self.update(((c, v),)) + + def __delitem__(self, c: Fixture.Channel): + if c in self._values: + del self._values[c] + self._update_render() + + def get_data(self): + return None + + def copy_data(self, data): + return None + + def render(self, t, data = None): + return (self._render, (), None) + + def serialize(self): + e = et.Element(BXW+"function") + e.set("type", self.type) + e.set("id", str(self.id)) + e.set("name", self.name) + for c, v in self.values: + ce = et.SubElement(e, BXW+"value") + ce.set("fixture", str(c.f.id)) + ce.set("channel", str(c.id)) + ce.text = str(v) + + return e + + @classmethod + def deserialize(cls, w, e): + if e.tag != BXW+"function": + raise LoadError("Invalid function tag") + elif e.get("type") != SCENE: + raise LoadError("Load delegated to wrong class (this is a bug)") + + id_ = cls.int_or_none(e.get("id")) + if id_ is None: + raise LoadError("Function tag has invalid/missing ID") + + name = e.get("name") + + values = {} + for ve in e: + if ve.tag != BXW+"value": + raise LoadError("Invalid value tag") + + fixture = cls.int_or_none(ve.get("fixture")) + channel = cls.int_or_none(ve.get("channel")) + + if None in (fixture, channel): + raise LoadError("Missing/invalid fixture/channel value") + elif fixture not in w.fixtures: + raise LoadError("Missing fixture ID %d" % fixture) + elif channel >= len(w.fixtures[fixture].channels): + raise LoadError("Fixture %d missing channel ID %d" % (fixture, channel)) + + channel = w.fixtures[fixture].channels[channel] + + value = cls.int_or_none(ve.text) + if value is None: + raise LoadError("Missing/invalid value for channel") + + values[channel] = value + + return cls(w=w, id_=id_, name=name, values=values) diff --git a/interfaces.py b/interfaces.py new file mode 100644 index 0000000..e0a904a --- /dev/null +++ b/interfaces.py @@ -0,0 +1,47 @@ +"""Module containing various interfaces.""" + +from abc import ABCMeta, abstractmethod, abstractclassmethod +import xml.etree.ElementTree as et + +class XMLSerializable(metaclass=ABCMeta): + """Interface for XML-serializable Workspace components.""" + @staticmethod + def int_or_none(v): + if v is None: + return None + + try: + v = int(v) + except (ValueError, TypeError): + return None + + return v + + @staticmethod + def indent(elem, indent=4, level=0): + """Pretty-indent the XML tree.""" + i = "\n" + level*(indent*' ') + if len(elem) > 0: + if not elem.text or not elem.text.strip(): + elem.text = i + (' '*indent) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + for elem in elem: + XMLSerializable.indent(elem, indent=indent, level=level+1) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + else: + if level and (not elem.tail or not elem.tail.strip()): + elem.tail = i + + @abstractmethod + def serialize(self) -> et.Element: + """Serialize the object into an XML element.""" + + @abstractclassmethod + def deserialize(cls, w: "Workspace", e: et.Element): + """Deserialize the object from an XML element. + + This function may assume that all dependencies have already been loaded into the + passed workspace. + """ diff --git a/topology.py b/topology.py new file mode 100644 index 0000000..331c4e2 --- /dev/null +++ b/topology.py @@ -0,0 +1,135 @@ +"""Topology module. + +Contains the topology classes for representing the physical and virtual topology of the +lighting setup. +""" + +import xml.etree.ElementTree as et + +from .constants import BXW +from .interfaces import XMLSerializable +from .exceptions import LoadError + +class Fixture(XMLSerializable): + """Class representing a lighting fixture. + + Each lighting fixture has a number of channels, which are mapped to the physical + topology. + + Channels must be added by changing the value of ``Fixture.channel_count``, which will + create the necessary channel objects and handle deletion. + """ + def __init__(self, w: "Workspace", id_: int = None, name: str = None, channel_count: int = 0): + self.w = w + self.id = id_ if id_ is not None else w.next_fixture_id + self.name = name if name else "Fixture %d" % self.id + + self.channels = () + self.channel_count = channel_count + + self.w.register_fixture(self) + + class Channel: + """Class representing a single channel on a Fixture. + + The physical address of the channel is stored in the ``Channel.address`` attribute + and may be changed at will. + + This program takes a very lax approach to channel addressing: the address is not + parsed in any way and duplicate addresses are not handled. For convenience with + OLA, Channel is iterable and iteration returns an iterator over its ``address`` + attribute. + """ + def __init__(self, f: "Fixture", id_: int, name: str = "Intensity", address = (-1,-1)): + self.f = f + self.id = id_ + self.name = name + self.address = address + self._hash = hash((f.id, id_)) + + def __hash__(self): + return self._hash + + def __iter__(self): + return iter(self.address) + + def __repr__(self): + return "Channel(fixture={c.f.id}, index={c.id}, name={c.name})".format(c=self) + + @property + def channel_count(self): + """Return the current number of channels on the fixture.""" + return len(self.channels) + + @channel_count.setter + def channel_count(self, value): + """Change the number of channels on the fixture. + + This function handles deletion of removed channels from functions as well. + """ + if value < 0: + raise ValueError("Number of channels must be nonnegative") + elif value < len(self.channels): + for i in range(value, len(self.channels)): + self.w.delete_channel(self.channels[i]) + self.channels = self.channels[:value] + elif value > len(self.channels): + self.channels += tuple((Fixture.Channel(self, i) for i in range(len(self.channels), value))) + + def __repr__(self): + return "Fixture({f.name}, id={f.id}, channels={f.channel_count})".format(f=self) + + def serialize(self): + e = et.Element(BXW+"fixture") + e.set("name", self.name) + e.set("id", str(self.id)) + + for c in self.channels: + ce = et.SubElement(e, BXW+"channel") + ce.set("name", c.name) + if c.address is not None: + ## TODO: Other addressing modes + try: + univ, addr = c.address + ae = et.SubElement(ce, BXW+"ola") + ae.set("universe", str(univ)) + ae.set("address", str(addr)) + except ValueError: + pass + + return e + + @classmethod + def deserialize(cls, w, e): + if e.tag != BXW+"fixture": + raise LoadError("Invalid fixture tag") + + id_ = cls.int_or_none(e.get("id")) + if id_ is None: + raise LoadError("Fixture tag has invalid/missing ID") + + name = e.get("name") + + f = cls(w, id_=id_, name=name, channel_count=len(e)) + for n, channel in enumerate(e): + if channel.tag != BXW+"channel": + raise LoadError("Invalid channel tag") + + name = channel.get("name") + if name is not None: + f.channels[n].name = name + + if len(channel) > 1: + raise LoadError("Channel can have at most one address") + elif len(channel) == 1: + address, = channel + if address.tag == BXW+"ola": + try: + address = (int(address.get("universe")), int(address.get("address")),) + except (ValueError, TypeError): + raise LoadError("Invalid OLA address on channel") + else: + raise LoadError("Unknown address tag \"%s\"" % address.tag) + f.channels[n].address = address + + return f diff --git a/workspace.py b/workspace.py new file mode 100644 index 0000000..add047b --- /dev/null +++ b/workspace.py @@ -0,0 +1,248 @@ +"""Workspace module. + +Contains the main Workspace implementation. +""" + +import datetime as dt +import json +import subprocess as subp +import xml.etree.ElementTree as et + +from .constants import AUDIO, SCENE, BXW +from .functions.function import Function +from .exceptions import LoadError +from .interfaces import XMLSerializable + +def ffprobe_audio_length(f: str, path: str = "ffprobe") -> int: + """Use ffprobe to check audio length in milliseconds. + + Will always return the nearest whole millisecond greater than or equal to the duration. + + Parameters: + f: the path to check + path: the path of ffprobe + """ + try: + a = subp.check_output([path, "-show_format", "-print_format", "json", f], stderr=subp.DEVNULL) + except subp.CalledProcessError: + return 0 + return int(1000*float(json.loads(a)["format"]["duration"])+0.5) + +class Workspace(XMLSerializable): + """Class representing a audiovisual workspace.""" + def __init__(self, name: str, author: str, version: int, modified: dt.datetime): + self.name = name + self.author = author + self.version = version + self.modified = modified + + self.fixtures = {} + self._last_fixture_id = -1 + self.functions = {} + self._last_function_id = -1 + + self._audio_lengths = {} + + self._change_callbacks = {} + self._delete_callbacks = {} + + def get_audio_length(self, filename: str) -> int: + """Determine the audio length of the given file. + + This value is returned from the cache, if available. + """ + if filename not in self._audio_lengths: + self._audio_lengths[filename] = ffprobe_audio_length(filename) + return self._audio_lengths[filename] + + def recheck_audio_length(self, filename: str) -> int: + """Determine the audio length of the given file. + + This function re-probes the value, updating the cache result and the durations of + Audio functions. + """ + self._audio_lengths[filename] = ffprobe_audio_length(filename) + for f in self.functions.values(): + if f.type == AUDIO and f.filename == filename: + f.duration = self._audio_lengths[f.filename] + return self._audio_lengths[filename] + + def recheck_audio_lengths(self): + """Recheck and update all audio lengths.""" + for filename in self._audio_lengths: + self._audio_lengths[filename] = ffprobe_audio_length(filename) + for f in self.functions.values(): + if f.type == AUDIO and f.filename in self._audio_lengths: + f.filename = f.filename + + def register_fixture(self, f: "Fixture"): + """Register the fixture in the Workspace. + + Always called when the fixture is instantiated. + """ + if f.id in self.fixtures: + raise ValueError("A fixture with that ID already exists") + self.fixtures[f.id] = f + + def register_function(self, f: Function): + """Register the function in the Workspace. + + Always called when the function is instantiated. + """ + if f.id in self.functions: + raise ValueError("A function with that ID already exists") + self.functions[f.id] = f + + @property + def next_fixture_id(self): + """Return the next fixture ID.""" + return self._last_fixture_id + 1 + + @property + def next_function_id(self): + """Return the next function ID.""" + return self._last_function_id + 1 + + def delete_channel(self, c: "Fixture.Channel"): + """Notify that the given channel was deleted. + + This is used for removing deleted channels from functions. + """ + for f in self.functions.values(): + if f.type == SCENE and c in f.scope: + f.delete_channel(c) + + def delete_callbacks(self, owner, f: int = None): + """Remove all callbacks registered by the owner. + + :param f: the function to remove from (all if None) + """ + if isinstance(f, Function): + f = f.id + for g in self._change_callbacks: + if f is None or g == f: + self._change_callbacks[g] = [i for i in self._change_callbacks[g] if i[0] != owner] + for g in self._delete_callbacks: + if f is None or g == f: + self._delete_callbacks[g] = [i for i in self._delete_callbacks[g] if i[0] != owner] + + def register_function_change_callback(self, f: int, callback, owner = None): + """Register a callback for a function change. + + :param f: the ID of the function to monitor + :param callback: a function to call, accepting the Function + :param owner: optional ID to use for removal of the callback later + """ + if isinstance(f, Function): + f = f.id + if f not in self._change_callbacks: + self._change_callbacks[f] = [] + self._change_callbacks[f].append((owner, callback)) + + def register_function_delete_callback(self, f: int, callback, owner = None): + """Register a callback for a function deletion. + + :param f: the ID of the function to monitor + :param callback: a function to call, accepting the Function + :param owner: optional ID to use for removal of the callback later + """ + if isinstance(f, Function): + f = f.id + if f not in self._delete_callbacks: + self._delete_callbacks[f] = [] + self._delete_callbacks[f].append((owner, callback)) + + def function_changed(self, f: Function): + """Called when a function is changed. + + :param f: the changed function + """ + for callback in self._change_callbacks[f.id]: + callback(f) + + def function_deleted(self, f: Function): + """Called when a function is deleted. + + This also handles removing the function from the Workspace. + """ + for callback in self._delete_callbacks[f.id]: + callback(f) + + self.delete_callbacks(f) + + del self.functions[f.id] + + @classmethod + def load(cls, filename): + """Load the workspace from a file.""" + tree = et.parse(filename) + root = tree.getroot() + + return cls.deserialize(None, root) + + @classmethod + def deserialize(cls, w, e): + from .topology import Fixture + from .functions.scene import Scene + from .functions.audio import Audio + + if e.tag != BXW+"workspace": + raise LoadError("Root tag must be workspace") + + try: + name, author, version, modified, fixtures, functions = e + except ValueError: + raise LoadError("Invalid workspace layout") + + ## First load the metadata so we can create the workspace + name = name.text + author = author.text + version = int(version.text) + modified = dt.datetime.fromisoformat(modified.text) + + w = cls(name=name, author=author, version=version, modified=modified) + + ## Now load the fixtures + for fixture in fixtures: + Fixture.deserialize(w, fixture) + + ## Finally, load the functions + for function in functions: + type_ = function.get("type") + if type_ == AUDIO: + Audio.deserialize(w, function) + elif type_ == SCENE: + Scene.deserialize(w, function) + else: + raise LoadError("Unknown function type \"%s\"" % type_) + + return w + + def serialize(self) -> et.Element: + root = et.Element(BXW+"workspace") + + et.SubElement(root, BXW+"name").text = self.name + et.SubElement(root, BXW+"author").text = self.author + et.SubElement(root, BXW+"version").text = str(self.version) + et.SubElement(root, BXW+"modified").text = dt.datetime.now().isoformat() + + fixtures = et.SubElement(root, BXW+"fixtures") + for n, fixture in enumerate(self.fixtures.values()): + fe = fixture.serialize() + fixtures.insert(n, fe) + + functions = et.SubElement(root, BXW+"functions") + for n, function in enumerate(self.functions.values()): + fe = function.serialize() + functions.insert(n, fe) + + return root + + + def save(self, filename): + """Save the workspace to a file.""" + et.register_namespace("", BXW.strip("{}")) + root = self.serialize() + XMLSerializable.indent(root) + tree = et.ElementTree(element=root) + tree.write(filename, encoding="unicode") |