Repository: open-stage/blender-dmx (language: python)

Issue:
GDTF import fail - Base, Yoke, Head, Beam geometry naming is required
Hi, had an import fail for a GDTF file - https://gdtf-share.com/user-page?name=JB-Lighting&page=fixtures

Python: Traceback (most recent call last):
  File "C:\Users\r\AppData\Roaming\Blender Foundation\Blender\3.3\scripts\addons\dmx\panels\fixtures.py", line 243, in execute
    dmx.addFixture([self.name](http://self.name/)+" "+str(i+1), self.profile, self.universe, self.address, self.mode, self.gel_color)
  File "C:\Users\r\AppData\Roaming\Blender Foundation\Blender\3.3\scripts\addons\dmx\__init__.py", line 501, in addFixture
    dmx.fixtures[-1].build(name, profile, mode, universe, address, gel_color)
  File "C:\Users\r\AppData\Roaming\Blender Foundation\Blender\3.3\scripts\addons\dmx\fixture.py", line 149, in build
    model_collection = DMX_Model.getFixtureModelCollection(gdtf_profile)
  File "C:\Users\r\AppData\Roaming\Blender Foundation\Blender\3.3\scripts\addons\dmx\model.py", line 35, in getFixtureModelCollection
    return DMX_GDTF.buildCollection(profile)
  File "C:\Users\r\AppData\Roaming\Blender Foundation\Blender\3.3\scripts\addons\dmx\gdtf.py", line 217, in buildCollection
    updateGeom(profile)
  File "C:\Users\r\AppData\Roaming\Blender Foundation\Blender\3.3\scripts\addons\dmx\gdtf.py", line 215, in updateGeom
    updateGeom(child_geom, d+1)
  File "C:\Users\r\AppData\Roaming\Blender Foundation\Blender\3.3\scripts\addons\dmx\gdtf.py", line 179, in updateGeom
    obj_child = objs[[geom.name](http://geom.name/)]
KeyError: 'Base'
Refactor model loading and add support for empty geometries and for GeometryReferences
- get geometry tree root from DMX Mode link, this allows to only load models required for the geometry tree related to the chosen DMX mode
- unify loading logic for glb and 3ds loading, tested and confirmed that it works on Windows as well
- support empty geometries - (these act as a logic layer where needed)
- initial support for GeometryReferences https://gdtf.eu/gdtf/file-spec/geometry-collect/#geometry-type-reference . Geometries are loaded, placed and constrained correctly. What is still needed is to assign emitter to all beams - so beam now will need to be an array. I don't know this part of Blender too well, so i did not touch it at the moment. Also, as we do not have per geometry control (at the moment) it would be possible to "only" make linked copies of the beams, again, not sure what exactly does Blender support in this regard.

![image](https://user-images.githubusercontent.com/3680926/211156784-02c03a17-e366-45ae-812c-54f4067be82a.png)


Discussion / hints:
Hi rudolf,

This problem occurs because the plugin is expecting the fixture to have a "Base" object, which is not a good decision.
I have flagged it as a bug and will work on it after we merge some new features and fixes that @vanous is working on.

Thanks for the report
When do you see this coming? I am working on a DMX workflow as I do stage
design and it looks like it might be moving in the direction of light
design.
You don't have any future plans on MVR scene support?


On Wed, 4 Jan 2023 at 03:02, Hugo Aboud ***@***.***> wrote:

> Hi rudolf,
>
> This problem occurs because the plugin is expecting the fixture to have a
> "Base" object, which is not a good decision.
> I have flagged it as a bug and will work on it after we merge some new
> features and fixes that @vanous <https://github.com/vanous> is working on.
>
> Thanks for the report
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/hugoaboud/blenderDMX/issues/17#issuecomment-1370377285>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AU2R7WXZIV3XNHZQYPFZEYDWQTDZRANCNFSM6AAAAAATLEAU4E>
> .
> You are receiving this because you authored the thread.Message ID:
> ***@***.***>
>

Fixing the dependency on naming will take some time, but you can work around right now by editing the GDTF file in the GDTF Builder and renaming the base, yoke, head to Base, Yoke, Head.

As for MVR, do you have an example file that you could post here?

Thank you


Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..3f4218cf
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+### Changelog
+
+#### NEXT
+* Add support for sACN protocol
+* Fix receiving higher ArtNet universes
+* Add import of fixtures from MVR
+* Support multipixel devices and color control per geometry
+* Show DMX Channel count in list of DMX Modes
+* Add Live DMX - a table with current DMX values
+* Add logging
+
diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md
new file mode 100644
index 00000000..df74ce8d
--- /dev/null
+++ b/DEPENDENCIES.md
@@ -0,0 +1,8 @@
+### Dependencies
+
+As we must vendor some libraries in, due to how Blender dependencies work, here
+is a list of included libraries.
+
+* python-gdtf: https://github.com/jackdpage/python-gdtf/
+* sACN / E1.31 module: https://github.com/Hundemeier/sacn
+* python-mvr: https://github.com/vanous/python-mvr
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
new file mode 100644
index 00000000..051d0de8
--- /dev/null
+++ b/DEVELOPMENT.md
@@ -0,0 +1,19 @@
+## Development
+
+### Logging
+
+Use [predefined python logging module](https://docs.python.org/3/library/logging.html?highlight=logging#module-logging) instead of print. If not imported yet, import it into your class:
+
+```python
+from dmx.logging import DMX_LOG
+```
+
+Then use it. Choose appropriate level. Default level is `Error`, which means `Error` and `Critical` messages will be displayed. Following logging levels are available:
+
+```python
+DMX_LOG.log.critical("Logging critical message here, level 50")
+DMX_LOG.log.error("Logging error message here, level 40")
+DMX_LOG.log.warning("Logging warning message here, level 30")
+DMX_LOG.log.debug("Logging debug message here, level 20")
+DMX_LOG.log.info("Logging info message here, level 10")
+```
diff --git a/README.md b/README.md
index 794acecd..61d4cf4e 100644
--- a/README.md
+++ b/README.md
@@ -47,3 +47,6 @@ We also have a `Discord` server for sharing knowledge: [discord server link](htt
 *This addon will always be free and open source, if you can donate and help me keep developing tools for artists I'd be really grateful.*
 - [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/donate/?hosted_button_id=K2DRRKRFE583J)
 - Bitcon (BTC only): 1BApp5s16pEPk5oECtJJ7G8iYmbj7r2YrG
+
+
+
diff --git a/__init__.py b/__init__.py
index cff9d546..615dfcfe 100644
--- a/__init__.py
+++ b/__init__.py
@@ -15,13 +15,18 @@
 import bpy
 import os
 import atexit
+from operator import attrgetter
+
+from dmx.pymvr import *
 
 from dmx.fixture import *
 from dmx.group import *
 from dmx.universe import *
 from dmx.data import *
 from dmx.artnet import *
+from dmx.acn import DMX_sACN
 from dmx.network import *
+from dmx.logging import *
 
 from dmx.panels.setup import *
 from dmx.panels.dmx import *
@@ -50,10 +55,12 @@ class DMX(PropertyGroup):
     classes_base = (DMX_Param,
                     DMX_Model_Param,
                     DMX_Fixture_Object,
+                    DMX_Emitter_Material,
                     DMX_Fixture_Channel,
                     DMX_Fixture,
                     DMX_Group,
                     DMX_Universe,
+                    DMX_Value,
                     DMX_PT_Setup)
 
     # Classes to be registered
@@ -69,10 +76,11 @@ class DMX(PropertyGroup):
                 DMX_PT_DMX,
                 DMX_PT_DMX_Universes,
                 DMX_PT_DMX_ArtNet,
+                DMX_PT_DMX_LiveDMX,
                 DMX_OT_Setup_Volume_Create,
                 DMX_PT_Setup_Background,
                 DMX_PT_Setup_Volume,
-                DMX_PT_Setup_Models,
+                DMX_PT_Setup_Debug,
                 DMX_MT_Fixture,
                 DMX_MT_Fixture_Manufacturers,
                 DMX_MT_Fixture_Profiles,
@@ -80,10 +88,12 @@ class DMX(PropertyGroup):
                 DMX_OT_Fixture_Item,
                 DMX_OT_Fixture_Profiles,
                 DMX_OT_Fixture_Mode,
+                DMX_UL_LiveDMX_items,
                 DMX_OT_Fixture_Add,
                 DMX_OT_Fixture_Edit,
                 DMX_OT_Fixture_Remove,
                 DMX_OT_Fixture_Import_GDTF,
+                DMX_OT_Fixture_Import_MVR,
                 DMX_PT_Fixtures,
                 DMX_UL_Group,
                 DMX_MT_Group,
@@ -93,6 +103,9 @@ class DMX(PropertyGroup):
                 DMX_OT_Group_Remove,
                 DMX_PT_Groups,
                 DMX_OT_Programmer_DeselectAll,
+                DMX_OT_Programmer_SelectAll,
+                DMX_OT_Programmer_SelectInvert,
+                DMX_OT_Programmer_SelectEveryOther,
                 DMX_OT_Programmer_Clear,
                 DMX_OT_Programmer_SelectBodies,
                 DMX_OT_Programmer_SelectTargets,
@@ -141,6 +154,28 @@ def unregister():
         name = "DMX Groups",
         type = DMX_Universe)
 
+    def prepare_empty_buffer(self, context):
+        # Clear the buffer on change of every protocol
+        DMX_Data.prepare_empty_buffer()
+
+    selected_live_dmx_source : EnumProperty(
+        name = "DMX Source",
+        description="The network protocol for which to show live DMX values",
+        default = "BLENDERDMX",
+        items = network_options_list,
+        update = prepare_empty_buffer
+    )
+    selected_live_dmx_universe: IntProperty(
+        min = 0,
+            )
+
+    dmx_values: CollectionProperty(
+        name = "DMX buffer",
+        type = DMX_Value
+    )
+
+    dmx_value_index: IntProperty() # Just a fake value, we need as the live DMX UI Panel requires it
+
     # New DMX Scene
     # - Remove any previous DMX objects/collections
     # - Create DMX collection
@@ -183,6 +218,9 @@ def new(self):
     # - Allocate static universe data
     def linkFile(self):
         print("DMX", "Linking to file")
+        DMX_Log.enable(self.logging_level)
+        DMX_Log.log.info("DMX", "Linking to file")
+
 
         # Link pointer properties to file objects
         if ("DMX" in bpy.data.collections):
@@ -218,6 +256,9 @@ def linkFile(self):
         if (dmx.artnet_enabled and dmx.artnet_status != 'online'):
             dmx.artnet_enabled = False
             dmx.artnet_status = 'offline'
+        if (dmx.sacn_enabled and dmx.artnet_status != 'online'):
+            dmx.sacn_enabled = False
+            dmx.artnet_status = 'offline'
 
         # Rebuild group runtime dictionary (evaluating if this is gonna stay here)
         #DMX_Group.runtime = {}
@@ -261,13 +302,34 @@ def onBackgroundColor(self, context):
     # # Setup > Models > Display Pigtails
 
     def onDisplayPigtails(self, context):
-        self.updateDisplayPigtails()
+        for fixture in self.fixtures:
+            for obj in fixture.collection.objects:
+                if "Pigtail" in obj.name:
+                    obj.hide_set(not self.display_pigtails)
 
     display_pigtails: BoolProperty(
         name = "Display pigtails",
         default = False,
         update = onDisplayPigtails)
 
+    # # Logging levels
+
+    def onLoggingLevel(self, context):
+        DMX_Log.log.setLevel(self.logging_level)
+
+    logging_level: bpy.props.EnumProperty(
+        name= "Logging level",
+        description= "logging level",
+        default = "ERROR",
+        items= [
+                ('CRITICAL', "Critical", "", "TOOL_SETTINGS", 0),
+                ('ERROR', "Error", "", 'ERROR', 1),
+                ('WARNING', "Warning", "", "CANCEL", 2),
+                ('DEBUG', "Debug", "", "OUTLINER_OB_LIGHT",3),
+                ('INFO', "Info", "", "INFO", 4),
+        ],
+        update = onLoggingLevel
+        )
 
     # # Setup > Volume > Preview Volume
 
@@ -363,6 +425,18 @@ def onUniverseN(self, context):
         items = DMX_Network.cards()
     )
 
+    # # DMX > sACN > Enable
+
+    def onsACNEnable(self, context):
+        dmx = bpy.context.scene.dmx
+        if (self.sacn_enabled):
+            DMX_sACN.enable()
+            dmx.artnet_status = 'online'
+            
+        else:
+            DMX_sACN.disable()
+            dmx.artnet_status = 'online'
+            
     # # DMX > ArtNet > Enable
 
     def onArtNetEnable(self, context):
@@ -378,6 +452,12 @@ def onArtNetEnable(self, context):
         update = onArtNetEnable
     )
 
+    sacn_enabled : BoolProperty(
+        name = "Enable sACN Input",
+        description="Enables the input of DMX data throught sACN.",
+        default = False,
+        update = onsACNEnable
+    )
     # # DMX > ArtNet > Status
 
     artnet_status : EnumProperty(
@@ -535,11 +615,11 @@ def syncProgrammer(self):
 
     # # Fixtures
 
-    def addFixture(self, name, profile, universe, address, mode, gel_color):
+    def addFixture(self, name, profile, universe, address, mode, gel_color, display_beams, position=None):
         bpy.app.handlers.depsgraph_update_post.clear()
         dmx = bpy.context.scene.dmx
         dmx.fixtures.add()
-        dmx.fixtures[-1].build(name, profile, mode, universe, address, gel_color)
+        dmx.fixtures[-1].build(name, profile, mode, universe, address, gel_color, display_beams, position)
         bpy.app.handlers.depsgraph_update_post.append(onDepsgraph)
 
     def removeFixture(self, fixture):
@@ -571,6 +651,23 @@ def selectedFixtures(self):
                     break
         return selected
 
+    def addMVR(self, file_name):
+        mvr_scene = pymvr.GeneralSceneDescription(file_name)
+        current_path = os.path.dirname(os.path.realpath(__file__))
+        extract_to_folder_path = os.path.join(current_path, 'assets', 'profiles')
+        for layer_index, layer in enumerate(mvr_scene.layers):
+            for fixture_index, fixture in enumerate(layer.fixtures):
+                mvr_scene._package.extract(fixture.gdtf_spec, extract_to_folder_path)
+                self.ensureUniverseExists(fixture.addresses[0].universe)
+                self.addFixture(f"{fixture.name} {layer_index}-{fixture_index}", fixture.gdtf_spec, fixture.addresses[0].universe, fixture.addresses[0].address, fixture.gdtf_mode, (1.0,1.0,1.0,1.0), True, position=fixture.matrix.matrix)
+
+    def ensureUniverseExists(self, universe):
+        # Allocate universes to be able to control devices
+        dmx = bpy.context.scene.dmx
+        for _ in range(len(dmx.universes), universe+1):
+            self.addUniverse()
+        self.universes_n = len(self.universes)
+
     # # Groups
 
     def createGroup(self, name):
@@ -673,6 +770,7 @@ def onLoadFile(scene):
 
     # Stop ArtNet
     DMX_ArtNet.disable()
+    DMX_sACN.disable()
 
 @bpy.app.handlers.persistent
 def onUndo(scene):
@@ -706,10 +804,12 @@ def register():
     # since 2.91.0 unregister is called also on Blender exit
     if bpy.app.version <= (2, 91, 0):
         atexit.register(DMX_ArtNet.disable)
+        atexit.register(DMX_sACN.disable)
 
 def unregister():
     # Stop ArtNet
     DMX_ArtNet.disable()
+    DMX_sACN.disable()
 
     try:
         # Unregister Base Classes
diff --git a/acn.py b/acn.py
new file mode 100644
index 00000000..e116ca17
--- /dev/null
+++ b/acn.py
@@ -0,0 +1,66 @@
+import bpy
+from dmx.sacn import sACNreceiver
+from dmx.data import DMX_Data
+from dmx.logging import DMX_Log
+
+
+class DMX_sACN:
+
+    _instance = None
+
+    def __init__(self):
+        super(DMX_sACN, self).__init__()
+        self.data = None
+        self.receiver = sACNreceiver()
+        self._dmx = bpy.context.scene.dmx
+
+    def callback(packet):  # packet type: sacn.DataPacket
+        dmx = bpy.context.scene.dmx
+        if packet.universe >= len(dmx.universes):
+            DMX_Log.log.info("Not enough DMX universes set in BlenderDMX")
+            return
+        if not dmx.universes[packet.universe]:
+            DMX_Log.log.info("sACN universe doesn't exist in BlenderDMX")
+            return
+        if dmx.universes[packet.universe].input != "sACN":
+            DMX_Log.log.info("This DMX universe is not set to accept sACN data")
+            return
+        DMX_Data.set_universe(packet.universe, bytearray(packet.dmxData), "sACN")
+        try:
+            dmx.artnet_status = "online"
+        except Exception as e:
+            print(e)
+
+
+    @staticmethod
+    def enable():
+        DMX_sACN._instance = DMX_sACN()
+        dmx = bpy.context.scene.dmx
+        DMX_sACN._instance.receiver.start()  # start the receiving thread
+        DMX_Log.log.info("enabling ACN")
+
+        for universe in range(1, len(dmx.universes) + 1):
+            DMX_sACN._instance.receiver.register_listener(
+                "universe", DMX_sACN.callback, universe=universe
+            )
+        DMX_sACN._instance.receiver.join_multicast(1)
+        bpy.app.timers.register(DMX_sACN.run_render)
+        dmx.artnet_status = "listen"
+
+    @staticmethod
+    def disable():
+        dmx = bpy.context.scene.dmx
+        if DMX_sACN._instance:
+            DMX_sACN._instance.receiver.leave_multicast(1)
+            DMX_sACN._instance.receiver.remove_listener(DMX_sACN.callback)
+            DMX_sACN._instance.receiver.stop()
+            DMX_sACN._instance.data = None
+            dmx.artnet_status = "offline"
+
+        if bpy.app.timers.is_registered(DMX_sACN.run_render):
+            bpy.app.timers.unregister(DMX_sACN.run_render)
+
+    @staticmethod
+    def run_render():
+        bpy.context.scene.dmx.render()
+        return 1.0 / 60.0
diff --git a/artnet.py b/artnet.py
index 9967335a..5150d9fe 100644
--- a/artnet.py
+++ b/artnet.py
@@ -41,6 +41,7 @@ def build(udp_data):
         packet = ArtnetPacket()
         (packet.op_code, packet.ver, packet.sequence, packet.physical,
             packet.universe, packet.length) = unpack('!HHBBHH', udp_data[8:18])
+        (packet.universe,) = unpack('<H', udp_data[14:16])
 
         packet.data = unpack(
             '{0}s'.format(int(packet.length)),
@@ -81,7 +82,7 @@ def run(self):
                 if (self._dmx.universes[packet.universe].input != 'ARTNET'):
                     continue
                 
-                DMX_Data.set_universe(packet.universe, bytearray(packet.data))
+                DMX_Data.set_universe(packet.universe, bytearray(packet.data), "ARTNET")
                 
                 #print(packet)
             except Exception as e:
diff --git a/data.py b/data.py
index 052e4c25..5d8345b1 100644
--- a/data.py
+++ b/data.py
@@ -9,13 +9,50 @@
 #
 
 import bpy
+import time
+
+from dmx.logging import DMX_Log
+from bpy.types import (PropertyGroup)
+from bpy.props import (IntProperty)
+
+def update_callback(self, context):
+    # https://blender.stackexchange.com/questions/238441/force-redraw-add-on-custom-propery-in-n-panel-from-a-separate-thread
+    # The above (tag_redraw()) doesn't work, but it seems that even if this method is empty
+    # just the fact that there is an update callback on the DMX_Value.channel
+    # causes the UI to be refreshed periodically even when not in focus
+    return None
+
+class DMX_Value(PropertyGroup):
+    channel: IntProperty(
+        name = "DMX Value",
+        update=update_callback,
+        default = 0)
 
 class DMX_Data():
 
     _universes = []
+    _dmx = None # Cache access to the context.scene
+    _last_updated = None # Last update time of UI with live DMX values
+
+    @staticmethod
+    def prepare_empty_buffer():
+        # Prepare structure in the dmx_values collection, this is then used for the LiveDMX table
+        if DMX_Data._dmx is not None:
+            dmx = DMX_Data._dmx
+            dmx.dmx_values.clear()
+            buffer = [0]*512
+            for i in buffer:
+                dmx.dmx_values.add()
+                dmx.dmx_values[-1].channel=i
 
     @staticmethod
     def setup(universes):
+        try:
+            DMX_Data._dmx = bpy.context.scene.dmx
+        except:
+            pass
+        DMX_Data._last_updated = time.time() # Set some time instead of None
+        DMX_Data.prepare_empty_buffer()
         old_n = len(DMX_Data._universes)
         # shrinking (less universes then before)
         if (universes < old_n):
@@ -30,22 +67,40 @@ def setup(universes):
 
     @staticmethod
     def get(universe, addr, n):
-        if (universe > len(DMX_Data._universes)): return bytearray([0]*n)
+        if (universe >= len(DMX_Data._universes)): return bytearray([0]*n)
         if (addr + n > 512): return bytearray([0]*n)
-        return DMX_Data._universes[universe-1][addr-1:addr+n-1]
+        return DMX_Data._universes[universe][addr-1:addr+n-1]
     
     @staticmethod
     def set(universe, addr, val):
+        DMX_Log.log.debug((universe, addr, val))
         if (universe >= len(DMX_Data._universes)): return
         if (not bpy.context.scene.dmx.universes[universe]): return
         if (bpy.context.scene.dmx.universes[universe].input != 'BLENDERDMX'): return
-        DMX_Data._universes[universe-1][addr-1] = val
+        if val > 255: return
+
+        if DMX_Data._dmx is not None:
+            dmx = DMX_Data._dmx
+            if dmx.selected_live_dmx_source == "BLENDERDMX":
+                dmx = bpy.context.scene.dmx
+                dmx.dmx_values[addr-1].channel=val
+        DMX_Data._universes[universe][addr-1] = val
 
     @staticmethod
-    def set_universe(universe, data):
+    def set_universe(universe, data, source):
+        DMX_Log.log.debug((universe, data))
         if (universe >= len(DMX_Data._universes)):
             return
         if (DMX_Data._universes[universe] != data):
+            if DMX_Data._dmx is not None:
+                dmx = DMX_Data._dmx
+                if dmx.selected_live_dmx_source == source and dmx.selected_live_dmx_universe == universe:
+                    if DMX_Data._last_updated is not None and time.time() - DMX_Data._last_updated > 0.8:
+                        # We limit update by time, too fast updates were troubling Blender's UI
+                        for idx, val in enumerate(data):
+                            dmx.dmx_values[idx].channel = val
+                        DMX_Data._last_updated = time.time()
+                        print("update dmx")
             DMX_Data._universes[universe] = data
             return True
         return False
diff --git a/fixture.py b/fixture.py
index 0acbf459..868faff4 100644
--- a/fixture.py
+++ b/fixture.py
@@ -11,8 +11,10 @@
 
 from dmx.material import getEmitterMaterial
 from dmx.model import DMX_Model
+from dmx.logging import DMX_Log
 
 from dmx.param import DMX_Param, DMX_Model_Param
+from dmx import pygdtf
 
 from dmx.gdtf import DMX_GDTF
 from dmx.data import DMX_Data
@@ -20,6 +22,7 @@
 
 from bpy.props import (IntProperty,
                        FloatProperty,
+                       BoolProperty,
                        FloatVectorProperty,
                        PointerProperty,
                        StringProperty,
@@ -41,6 +44,11 @@ class DMX_Fixture_Object(PropertyGroup):
         name = "Fixture > Object",
         type = Object)
 
+class DMX_Emitter_Material(PropertyGroup):
+    material: PointerProperty(
+        name = "Emitter > Material",
+        type = Material)
+
 class DMX_Fixture_Channel(PropertyGroup):
     id: StringProperty(
         name = "Fixture > Channel > ID",
@@ -48,6 +56,9 @@ class DMX_Fixture_Channel(PropertyGroup):
     default: IntProperty(
         name = "Fixture > Channel > Default",
         default = 0)
+    geometry: StringProperty(
+        name = "Fixture > Geometry",
+        default = '')
 
 class DMX_Fixture(PropertyGroup):
 
@@ -67,9 +78,9 @@ class DMX_Fixture(PropertyGroup):
         type = DMX_Fixture_Object
     )
 
-    emitter_material: PointerProperty(
-        name = "Fixture > Emitter Material",
-        type = Material)
+    emitter_materials: CollectionProperty(
+        name = "Fixture > Materials",
+        type = DMX_Emitter_Material)
 
     # DMX properties
 
@@ -101,6 +112,11 @@ class DMX_Fixture(PropertyGroup):
         min = 1,
         max = 512)
         
+    display_beams: BoolProperty(
+        name = "Display beams",
+        description="Display beam projection and cone",
+        default = True)
+
     gel_color: FloatVectorProperty(
         name = "Gel Color",
         subtype = "COLOR",
@@ -109,10 +125,11 @@ class DMX_Fixture(PropertyGroup):
         max = 1.0,
         default = (1.0,1.0,1.0,1.0))
 
-    def build(self, name, profile, mode, universe, address, gel_color):
+    def build(self, name, profile, mode, universe, address, gel_color, display_beams, mvr_position = None):
 
         # (Edit) Store objects positions
         old_pos = {obj.name:obj.object.location.copy() for obj in self.objects}
+        old_rot = {obj.name:obj.object.rotation_euler.copy() for obj in self.objects}
         
         # (Edit) Collection with this name already exists, delete it
         if (self.name in bpy.data.collections):
@@ -129,6 +146,7 @@ def build(self, name, profile, mode, universe, address, gel_color):
         self.universe = universe
         self.address = address
         self.gel_color = list(gel_color)
+        self.display_beams = display_beams
 
         # (Edit) Clear links and channel cache
         self.lights.clear()
@@ -147,7 +165,7 @@ def build(self, name, profile, mode, universe, address, gel_color):
 
         # Import and deep copy Fixture Model Collection
         gdtf_profile = DMX_GDTF.loadProfile(profile)
-        model_collection = DMX_Model.getFixtureModelCollection(gdtf_profile)
+        model_collection = DMX_Model.getFixtureModelCollection(gdtf_profile, self.mode, self.display_beams)
         links = {}
         for obj in model_collection.objects:
             # Copy object
@@ -156,8 +174,9 @@ def build(self, name, profile, mode, universe, address, gel_color):
             if (obj.type == 'LIGHT'):
                 links[obj.name].data = obj.data.copy()
                 self.lights.add()
-                self.lights[-1].name = 'Light'
-                self.lights['Light'].object = links[obj.name]
+                light_name=f'Light{len(self.lights)}'
+                self.lights[-1].name = light_name
+                self.lights[light_name].object = links[obj.name]
             # Store reference to body, base and target
             if ('Base' in obj.name):
                 self.objects.add()
@@ -183,42 +202,61 @@ def build(self, name, profile, mode, universe, address, gel_color):
             for constraint in obj.constraints:
                 constraint.target = links[constraint.target.name]
 
-        # (Edit) Reload old position
+        # (Edit) Reload old positions and rotations
         bpy.context.view_layer.update()
         for obj in self.objects:
-            if (obj.name in old_pos):
+            if obj.name in old_pos:
                 obj.object.location = old_pos[obj.name]
-            elif (obj.name == 'Base'):
-                if ('Body' in old_pos):
-                    obj.object.location = old_pos['Body']
-            elif (obj.name == 'Body'):
-                if ('Base' in old_pos):
-                    obj.object.location = old_pos['Base']
+
+            if obj.name == 'Base':
+                if 'Base' in old_rot:
+                    obj.object.rotation_mode = 'XYZ'
+                    obj.object.rotation_euler = old_rot['Base']
+            elif obj.name == 'Body':
+                if 'Body' in old_rot:
+                    obj.object.rotation_mode = 'XYZ'
+                    obj.object.rotation_euler = old_rot['Body']
+
+        # Set position from MVR
+        if mvr_position is not None:
+            for obj in self.objects:
+                if (obj.name == 'Base'):
+                    obj.object.matrix_world=mvr_position
+                elif (obj.name == 'Body'):
+                    obj.object.matrix_world=mvr_position
 
         # Setup emitter
-        emitter = None
         for obj in self.collection.objects:
-            if ('Beam' in obj.name):
+            if "beam" in obj.name.lower():
                 emitter = obj
-        try:
-            assert emitter
-            self.emitter_material = getEmitterMaterial(name)
-            emitter.active_material = self.emitter_material
-            emitter.material_slots[0].link = 'OBJECT'
-            emitter.material_slots[0].material = self.emitter_material
-            emitter.material_slots[0].material.shadow_method = 'NONE' # eevee
-        except Exception as e:
-            print("Emitter required", e)
+                self.emitter_materials.add()
+                self.emitter_materials[-1].name = obj.name
+
+                emitter_material = getEmitterMaterial(obj.name)
+                emitter.active_material = emitter_material
+                emitter.material_slots[0].link = 'OBJECT'
+                emitter.material_slots[0].material = emitter_material
+                emitter.material_slots[0].material.shadow_method = 'NONE' # eevee
+                self.emitter_materials[-1].material = emitter_material
 
 
         # Link collection to DMX collection
         bpy.context.scene.dmx.collection.children.link(self.collection)
 
+        # Set Pigtail visibility
+        for obj in self.collection.objects:
+            if "Pigtail" in obj.name:
+                obj.hide_set(not bpy.context.scene.dmx.display_pigtails)
+
         # Build DMX channels cache
-        for ch in DMX_GDTF.getChannels(gdtf_profile, self.mode):
+        dmx_channels = pygdtf.utils.get_dmx_channels(gdtf_profile, self.mode)
+        # Merge all DMX breaks together
+        dmx_channels_flattened = [channel for break_channels in dmx_channels for channel in break_channels]
+        for ch in dmx_channels_flattened:
             self.channels.add()
             self.channels[-1].id = ch['id']
             self.channels[-1].default = ch['default']
+            self.channels[-1].geometry = ch['geometry']
         
         self.clear()
         bpy.context.scene.dmx.render()
@@ -228,10 +266,9 @@ def build(self, name, profile, mode, universe, address, gel_color):
     def setDMX(self, pvalues):
         channels = [c.id for c in self.channels]
         for param, value in pvalues.items():
-            if (param not in channels): 
-                continue
-            p = channels.index(param)
-            DMX_Data.set(self.universe, self.address+p, value)
+            for idx, channel in enumerate(channels):
+                if channel == param:
+                    DMX_Data.set(self.universe, self.address+idx, value)
 
     def render(self):
         channels = [c.id for c in self.channels]
@@ -240,20 +277,29 @@ def render(self):
         rgb = [None,None,None]
         cmy = [None,None,None]
         zoom = None
+        mixing={} #for now, only RGB mixing is per geometry
         for c in range(len(channels)):
+            geometry=self.channels[c].geometry
+            if geometry not in mixing.keys():
+                mixing[geometry]=[None, None, None]
             if (channels[c] == 'Dimmer'): self.updateDimmer(data[c])
-            elif (channels[c] == 'ColorAdd_R'): rgb[0] = data[c]
-            elif (channels[c] == 'ColorAdd_G'): rgb[1] = data[c]
-            elif (channels[c] == 'ColorAdd_B'): rgb[2] = data[c]
+            elif (channels[c] == 'ColorAdd_R'): mixing[geometry][0] = data[c]
+            elif (channels[c] == 'ColorAdd_G'): mixing[geometry][1] = data[c]
+            elif (channels[c] == 'ColorAdd_B'): mixing[geometry][2] = data[c]
             elif (channels[c] == 'ColorSub_C'): cmy[0] = data[c]
             elif (channels[c] == 'ColorSub_M'): cmy[1] = data[c]
             elif (channels[c] == 'ColorSub_Y'): cmy[2] = data[c]
             elif (channels[c] == 'Pan'): panTilt[0] = data[c]
             elif (channels[c] == 'Tilt'): panTilt[1] = data[c]
             elif (channels[c] == 'Zoom'): zoom = data[c]
-        
-        if (rgb[0] != None and rgb[1] != None and rgb[2] != None):
-            self.updateRGB(rgb)
+       
+        for geometry, rgb in mixing.items():
+            if (rgb[0] != None and rgb[1] != None and rgb[2] != None):
+                if len(mixing) == 1 or not self.light_object_for_geometry_exists(mixing): 
+                    # do not apply for simple devices as trickle down is not implemented...
+                    self.updateRGB(rgb, None)
+                else:
+                    self.updateRGB(rgb, geometry)
         
         if (cmy[0] != None and cmy[1] != None and cmy[2] != None):
             self.updateCMY(cmy)
@@ -263,18 +309,51 @@ def render(self):
         if (zoom != None):
             self.updateZoom(zoom)
 
+    def light_object_for_geometry_exists(self, mixing):
+        """Check if there is any light or emitter matching geometry name of a color attribute"""
+        for geo in mixing.keys():
+            for light in self.lights:
+                if geo in light.object.data.name:
+                    return True
+            for emitter_material in self.emitter_materials:
+                if geo in emitter_material.name:
+                    return True
+        return False
+
     def updateDimmer(self, dimmer):
-        self.emitter_material.node_tree.nodes[1].inputs[STRENGTH].default_value = 10*(dimmer/255.0)
-        for light in self.lights:
-            light.object.data.energy = (dimmer/255.0) * light.object.data['flux']
+        try:
+            for emitter_material in self.emitter_materials:
+                emitter_material.material.node_tree.nodes[1].inputs[STRENGTH].default_value = 10*(dimmer/255.0)
+            for light in self.lights:
+                light.object.data.energy = (dimmer/255.0) * light.object.data['flux']
+        except Exception as e:
+            print("Error updating dimmer", e)
+                
         return dimmer
 
-    def updateRGB(self, rgb):
-        rgb = [c/255.0-(1-gel) for (c, gel) in zip(rgb, self.gel_color[:3])]
-        #rgb = [c/255.0 for c in rgb]
-        self.emitter_material.node_tree.nodes[1].inputs[COLOR].default_value = rgb + [1]
-        for light in self.lights:
-            light.object.data.color = rgb
+    def updateRGB(self, rgb, geometry):
+        DMX_Log.log.info(("color change for geometry", geometry))
+        try:
+            rgb = [c/255.0-(1-gel) for (c, gel) in zip(rgb, self.gel_color[:3])]
+            #rgb = [c/255.0 for c in rgb]
+            for emitter_material in self.emitter_materials:
+                DMX_Log.log.info(("emitter:", emitter_material.name))
+                if geometry is not None:
+                    if f"{geometry}" in emitter_material.name:
+                        DMX_Log.log.info("matched emitter")
+                        emitter_material.material.node_tree.nodes[1].inputs[COLOR].default_value = rgb + [1]
+                else:
+                    emitter_material.material.node_tree.nodes[1].inputs[COLOR].default_value = rgb + [1]
+            for light in self.lights:
+                if geometry is not None:
+                    DMX_Log.log.info(("light:", light.object.data.name))
+                    if f"{geometry}" in light.object.data.name:
+                        DMX_Log.log.info("matched light")
+                        light.object.data.color = rgb
+                else:
+                    light.object.data.color = rgb
+        except Exception as e:
+            print("Error updating RGB", e)
         return rgb
 
 
@@ -283,15 +362,19 @@ def updateCMY(self, cmy):
         rgb=cmy_to_rgb(cmy)
         rgb = [c/255.0-(1-gel) for (c, gel) in zip(rgb, self.gel_color[:3])]
         #rgb = [c/255.0 for c in rgb]
-        self.emitter_material.node_tree.nodes[1].inputs[COLOR].default_value = rgb + [1]
+        for emitter_material in self.emitter_materials:
+            emitter_material.material.node_tree.nodes[1].inputs[COLOR].default_value = rgb + [1]
         for light in self.lights:
             light.object.data.color = rgb
         return cmy
 
     def updateZoom(self, zoom):
-        spot_size=zoom*3.1415/180.0
-        for light in self.lights:
-            light.object.data.spot_size=spot_size
+        try:
+            spot_size=zoom*3.1415/180.0
+            for light in self.lights:
+                light.object.data.spot_size=spot_size
+        except Exception as e:
+            print("Error updating zoom", e)
         return zoom
 
 
diff --git a/gdtf.py b/gdtf.py
index b1513a5c..369a4ebe 100644
--- a/gdtf.py
+++ b/gdtf.py
@@ -7,11 +7,12 @@
 
 import os
 import bpy
+import copy
 
-from mathutils import Euler
+from mathutils import Euler, Matrix
 
 from dmx import pygdtf
-
+from dmx.logging import DMX_Log
 from dmx.io_scene_3ds.import_3ds import load_3ds
 
 class DMX_GDTF():
@@ -57,56 +58,15 @@ def getProfileList(manufacturer):
 
     @staticmethod
     def getModes(profile):
+        """Returns an array, keys are mode names, value is channel count"""
         gdtf_profile = DMX_GDTF.loadProfile(profile)
-        return tuple([mode.name for mode in gdtf_profile.dmx_modes])
-
-    @staticmethod
-    def getValue(dmx_value, fine=False):
-        if (dmx_value.byte_count == 1): return dmx_value.value
-        f = dmx_value.value/255.0
-        msb = int(f)
-        if (not fine): return msb
-        lsb = int((f-msb)*255)
-        return lsb
-
-    @staticmethod
-    def getFootprint(channels):
-        #FIXME: for devices with GeometryReferences and DMX Brakes
-        dmx_offsets = []
-        for ch in channels:
-            if ch.offset is None:
-                continue
-            dmx_offsets.append(max(o for o in ch.offset))
-        return max(dmx_offsets)
-
-    @staticmethod
-    def getChannels(gdtf_profile, mode):
-        dmx_mode = None
-        for m in gdtf_profile.dmx_modes:
-            if (m.name == mode):
-                dmx_mode = m
-                break
-        if (not dmx_mode): return []
-        
-        channels = dmx_mode.dmx_channels
-        footprint = DMX_GDTF.getFootprint(channels)
-
-        dmx_channels = [{'id':'', 'default':0}]*footprint
-        for ch in channels:
-            if ch.offset is None:
-                continue
-            dmx_channels[ch.offset[0]-1] = {
-                'id':str(ch.logical_channels[0].channel_functions[0].attribute),
-                'default':DMX_GDTF.getValue(ch.logical_channels[0].channel_functions[0].default)
-            }
-            if (len(ch.offset) > 1):
-                dmx_channels[ch.offset[1]-1] = {
-                    'id':'+'+str(ch.logical_channels[0].channel_functions[0].attribute),
-                    'default':DMX_GDTF.getValue(ch.logical_channels[0].channel_functions[0].default, True)
-                }
-
-        return dmx_channels
-        
+        modes = {}
+        for mode in gdtf_profile.dmx_modes:
+            dmx_channels = pygdtf.utils.get_dmx_channels(gdtf_profile, mode.name)
+            dmx_channels_flattened = [channel for break_channels in dmx_channels for channel in break_channels]
+            modes[mode.name] = len(dmx_channels_flattened)
+        return modes
+ 
     @staticmethod
     def loadProfile(filename):
         path = os.path.join(DMX_GDTF.getProfilesPath(), filename)
@@ -143,28 +103,20 @@ def loadPrimitive(model):
         return obj
 
     @staticmethod
-    def loadGlb(profile, model):
-        #glbs must be loaded from a file, so we must unzip them
-        folder_path = os.path.dirname(os.path.realpath(__file__))
-        folder_path = os.path.join(folder_path, 'assets', 'models', profile.fixture_type_id)
-        filename = f"models/gltf/{model.file.name}.glb"
-        profile._package.extract(filename, folder_path)
-        file_glb=os.path.join(folder_path, filename)
-        bpy.ops.import_scene.gltf(filepath=file_glb)
-        obj = bpy.context.view_layer.objects.selected[0]
-        obj.users_collection[0].objects.unlink(obj)
-        obj.rotation_euler = Euler((0, 0, 0), 'XYZ')
-        obj.scale = (
-            obj.scale.x*model.length/obj.dimensions.x,
-            obj.scale.y*model.width/obj.dimensions.y,
-            obj.scale.z*model.height/obj.dimensions.z)
-        return obj
+    def loadModel(profile, model):
+        current_path = os.path.dirname(os.path.realpath(__file__))
+        extract_to_folder_path = os.path.join(current_path, 'assets', 'models', profile.fixture_type_id)
+        
+        if model.file.extension.lower() == "3ds":
+            inside_zip_path =f"models/3ds/{model.file.name}.{model.file.extension}"
+            file_name = profile._package.open(inside_zip_path)
+            load_3ds(file_name, bpy.context)
+        else:
+            inside_zip_path = f"models/gltf/{model.file.name}.{model.file.extension}"
+            profile._package.extract(inside_zip_path, extract_to_folder_path)
+            file_name=os.path.join(extract_to_folder_path, inside_zip_path)
+            bpy.ops.import_scene.gltf(filepath=file_name)
 
-    @staticmethod
-    def load3ds(profile, model):
-        filename = 'models/3ds/'+model.file.name+'.3ds'
-        file_3ds = profile._package.open(filename)
-        load_3ds(file_3ds, bpy.context)
         obj = bpy.context.view_layer.objects.selected[0]
         obj.users_collection[0].objects.unlink(obj)
         obj.rotation_euler = Euler((0, 0, 0), 'XYZ')
@@ -176,14 +128,37 @@ def load3ds(profile, model):
         return obj
 
     @staticmethod
-    def buildCollection(profile):
+    def buildCollection(profile, mode, display_beams):
 
         # Create model collection
-        collection = bpy.data.collections.new(DMX_GDTF.getName(profile))
-
-        # Load 3ds objects from the GDTF profile
+        collection = bpy.data.collections.new(DMX_GDTF.getName(profile, mode))
         objs = {}
-        for model in profile.models:
+        # Get root geometry reference from the selected DMX Mode
+        dmx_mode = pygdtf.utils.get_dmx_mode_by_name(profile, mode)
+        root_geometry = pygdtf.utils.get_geometry_by_name(profile, dmx_mode.geometry)
+
+        def load_geometries(geometry):
+            """Load 3d models, primitives and shapes"""
+            
+            if isinstance(geometry, pygdtf.GeometryReference):
+                reference = pygdtf.utils.get_geometry_by_name(profile, geometry.geometry)
+                geometry.model = reference.model
+
+            if geometry.model is None:
+                # Empty geometries are allowed as of GDTF 1.2
+                # If the size is 0, Blender will discard it, set it to something tiny
+                model=pygdtf.Model(name=geometry.name,
+                                   length=0.0001, width=0.0001, height=0.0001, primitive_type="Cube")
+                geometry.model = ""
+            else:
+                # Deepcopy the model because GeometryReference will modify the name
+                # Perhaps this could be done conditionally
+                # Also, we could maybe make a copy of the beam instance, if Blender supports it...
+                model = copy.deepcopy(pygdtf.utils.get_model_by_name(profile, geometry.model))
+
+            if isinstance(geometry, pygdtf.GeometryReference):
+                model.name=geometry.name
+
             obj = None
             primitive = str(model.primitive_type)
             # Normalize 1.1 PrimitiveTypes
@@ -194,12 +169,12 @@ def buildCollection(profile):
             # BlenderDMX primitives
             if (str(model.primitive_type) in ['Base','Conventional','Head','Yoke']):
                 obj = DMX_GDTF.loadPrimitive(model)
-            # 'Undefined': load from 3ds
+            # 'Undefined': load from 3d
             elif (str(model.primitive_type) == 'Undefined'):
-                if f"models/gltf/{model.file.name}.glb" in profile._package.namelist():
-                    obj = DMX_GDTF.loadGlb(profile, model)
-                else:
-                    obj = DMX_GDTF.load3ds(profile, model)
+                try:
+                    obj = DMX_GDTF.loadModel(profile, model)
+                except Exception as e:
+                    print("Error importing 3D model:", e)
             # Blender primitives
             else:
                 obj = DMX_GDTF.loadBlenderPrimitive(model)
@@ -209,66 +184,82 @@ def buildCollection(profile):
                 objs[model.name] = obj
                 obj.hide_select = True
 
-        # Recursively update object position, rotation and scale
-        def updateGeom(geom, d=0):
-            if (d > 0):
-                # Add child position
-                position = [geom.position.matrix[c][3] for c in range(3)]
-                obj_child = objs[geom.name]
-                obj_child.location[0] += position[0]
-                obj_child.location[1] += position[1]
-                obj_child.location[2] += position[2]
-                scale = [geom.position.matrix[c][c] for c in range(3)]
-                obj_child.scale[0] *= scale[0]
-                obj_child.scale[1] *= scale[1]
-                obj_child.scale[2] *= scale[2]
-                if 'Pigtail' in geom.model:
-                    if not bpy.context.scene.dmx.display_pigtails:
-                        obj_child.scale[0] *= 0
-                        obj_child.scale[1] *= 0
-                        obj_child.scale[2] *= 0
-
-                # Beam geometry: add light source and emitter material
-                if (isinstance(geom, pygdtf.GeometryBeam)):
-                    obj_child.visible_shadow = False
-                    light_data = bpy.data.lights.new(name="Spot", type='SPOT')
-                    light_data['flux'] = geom.luminous_flux
-                    light_data.energy = 0
-                    light_data.spot_size = geom.beam_angle*3.1415/180.0
-                    light_data.shadow_soft_size = geom.beam_radius
-                    light_object = bpy.data.objects.new(name="Spot", object_data=light_data)
-                    light_object.location = obj_child.location
-                    light_object.hide_select = True
-                    constraint = light_object.constraints.new('CHILD_OF')
-                    constraint.target = obj_child
-                    collection.objects.link(light_object)
-
-            if (len(geom.geometries) > 0):
-                for child_geom in geom.geometries:
-                    if (d > 0):
-                        # Constrain child to parent
-                        obj_parent = objs[geom.name]
-                        if (not child_geom.name in objs): continue
-                        obj_child = objs[child_geom.name]
-                        constraint = obj_child.constraints.new('CHILD_OF')
-                        constraint.target = obj_parent
-                        constraint.use_scale_x = False
-                        constraint.use_scale_y = False
-                        constraint.use_scale_z = False
-                        # Add parent position
-                        position = [geom.position.matrix[c][3] for c in range(3)]
-                        obj_child.location[0] += obj_parent.location[0]
-                        obj_child.location[1] += obj_parent.location[1]
-                        obj_child.location[2] += obj_parent.location[2]
-                    try:
-                        updateGeom(child_geom, d+1)
-                    except Exception as e:
-                        print("Error during recursive geometry updates", e), child_geom, d
-
-        try:
-            updateGeom(profile)
-        except Exception as e:
-            print("Error during geometry updates", e, profile)
+            if hasattr(geometry, "geometries"):
+                for sub_geometry in geometry.geometries:
+                    load_geometries(sub_geometry)
+
+
+        def add_child_position(geometry):
+            """Add a child, create a light source and emitter material for beams"""
+            obj_child = objs[geometry.name]
+
+            position = Matrix(geometry.position.matrix).to_translation()
+            obj_child.location[0] += (position[0]*-1) # bug in the pygdtf?
+            obj_child.location[1] += position[1]
+            obj_child.location[2] += position[2]
+
+            obj_child.rotation_mode = "XYZ"
+            obj_child.rotation_euler = Matrix(geometry.position.matrix).to_euler('XYZ')
+
+            scale=Matrix(geometry.position.matrix).to_scale()
+            obj_child.scale[0] *= scale[0]
+            obj_child.scale[1] *= scale[1]
+            obj_child.scale[2] *= scale[2]
+
+            if isinstance(geometry, (pygdtf.GeometryBeam, pygdtf.GeometryReference)):
+                if isinstance(geometry, pygdtf.GeometryReference):
+                    geometry = pygdtf.utils.get_geometry_by_name(profile, geometry.geometry)
+
+                if "beam" not in obj_child.name.lower():
+                    obj_child.name=f"Beam {obj_child.name}"
+
+                if not display_beams: # Don't even create beam objects to save resources
+                    return
+
+                obj_child.visible_shadow = False
+                light_data = bpy.data.lights.new(name=f"Spot {obj_child.name}", type='SPOT')
+                light_data['flux'] = geometry.luminous_flux
+                light_data.energy = light_data['flux'] #set by default to full brightness for devices without dimmer
+                light_data.spot_size = geometry.beam_angle
+                light_data.spot_size = geometry.beam_angle*3.1415/180.0
+                light_data.shadow_soft_size = geometry.beam_radius
+                light_object = bpy.data.objects.new(name=f"Spot", object_data=light_data)
+                light_object.location = obj_child.location
+                light_object.hide_select = True
+                constraint = light_object.constraints.new('CHILD_OF')
+                constraint.target = obj_child
+                collection.objects.link(light_object)
+
+        def constraint_child_to_parent(parent_geometry, child_geometry):
+            obj_parent = objs[parent_geometry.name]
+            if (not child_geometry.name in objs): return
+            obj_child = objs[child_geometry.name]
+            constraint = obj_child.constraints.new('CHILD_OF')
+            constraint.target = obj_parent
+            constraint.use_scale_x = False
+            constraint.use_scale_y = False
+            constraint.use_scale_z = False
+            # Add parent position
+            position = [parent_geometry.position.matrix[c][3] for c in range(3)]
+            obj_child.location[0] += obj_parent.location[0]
+            obj_child.location[1] += obj_parent.location[1]
+            obj_child.location[2] += obj_parent.location[2]
+
+        def update_geometry(geometry):
+            """Recursively update objects position, rotation and scale
+               and define parent/child constraints"""
+
+            add_child_position(geometry)
+
+            if hasattr(geometry, "geometries"):
+                if len(geometry.geometries) > 0:
+                    for child_geometry in geometry.geometries:
+                        constraint_child_to_parent(geometry, child_geometry)
+                        update_geometry(child_geometry)
+
+        # Load 3d objects from the GDTF profile
+        load_geometries(root_geometry)
+        update_geometry(root_geometry)
 
         # Add target for manipulating fixture
         target = bpy.data.objects.new(name="Target", object_data=None)
@@ -314,6 +305,7 @@ def updateGeom(geom, d=0):
             # Make body selectable
             objs['Base'].hide_select = False
 
+
         # Link objects to collection
         for name, obj in objs.items():
             collection.objects.link(obj)
@@ -321,5 +313,6 @@ def updateGeom(geom, d=0):
         return collection
 
     @staticmethod
-    def getName(profile):
-        return profile.manufacturer + ", " + profile.name + ", " + (profile.revisions[-1].text if len(profile.revisions) else '')
+    def getName(profile, dmx_mode):
+        revision = profile.revisions[-1].text if len(profile.revisions) else ''
+        return f"{profile.manufacturer}, {profile.name}, {dmx_mode}, {revision}"
diff --git a/logging.py b/logging.py
new file mode 100644
index 00000000..03c6ccce
--- /dev/null
+++ b/logging.py
@@ -0,0 +1,31 @@
+import logging
+
+
+class DMX_Log:
+    log = None
+
+    def __init__(self):
+        super(DMX_Log, self).__init__()
+
+    @staticmethod
+    def enable(level):
+        # DMX_Log._instance = DMX_Log()
+        log = logging.getLogger("blenderDMX")
+        log.setLevel(level)
+
+        # file log
+        # log_handler = logging.FileHandler(filename="blenderDMX.log", encoding="utf-8", mode="a")
+        # log_handler.setFormatter(
+        #    logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s")
+        # )
+
+        # console log
+        log_formatter = logging.Formatter(
+            fmt="%(asctime)s.%(msecs)03d %(levelname)s %(module)s - %(funcName)s %(lineno)d: %(message)s",
+            datefmt="%Y-%m-%d %H:%M:%S",
+        )
+        log_handler2 = logging.StreamHandler()
+        log_handler2.setFormatter(log_formatter)
+        # log.addHandler(log_handler)
+        log.addHandler(log_handler2)
+        DMX_Log.log = log
diff --git a/model.py b/model.py
index c095d009..19e700dc 100644
--- a/model.py
+++ b/model.py
@@ -18,13 +18,13 @@ class DMX_Model():
     #   This collection is then deep-copied by the Fixture class
     #   to create a fixture collection.
     @staticmethod
-    def getFixtureModelCollection(profile):
+    def getFixtureModelCollection(profile, dmx_mode, display_beams):
 
         # Make sure the profile was passed as an argument, otherwise return None
         if (profile == None):
             return None
 
-        name = DMX_GDTF.getName(profile)
+        name = DMX_GDTF.getName(profile, dmx_mode)
 
         # If the fixture collection was already imported for this model
         # just return it
@@ -32,4 +32,4 @@ def getFixtureModelCollection(profile):
             return bpy.data.collections[name]
 
         # Otherwise, build it from profile
-        return DMX_GDTF.buildCollection(profile)
+        return DMX_GDTF.buildCollection(profile, dmx_mode, display_beams)
diff --git a/panels/dmx.py b/panels/dmx.py
index 6ae027c0..47c1372d 100644
--- a/panels/dmx.py
+++ b/panels/dmx.py
@@ -8,7 +8,9 @@
 #
 
 import bpy
+from dmx.data import DMX_Data
 from bpy.props import (PointerProperty,
+                       EnumProperty,
                        StringProperty)
 
 from bpy.types import (Panel,
@@ -105,7 +107,7 @@ def draw(self, context):
         #layout.menu("dmx.menu.universe", text="...", icon="FILE_VOLUME")
 
 class DMX_PT_DMX_ArtNet(Panel):
-    bl_label = "ArtNet"
+    bl_label = "Network"
     bl_idname = "DMX_PT_DMX_ArtNet"
     bl_parent_id = "DMX_PT_DMX"
     bl_space_type = "VIEW_3D"
@@ -124,10 +126,39 @@ def draw(self, context):
         
         row = layout.row()
         row.prop(dmx, "artnet_enabled")
+        row = layout.row()
+        row.prop(dmx, "sacn_enabled")
         row.enabled = (dmx.artnet_status == 'offline' or dmx.artnet_status == 'listen' or dmx.artnet_status == 'online')
 
         layout.label(text='Status: ' + layout.enum_item_name(dmx, 'artnet_status', dmx.artnet_status))
 
+class DMX_UL_LiveDMX_items(UIList):
+    def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
+        layout.alignment = 'CENTER'
+        layout.label(text=f"{index+1}: {item.channel}")
+
+    def invoke(self, context, event):
+        pass
+
+class DMX_PT_DMX_LiveDMX(Panel):
+    bl_label = "Live DMX"
+    bl_idname = "DMX_PT_DMX_LiveDMX"
+    bl_parent_id = "DMX_PT_DMX"
+    bl_space_type = "VIEW_3D"
+    bl_region_type = "UI"
+    bl_category = "DMX"
+    bl_context = "objectmode"
+    bl_options = {'DEFAULT_CLOSED'}
+
+
+    def draw(self, context):
+        layout = self.layout
+        dmx = context.scene.dmx
+
+        row = layout.row()
+        row.prop(dmx, "selected_live_dmx_source", text="Source")
+        row.prop(dmx, "selected_live_dmx_universe", text="Universe")
+        layout.template_list("DMX_UL_LiveDMX_items", "", dmx, "dmx_values", dmx, "dmx_value_index", type='GRID')
 
 # Panel #
 
diff --git a/panels/fixtures.py b/panels/fixtures.py
index 382bdf60..e4542605 100644
--- a/panels/fixtures.py
+++ b/panels/fixtures.py
@@ -10,9 +10,11 @@
 import bpy
 import os
 import shutil
+from dmx import pygdtf
 
 from bpy.props import (IntProperty,
                        FloatProperty,
+                       BoolProperty,
                        FloatVectorProperty,
                        EnumProperty,
                        StringProperty,
@@ -63,6 +65,10 @@ def draw(self, context):
         # "Import GDTF Profile"
         row = layout.row()
         row.operator("dmx.import_gdtf_profile", text="Import GDTF Profile", icon="IMPORT")
+        
+        # "Import MVR scene"
+        row = layout.row()
+        row.operator("dmx.import_mvr_scene", text="Import MVR scene", icon="IMPORT")
 
 
 class DMX_MT_Fixture_Manufacturers(Menu):
@@ -104,10 +110,10 @@ def draw(self, context):
         dmx = scene.dmx
         profile = context.add_edit_panel.profile
         if (not profile): return
-        for mode in DMX_GDTF.getModes(profile):
+        for mode, channel_count in DMX_GDTF.getModes(profile).items():
             row = layout.row()
             row.context_pointer_set("add_edit_panel", context.add_edit_panel)
-            row.operator(DMX_OT_Fixture_Mode.bl_idname, text=mode).mode = mode
+            row.operator(DMX_OT_Fixture_Mode.bl_idname, text=f"{mode}, {channel_count} channels").mode = mode
 
 # Operators #
 
@@ -152,7 +158,8 @@ class DMX_Fixture_AddEdit():
 
     def onProfile(self, context):
         if hasattr(context,'add_edit_panel'):
-            context.add_edit_panel.mode = DMX_GDTF.getModes(context.add_edit_panel.profile)[0]
+            mode, channel_count = list(DMX_GDTF.getModes(context.add_edit_panel.profile).items())[0]
+            context.add_edit_panel.mode =f"{mode}"
 
     profile: StringProperty(
         name = "Profile",
@@ -193,11 +200,21 @@ def onProfile(self, context):
         max = 1.0,
         default = (1.0,1.0,1.0,1.0))
 
+    display_beams: BoolProperty(
+        name = "Display beams",
+        description="Display beam projection and cone",
+        default = True)
+
+    re_address_only: BoolProperty(
+        name = "Re-address only",
+        description="Do not rebuild the model structure",
+        default = False)
+
     units: IntProperty(
         name = "Units",
         description = "How many units of this light to add",
         default = 1,
-        min = 1,
+        min = 0,
         max = 1024)
 
     def draw(self, context):
@@ -208,7 +225,10 @@ def draw(self, context):
         text_profile = "GDTF Profile"
         if (self.profile != ""):
             text_profile = self.profile[:-5].replace('_',' ').split('@')
-            text_profile = text_profile[0] + " > " + text_profile[1]
+            if len(text_profile) > 1:
+                text_profile = text_profile[0] + " > " + text_profile[1]
+            else:
+                text_profile = "Unknown manufacturer" + " > " + text_profile[0]
         col.menu("DMX_MT_Fixture_Manufacturers", text = text_profile)
         text_mode = "DMX Mode"
         if (self.mode != ""):
@@ -216,9 +236,13 @@ def draw(self, context):
         col.menu("DMX_MT_Fixture_Mode", text = text_mode)
         col.prop(self, "universe")
         col.prop(self, "address")
-        col.prop(self, "gel_color")
-        if (self.units > 0):
-            col.prop(self, "units")
+        if self.units == 0:                   # Edit fixtures:
+            col.prop(self, "re_address_only") #     Be default, only change address, don't rebuild models (slow)
+        else:                                 # Adding new fixtures:
+            col.prop(self, "units")           #     Allow to define how many
+        if not self.re_address_only:          # When adding and editing:
+            col.prop(self, "display_beams")   #     Allow not to create and draw Beams (faster, only for emitter views)
+            col.prop(self, "gel_color")       #     This works when both adding AND when editing
 
 class DMX_OT_Fixture_Add(DMX_Fixture_AddEdit, Operator):
     bl_label = "DMX: Add Fixture"
@@ -239,7 +263,7 @@ def execute(self, context):
             self.report({'ERROR'}, "No DMX Mode selected.")
             return {'CANCELLED'}
         for i in range(self.units):
-            dmx.addFixture(self.name+" "+str(i+1), self.profile, self.universe, self.address, self.mode, self.gel_color)
+            dmx.addFixture(self.name+" "+str(i+1), self.profile, self.universe, self.address, self.mode, self.gel_color, self.display_beams)
         return {'FINISHED'}
 
     def invoke(self, context, event):
@@ -264,10 +288,15 @@ def execute(self, context):
             fixture = selected[0]
             if (self.name != fixture.name and self.name in bpy.data.collections):
                 return {'CANCELLED'}
-            fixture.build(self.name, self.profile, self.mode, self.universe, self.address, self.gel_color)
+            if not self.re_address_only:
+                fixture.build(self.name, self.profile, self.mode, self.universe, self.address, self.gel_color, self.display_beams)
+            else:
+                fixture.address = self.address
+                fixture.universe = self.universe
         # Multiple fixtures
         else:
             address = self.address
+            universe = self.universe
             for i, fixture in enumerate(selected):
                 name = self.name + ' ' + str(i+1)
                 if (name != fixture.name and name in bpy.data.collections):
@@ -276,8 +305,18 @@ def execute(self, context):
                 name = (self.name + ' ' + str(i+1)) if (self.name != '*') else fixture.name
                 profile = self.profile if (self.profile != '') else fixture.profile
                 mode = self.mode if (self.mode != '') else fixture.mode
-                fixture.build(name, profile, mode, self.universe, address, self.gel_color)
-                address += len(fixture.channels)
+                if not self.re_address_only:
+                    fixture.build(name, profile, mode, self.universe, address, self.gel_color, self.display_beams)
+                else:
+                    fixture.address = address
+                    fixture.universe = universe
+                if (address + len(fixture.channels)) > 512:
+                    universe += 1
+                    address = 1
+                else:
+                    address += len(fixture.channels)
+
+
         return {'FINISHED'}
 
     def invoke(self, context, event):
@@ -293,6 +332,8 @@ def invoke(self, context, event):
             self.address = fixture.address
             self.mode = fixture.mode
             self.gel_color = fixture.gel_color
+            self.re_address_only = True
+            self.display_beams = fixture.display_beams
             self.units = 0
         # Multiple fixtures
         else:
@@ -303,6 +344,8 @@ def invoke(self, context, event):
             self.mode = ''
             self.gel_color = (255,255,255,255)
             self.units = 0
+            self.display_beams = True
+            self.re_address_only = True
 
         wm = context.window_manager
         return wm.invoke_props_dialog(self)
@@ -362,6 +405,49 @@ def execute(self, context):
         self.report({'WARNING'}, 'Restart Blender to load the profiles.')
         return {'FINISHED'}
 
+
+class DMX_OT_Fixture_Import_MVR(Operator):
+    bl_label = "Import MVR scene"
+    bl_idname = "dmx.import_mvr_scene"
+    bl_description = "Import fixtures from MVR scene file"
+    bl_options = {'UNDO'}
+
+    filter_glob: StringProperty(default="*.mvr", options={'HIDDEN'})
+
+    directory: StringProperty(
+        name="File Path",
+        maxlen= 1024,
+        default= "" )
+
+    files: CollectionProperty(
+        name="Files",
+        type=bpy.types.OperatorFileListElement
+    )
+
+    def draw(self, context):
+        layout = self.layout
+        col = layout.column()
+        col.prop(self, "files")
+
+    def invoke(self, context, event):
+        wm = context.window_manager
+        wm.fileselect_add(self)
+        return {'RUNNING_MODAL'}
+
+    def execute(self, context):
+        folder_path = os.path.dirname(os.path.realpath(__file__))
+        folder_path = os.path.join(folder_path, '..', 'assets', 'profiles')
+        for file in self.files:
+            file_path = os.path.join(self.directory, file.name)
+            print(f'Processing MVR file: {file_path}')
+            dmx = context.scene.dmx
+            dmx.addMVR(file_path)
+            #shutil.copy(file_path, folder_path)
+        # https://developer.blender.org/T86803
+        #self.report({'WARNING'}, 'Restart Blender to load the profiles.')
+        return {'FINISHED'}
+
+
 # Panel #
 
 class DMX_OT_Fixture_Item(Operator):
diff --git a/panels/programmer.py b/panels/programmer.py
index 53c647ca..40f526e0 100644
--- a/panels/programmer.py
+++ b/panels/programmer.py
@@ -25,6 +25,54 @@ def execute(self, context):
         bpy.ops.object.select_all(action='DESELECT')
         return {'FINISHED'}
 
+class DMX_OT_Programmer_SelectAll(Operator):
+    bl_label = "DMX > Programmer > Select All"
+    bl_idname = "dmx.select_all"
+    bl_description = "Select every object in the Scene"
+    bl_options = {'UNDO'}
+
+    def execute(self, context):
+        dmx = context.scene.dmx
+        for fixture in dmx.fixtures:
+            fixture.select()
+        return {'FINISHED'}
+
+class DMX_OT_Programmer_SelectInvert(Operator):
+    bl_label = "DMX > Programmer > Invert selection"
+    bl_idname = "dmx.select_invert"
+    bl_description = "Invert the selection"
+    bl_options = {'UNDO'}
+
+    def execute(self, context):
+        dmx = context.scene.dmx
+        selected = []
+        for fixture in dmx.fixtures:
+            for obj in fixture.collection.objects:
+                if (obj in bpy.context.selected_objects):
+                    selected.append(fixture)
+                    fixture.unselect() 
+
+        for fixture in dmx.fixtures:
+            if fixture not in selected:
+                fixture.select()
+
+        return {'FINISHED'}
+
+class DMX_OT_Programmer_SelectEveryOther(Operator):
+    bl_label = "DMX > Programmer > Select every other light"
+    bl_idname = "dmx.select_every_other"
+    bl_description = "Select every other light"
+    bl_options = {'UNDO'}
+
+    def execute(self, context):
+        bpy.ops.object.select_all(action='DESELECT')
+        dmx = context.scene.dmx
+        for idx, fixture in enumerate(dmx.fixtures):
+            if idx % 2 == 0:
+                fixture.select()
+
+        return {'FINISHED'}
+
 class DMX_OT_Programmer_Clear(Operator):
     bl_label = "DMX > Programmer > Clear"
     bl_idname = "dmx.clear"
@@ -113,18 +161,20 @@ def draw(self, context):
         scene = context.scene
         dmx = scene.dmx
 
-        selected = len(bpy.context.selected_objects) > 0
 
         row = layout.row()
-        row.operator("dmx.deselect_all", text="Deselect All")
-        row.enabled = selected
+        row.operator("dmx.select_all", text='', icon='SELECT_EXTEND')
+        row.operator("dmx.select_invert", text='', icon='SELECT_SUBTRACT')
+        row.operator("dmx.select_every_other", text='', icon='SELECT_INTERSECT')
+        row.operator("dmx.deselect_all", text='', icon='SELECT_SET')
 
+        selected = len(bpy.context.selected_objects) > 0
         row = layout.row()
         row.operator("dmx.select_bodies", text="Bodies")
         row.operator("dmx.select_targets", text="Targets")
         row.enabled = selected
 
-        layout.template_color_picker(scene.dmx,"programmer_color")
+        layout.template_color_picker(scene.dmx,"programmer_color", value_slider=True)
         layout.prop(scene.dmx,"programmer_dimmer", text="Dimmer")
 
         layout.prop(scene.dmx,"programmer_pan", text="Pan")
diff --git a/panels/setup.py b/panels/setup.py
index c53f5ea9..ca4417b9 100644
--- a/panels/setup.py
+++ b/panels/setup.py
@@ -111,9 +111,9 @@ def draw(self, context):
         row.prop(context.scene.dmx, 'volume_density')
         row.enabled = (dmx.volume != None)
 
-class DMX_PT_Setup_Models(Panel):
-    bl_label = "Models"
-    bl_idname = "DMX_PT_Setup_Models"
+class DMX_PT_Setup_Debug(Panel):
+    bl_label = "Extras"
+    bl_idname = "DMX_PT_Setup_Debug"
     bl_parent_id = "DMX_PT_Setup"
     bl_space_type = "VIEW_3D"
     bl_region_type = "UI"
@@ -124,7 +124,10 @@ class DMX_PT_Setup_Models(Panel):
     def draw(self, context):
         layout = self.layout
         dmx = context.scene.dmx
-        layout.prop(context.scene.dmx,'display_pigtails')
+        row = layout.row()
+        row.prop(context.scene.dmx,'display_pigtails')
+        row = layout.row()
+        row.prop(context.scene.dmx, 'logging_level')
 
 # Panel #
 
diff --git a/pygdtf/__init__.py b/pygdtf/__init__.py
index bf6037c5..a8364a6e 100644
--- a/pygdtf/__init__.py
+++ b/pygdtf/__init__.py
@@ -3,6 +3,7 @@
 from xml.etree.ElementTree import Element
 import zipfile
 from dmx.pygdtf.value import *
+from .utils import *
 
 
 # Standard predefined colour spaces: R, G, B, W-P
@@ -104,6 +105,12 @@ def _read_xml(self):
         model_collect = self._root.find('Models')
         if model_collect:
             self.models = [Model(xml_node=i) for i in model_collect.findall('Model')]
+        for model in self.models:
+            if f"models/gltf/{model.file.name}.glb" in self._package.namelist():
+                model.file.extension='glb'
+            else:
+                model.file.extension='3ds'
+
         self.geometries = []
         geometry_collect = self._root.find('Geometries')
         if geometry_collect:
@@ -123,6 +130,7 @@ def _read_xml(self):
                 self.geometries.append(GeometryBeam(xml_node=i))
             for i in geometry_collect.findall('GeometryReference'):
                 self.geometries.append(GeometryReference(xml_node=i))
+        
         dmx_mode_collect = self._root.find('DMXModes')
         if dmx_mode_collect:
             self.dmx_modes = [DmxMode(xml_node=i) for i in dmx_mode_collect.findall('DMXMode')]
@@ -134,18 +142,6 @@ def _read_xml(self):
         else:
             self.revisions = []
 
-    def get_geometry_by_type(self, geometry_class):
-        """Recursively find all geometries of a given type"""
-        def iterate_geometries(collector):
-            for g in collector.geometries:
-                if type(g) == geometry_class:
-                    matched.append(g)
-                iterate_geometries(g)
-        matched = []
-        iterate_geometries(self)
-        return matched
-
-
 class BaseNode:
 
     def __init__(self, xml_node: 'Element' = None):
@@ -466,6 +462,10 @@ def _read_xml(self, xml_node: 'Element'):
         for i in xml_node.findall('GeometryReference'):
             self.geometries.append(GeometryReference(xml_node=i))
 
+    def __str__(self):
+        return f"{self.name} ({self.model})"
+
+
 
 class GeometryAxis(Geometry):
     pass
@@ -535,6 +535,8 @@ def _read_xml(self, xml_node: 'Element'):
         self.model = xml_node.attrib.get('Model')
         self.breaks = [Break(xml_node=i) for i in xml_node.findall('Break')]
 
+    def __str__(self):
+        return f"{self.name} ({self.model})"
 
 class Break(BaseNode):
 
diff --git a/pygdtf/utils/__init__.py b/pygdtf/utils/__init__.py
new file mode 100644
index 00000000..bd97bf24
--- /dev/null
+++ b/pygdtf/utils/__init__.py
@@ -0,0 +1,167 @@
+from dmx import pygdtf
+from typing import List, Dict
+
+
+def getValue(dmx_value, fine=False):
+    if dmx_value.byte_count == 1:
+        return dmx_value.value
+    f = dmx_value.value / 255.0
+    msb = int(f)
+    if not fine:
+        return msb
+    lsb = int((f - msb) * 255)
+    return lsb
+
+
+def get_dmx_mode_by_name(gdtf_profile: 'pygdtf.FixtureType' = None, mode_name: str = None) -> 'pygdtf.DmxMode':
+    """Find mode by name"""
+    for mode in gdtf_profile.dmx_modes:
+        if mode.name == mode_name:
+            return mode
+
+
+def get_geometry_by_name(gdtf_profile: 'pygdtf.FixtureType' = None, geometry_name: str = None) -> 'pygdtf.Geometry':
+    """Recursively find a geometry of a given name"""
+
+    def iterate_geometries(collector):
+        if collector.name == geometry_name:
+            matched.append(collector)
+        for g in collector.geometries:
+            if g.name == geometry_name:
+                matched.append(g)
+            if hasattr(g, "geometries"):
+                iterate_geometries(g)
+
+    matched = []
+    iterate_geometries(gdtf_profile)
+    if matched:
+        return matched[0]
+
+
+def get_geometry_by_type(root_geometry: 'pygdtf.Geometry' = None, geometry_class: 'pygdtf.Geometry' = None) -> List['pygdtf.Geometry']:
+    """Recursively find all geometries of a given type"""
+
+    def iterate_geometries(collector):
+        for g in collector.geometries:
+            if type(g) == geometry_class:
+                matched.append(g)
+            if hasattr(g, "geometries"):
+                iterate_geometries(g)
+
+    matched = []
+    iterate_geometries(root_geometry)
+    return matched
+
+
+def get_model_by_name(gdtf_profile: 'pygdtf.FixtureType' = None, model_name: str = None) -> 'pygdtf.Model':
+    """Find model by name"""
+    for model in gdtf_profile.models:
+        if model.name == model_name:
+            return model
+
+
+def get_channels_by_geometry(geometry_name: str = None, channels: List['pygdtf.DmxChannel'] = None) -> List['pygdtf.DmxChannel']:
+    """Find channels for a given geometry"""
+    matched = []
+    for channel in channels:
+        if channel.geometry == geometry_name:
+            matched.append(channel)
+
+    return matched
+
+
+def get_address_by_break(dmx_breaks: 'pygdtf.Break' = None, value: int = 1) -> 'pygdtf.DmxAddress':
+    """Return DMX address for a given DMX break"""
+    for item in dmx_breaks:
+        if item.dmx_break == value:
+            return item.dmx_offset
+
+
+def get_channels_for_geometry(gdtf_profile: 'pygdtf.FixtureType' = None, geometry: 'pygdtf.Geometry' = None, dmx_channels: List['pygdtf.DmxChannel'] = None, channel_list: List['pygdtf.DmxChannel'] = None) -> List['pygdtf.DmxChannel']:
+    """Get all channels for the device, recursively, starting from root geometry"""
+    name = geometry.name
+
+    if isinstance(geometry, pygdtf.GeometryReference):
+        name = geometry.geometry
+
+    for channel in get_channels_by_geometry(name, dmx_channels):
+        channel_list.append((channel, geometry))
+    if hasattr(geometry, "geometries"):
+        for sub_geometry in geometry.geometries:
+            channel_list = get_channels_for_geometry(
+                gdtf_profile, sub_geometry, dmx_channels, channel_list
+            )
+    return channel_list
+
+
+def get_dmx_channels(gdtf_profile: 'pygdtf.FixtureType' = None, mode: 'pygdtf.DmxMode' = None) -> List['Dict']:
+    """Returns list of arrays, each array is one DMX Break,
+    with DMX channels, defaults, geometries"""
+
+    dmx_mode = None
+    dmx_mode = get_dmx_mode_by_name(gdtf_profile, mode)
+    root_geometry = get_geometry_by_name(gdtf_profile, dmx_mode.geometry)
+    device_channels = get_channels_for_geometry(
+        gdtf_profile, root_geometry, dmx_mode.dmx_channels, []
+    )
+
+    dmx_channels = []
+
+    for channel, geometry in device_channels:
+        if channel.offset is None:
+            continue
+        if len(dmx_channels) < channel.dmx_break:
+            dmx_channels = dmx_channels + [[]] * (channel.dmx_break - len(dmx_channels))
+
+        break_channels = dmx_channels[channel.dmx_break - 1]  # off by one...
+
+        break_addition = 0
+
+        if hasattr(geometry, "breaks"):
+            dmx_offset = get_address_by_break(geometry.breaks, channel.dmx_break)
+            if dmx_offset is not None:
+                break_addition = dmx_offset.address - 1
+
+        offset0 = channel.offset[0] + break_addition
+        offset1 = 0
+        # TODO: rework here to support ultra and uber
+
+        if len(channel.offset) > 1:
+            offset1 = channel.offset[1] + break_addition
+
+        max_offset = max([offset0, offset1])
+
+        if len(break_channels) < max_offset:
+            # print(len(break_channels), break_channels)
+            break_channels = break_channels + [
+                {"dmx": "", "id": "", "default": 0, "geometry": "", "break": ""}
+            ] * (max_offset - len(break_channels))
+
+        break_channels[offset0 - 1] = {
+            "dmx": offset0,
+            "id": str(channel.logical_channels[0].channel_functions[0].attribute),
+            "default": getValue(
+                channel.logical_channels[0].channel_functions[0].default
+            ),
+            "geometry": geometry.name,
+            "break": channel.dmx_break,
+        }
+        if offset1 > 0:
+            break_channels[offset1 - 1] = {
+                "dmx": offset1,
+                "id": "+"
+                + str(channel.logical_channels[0].channel_functions[0].attribute),
+                "default": getValue(
+                    channel.logical_channels[0].channel_functions[0].default, True
+                ),
+                "geometry": geometry.name,
+                "break": channel.dmx_break,
+            }
+        dmx_channels[channel.dmx_break - 1] = break_channels
+
+    # This returns multiple lists of channel arrays. Each list is for one DMX Break, these
+    # can be patched onto different DMX addresses. Or, these lists can be flatten into one
+    # DMX footprint this way:
+    # [channel for break_channels in dmx_channels for channel in break_channels]
+    # Here, we should return the list of arrays, so the consumer can decide how to process it.
+    return dmx_channels
diff --git a/pymvr/__init__.py b/pymvr/__init__.py
new file mode 100644
index 00000000..1728a03a
--- /dev/null
+++ b/pymvr/__init__.py
@@ -0,0 +1,160 @@
+from typing import List
+from xml.etree import ElementTree
+from xml.etree.ElementTree import Element
+import zipfile
+from dmx.pymvr.value import *
+
+
+def _find_root(pkg: "zipfile.ZipFile") -> "ElementTree.Element":
+    """Given a GDTF zip archive, find the FixtureType of the corresponding
+    description.xml file."""
+
+    with pkg.open("GeneralSceneDescription.xml", "r") as f:
+        description_str = f.read()
+    return ElementTree.fromstring(description_str)
+
+
+class GeneralSceneDescription:
+    def __init__(self, path=None):
+        self._package = None
+        self._root = None
+        if path is not None:
+            self._package = zipfile.ZipFile(path, "r")
+        if self._package is not None:
+            self._root = _find_root(self._package)
+            self._user_data = self._root.find("UserData")
+            self._scene = self._root.find("Scene")
+        if self._root is not None:
+            self._read_xml()
+
+    def _read_xml(self):
+        self.version_major = self._root.get("verMajor")
+        self.version_minor = self._root.get("verMinor")
+
+        layers_collect = self._scene.find("Layers")
+        if layers_collect:
+            self.layers = [Layer(xml_node=i) for i in layers_collect.findall("Layer")]
+        else:
+            self.layers = []
+
+
+class BaseNode:
+    def __init__(self, xml_node: "Element" = None):
+        if xml_node is not None:
+            self._read_xml(xml_node)
+
+    def _read_xml(self, xml_node: "Element"):
+        pass
+
+
+class Fixture(BaseNode):
+    def __init__(
+        self,
+        name: str = None,
+        uuid: str = None,
+        gdtf_spec: str = None,
+        gdtf_mode: str = None,
+        matrix: Matrix = Matrix(0),
+        fixture_id: str = None,
+        unit_number: str = None,
+        fixture_type_id: int = 0,
+        custom_id: int = 0,
+        color: "ColorCIE" = None,
+        cast_shadow: bool = False,
+        addresses: List["Address"] = None,
+        *args,
+        **kwargs,
+    ):
+        self.name = name
+        self.uuid = uuid
+        self.gdtf_spec = gdtf_spec
+        self.gdtf_mode = gdtf_mode
+        self.matrix = matrix
+        self.fixture_id = fixture_id
+        self.unit_number = unit_number
+        self.fixture_type_id = fixture_type_id
+        self.custom_id = custom_id
+        self.color = color
+        self.cast_shadow = cast_shadow
+        self.addresses = addresses
+
+        super().__init__(*args, **kwargs)
+
+    def _read_xml(self, xml_node: "Element"):
+        self.name = xml_node.attrib.get("name")
+        self.uuid = xml_node.attrib.get("uuid")
+        self.gdtf_spec = xml_node.find("GDTFSpec").text
+        self.gdtf_mode = xml_node.find("GDTFMode").text
+        self.matrix = Matrix(str_repr=xml_node.find("Matrix").text)
+        self.fixture_id = xml_node.find("FixtureID").text
+        self.unit_number = xml_node.find("UnitNumber").text
+        self.fixture_type_id = xml_node.find("FixtureTypeId").text
+        self.custom_id = xml_node.find("CustomId").text
+        self.color = xml_node.find("Color").text
+        self.cast_shadow = xml_node.find("CastShadow").text
+        self.addresses = [
+            Address(xml_node=i) for i in xml_node.find("Addresses").findall("Address")
+        ]
+
+
+class Layer(BaseNode):
+    def __init__(
+        self,
+        name: str = None,
+        uuid: str = None,
+        gdtf_spec: str = None,
+        gdtf_mode: str = None,
+        fixtures: List["Fixture"] = None,
+        matrix: Matrix = Matrix(0),
+        *args,
+        **kwargs,
+    ):
+        self.name = name
+        self.uuid = uuid
+        self.gdtf_spec = gdtf_spec
+        self.gdtf_mode = gdtf_mode
+
+        if fixtures is not None:
+            self.fixtures = fixtures
+        else:
+            self.fixtures = []
+        super().__init__(*args, **kwargs)
+
+    def _read_xml(self, xml_node: "Element"):
+        self.name = xml_node.attrib.get("name")
+        self.uuid = xml_node.attrib.get("uuid")
+        self.gdtf_spec = xml_node.find("GDTFSpec").text
+        self.gdtf_mode = xml_node.find("GDTFMode").text
+        self.fixtures = [
+            Fixture(xml_node=i) for i in xml_node.find("ChildList").findall("Fixture")
+        ]
+        if xml_node.find("Matrix"):
+            self.matrix = Matrix(str_repr=xml_node.find("Matrix").text)
+
+
+class Address(BaseNode):
+    def __init__(
+        self, dmx_break: int = 0, universe: int = 1, address: int = 1, *args, **kwargs
+    ):
+        self.dmx_break = dmx_break
+        self.address = address
+        self.universe = universe
+
+        super().__init__(*args, **kwargs)
+
+    def _read_xml(self, xml_node: "Element"):
+        self.dmx_break = int(xml_node.attrib.get("break"))
+        raw_address = xml_node.text
+        if "." in raw_address:
+            universe, address = raw_address.split(".")
+            self.universe = int(universe)
+            self.address = int(address)
+            return
+        self.universe = 1 + int(raw_address) // 512
+        self.address = int(raw_address) % 512
+
+    def __repr__(self):
+        return f"B: {self.dmx_break}, U: {self.universe}, A: {self.address}"
+
+    def __str__(self):
+        return f"B: {self.dmx_break}, U: {self.universe}, A: {self.address}"
diff --git a/pymvr/value.py b/pymvr/value.py
new file mode 100644
index 00000000..54d9b9d4
--- /dev/null
+++ b/pymvr/value.py
@@ -0,0 +1,82 @@
+from collections import namedtuple
+
+
+# Data type that only allows a specific set of values, if given a value
+# which is not permitted, the value will be set to the default
+class Enum:
+    permitted = []
+    _default = None
+
+    def __init__(self, value):
+        self.value = None
+        if value not in self.permitted:
+            self.value = self._default
+        else:
+            self.value = value
+
+    def __str__(self):
+        return str(self.value)
+
+    def __bool__(self):
+        return bool(self.value)
+
+
+class ColorCIE:
+    def __init__(
+        self, x: float = None, y: float = None, z: float = None, str_repr: str = None
+    ):
+        self.x = x
+        self.y = y
+        self.z = z
+        if str_repr is not None:
+            self.x = float(str_repr.split(",")[0])
+            self.y = float(str_repr.split(",")[1])
+            self.Y = float(str_repr.split(",")[2])
+
+
+class Rotation:
+    def __init__(self, str_repr):
+        str_repr = str_repr.replace("}{", ",")
+        str_repr = str_repr.replace("{", "")
+        str_repr = str_repr.replace("}", "")
+        component = str_repr.split(",")
+        component = [float(i) for i in component]
+        self.matrix = [
+            [component[0], component[1], component[2]],
+            [component[3], component[4], component[5]],
+            [component[6], component[7], component[8]],
+        ]
+
+
+class Matrix:
+    def __init__(self, str_repr):
+        if str_repr == "0" or str_repr == 0:
+            self.matrix = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 0]]
+        else:
+            str_repr = str_repr.replace("}{", ",")
+            str_repr = str_repr.replace("{", "")
+            str_repr = str_repr.replace("}", "")
+            component = str_repr.split(",")
+            component = [float(i) * 0.001 for i in component]
+            self.matrix = [
+                [component[0], component[1], component[2], 0],
+                [component[3], component[4], component[5], 0],
+                [component[6], component[7], component[8], 0],
+                [component[9], component[10], component[11], 0],
+            ]
+
+
+# A node link represents a link to another node in the XML tree, starting from
+# start_point and traversing the tree with a decimal-point notation in str_link.
+# There isn't yet a standard for how start_point is formatted so the only useful
+# feature of this type currently is the str_link. A typical use would be for
+# specifying linked attributes. In this case, the str_link text itself is perfectly
+# serviceable if all that is needed is the attribute name. For this reason, the
+# str representation of the NodeLink will always give the raw str_link property.
+class NodeLink:
+    def __init__(self, start_point, str_link):
+        self.start_point = start_point
+        self.str_link = str_link
+
+    def __str__(self):
+        return str(self.str_link)
diff --git a/sacn/__init__.py b/sacn/__init__.py
new file mode 100644
index 00000000..5a670e77
--- /dev/null
+++ b/sacn/__init__.py
@@ -0,0 +1,8 @@
+# re-export the classes available to consumers of this library
+from dmx.sacn.receiver import sACNreceiver, LISTEN_ON_OPTIONS  # noqa: F401
+from dmx.sacn.sender import sACNsender  # noqa: F401
+from dmx.sacn.messages.data_packet import DataPacket  # noqa: F401
+from dmx.sacn.messages.universe_discovery import UniverseDiscoveryPacket  # noqa: F401
+
+import logging
+logging.getLogger('sacn').addHandler(logging.NullHandler())
diff --git a/sacn/messages/__init__.py b/sacn/messages/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/sacn/messages/data_packet.py b/sacn/messages/data_packet.py
new file mode 100644
index 00000000..a931cc41
--- /dev/null
+++ b/sacn/messages/data_packet.py
@@ -0,0 +1,220 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+"""
+This represents a framing layer and a DMP layer from the E1.31 Standard
+Information about sACN: http://tsp.esta.org/tsp/documents/docs/E1-31-2016.pdf
+"""
+
+from dmx.sacn.messages.root_layer import \
+    VECTOR_DMP_SET_PROPERTY, \
+    VECTOR_E131_DATA_PACKET, \
+    VECTOR_ROOT_E131_DATA, \
+    RootLayer, \
+    int_to_bytes, \
+    byte_tuple_to_int, \
+    make_flagsandlength
+
+
+class DataPacket(RootLayer):
+    def __init__(self, cid: tuple, sourceName: str, universe: int, dmxData: tuple = (), priority: int = 100,
+                 sequence: int = 0, streamTerminated: bool = False, previewData: bool = False,
+                 forceSync: bool = False, sync_universe: int = 0, dmxStartCode: int = 0x00):
+        super().__init__(126 + len(dmxData), cid, VECTOR_ROOT_E131_DATA)
+        self.sourceName: str = sourceName
+        self.priority = priority
+        self.syncAddr = sync_universe
+        self.universe = universe
+        self.option_StreamTerminated: bool = streamTerminated
+        self.option_PreviewData: bool = previewData
+        self.option_ForceSync: bool = forceSync
+        self.sequence = sequence
+        self.dmxStartCode = dmxStartCode
+        self.dmxData = dmxData
+
+    def __str__(self):
+        return f'sACN DataPacket: Universe: {self._universe}, Priority: {self._priority}, Sequence: {self._sequence}, ' \
+               f'CID: {self._cid}'
+
+    @property
+    def sourceName(self) -> str:
+        return self._sourceName
+
+    @sourceName.setter
+    def sourceName(self, sourceName: str):
+        if type(sourceName) is not str:
+            raise TypeError(f'sourceName must be a string! Type was {type(sourceName)}')
+        tmp_sourceName_length = len(str(sourceName).encode('UTF-8'))
+        if tmp_sourceName_length > 63:
+            raise ValueError(f'sourceName must be less than 64 bytes when UTF-8 encoded! "{sourceName}" is {tmp_sourceName_length} bytes')
+        self._sourceName = sourceName
+
+    @property
+    def priority(self) -> int:
+        return self._priority
+
+    @priority.setter
+    def priority(self, priority: int):
+        if type(priority) is not int:
+            raise TypeError(f'priority must be an integer! Type was {type(priority)}')
+        if priority not in range(0, 201):
+            raise ValueError(f'priority must be in range [0-200]! value was {priority}')
+        self._priority = priority
+
+    @property
+    def universe(self) -> int:
+        return self._universe
+
+    @universe.setter
+    def universe(self, universe: int):
+        if type(universe) is not int:
+            raise TypeError(f'universe must be an integer! Type was {type(universe)}')
+        if universe not in range(1, 64000):
+            raise ValueError(f'universe must be [1-63999]! value was {universe}')
+        self._universe = universe
+
+    @property
+    def syncAddr(self) -> int:
+        return self._syncAddr
+
+    @syncAddr.setter
+    def syncAddr(self, sync_universe: int):
+        if type(sync_universe) is not int:
+            raise TypeError(f'sync_universe must be an integer! Type was {type(sync_universe)}')
+        if sync_universe not in range(0, 64000):
+            raise ValueError(f'sync_universe must be [1-63999]! value was {sync_universe}')
+        self._syncAddr = sync_universe
+
+    @property
+    def sequence(self) -> int:
+        return self._sequence
+
+    @sequence.setter
+    def sequence(self, sequence: int):
+        if type(sequence) is not int:
+            raise TypeError(f'sequence must be an integer! Type was {type(sequence)}')
+        if sequence not in range(0, 256):
+            raise ValueError(f'sequence is a byte! values: [0-255]! value was {sequence}')
+        self._sequence = sequence
+
+    def sequence_increase(self):
+        self._sequence += 1
+        if self._sequence > 0xFF:
+            self._sequence = 0
+
+    @property
+    def dmxStartCode(self) -> int:
+        return self._dmxStartCode
+
+    @dmxStartCode.setter
+    def dmxStartCode(self, dmxStartCode: int):
+        """
+        DMX start code values: 0x00 for level data; 0xDD for per address priority data
+        """
+        if type(dmxStartCode) is not int:
+            raise TypeError(f'dmx start code must be an integer! Type was {type(dmxStartCode)}')
+        if dmxStartCode not in range(0, 256):
+            raise ValueError(f'dmx start code is a byte! values: [0-255]! value was {dmxStartCode}')
+        self._dmxStartCode = dmxStartCode
+
+    @property
+    def dmxData(self) -> tuple:
+        return self._dmxData
+
+    @dmxData.setter
+    def dmxData(self, data: tuple):
+        """
+        For legacy devices and to prevent errors, the length of the DMX data is normalized to 512
+        """
+        if len(data) > 512 or \
+                not all((isinstance(x, int) and (0 <= x <= 255)) for x in data):
+            raise ValueError(f'dmxData is a tuple with a max length of 512! The data in the tuple has to be valid bytes! '
+                             f'Length was {len(data)}')
+        newData = [0]*512
+        for i in range(0, min(len(data), 512)):
+            newData[i] = data[i]
+        self._dmxData = tuple(newData)
+        # in theory this class supports dynamic length, so the next line is correcting the length
+        self.length = 126 + len(self._dmxData)
+
+    def getBytes(self) -> tuple:
+        rtrnList = super().getBytes()
+        # Flags and Length Framing Layer:-------
+        rtrnList.extend(make_flagsandlength(self.length - 38))
+        # Vector Framing Layer:-----------------
+        rtrnList.extend(VECTOR_E131_DATA_PACKET)
+        # sourceName:---------------------------
+        # UTF-8 encode the string
+        tmpSourceName = str(self._sourceName).encode('UTF-8')
+        rtrnList.extend(tmpSourceName)
+        # pad to 64 bytes
+        rtrnList.extend([0] * (64 - len(tmpSourceName)))
+        # priority------------------------------
+        rtrnList.append(self._priority)
+        # syncAddress---------------------------
+        rtrnList.extend(int_to_bytes(self._syncAddr))
+        # sequence------------------------------
+        rtrnList.append(self._sequence)
+        # Options Flags:------------------------
+        tmpOptionsFlags = 0
+        # stream terminated:
+        tmpOptionsFlags += int(self.option_StreamTerminated) << 6
+        # preview data:
+        tmpOptionsFlags += int(self.option_PreviewData) << 7
+        # force synchronization
+        tmpOptionsFlags += int(self.option_ForceSync) << 5
+        rtrnList.append(tmpOptionsFlags)
+        # universe:-----------------------------
+        rtrnList.extend(int_to_bytes(self._universe))
+        # DMP Layer:---------------------------------------------------
+        # Flags and Length DMP Layer:-----------
+        rtrnList.extend(make_flagsandlength(self.length - 115))
+        # Vector DMP Layer:---------------------
+        rtrnList.append(VECTOR_DMP_SET_PROPERTY)
+        # Some static values (Address & Data Type, First Property addr, ...)
+        rtrnList.extend([0xa1, 0x00, 0x00, 0x00, 0x01])
+        # Length of the data:-------------------
+        lengthDmxData = len(self._dmxData)+1
+        rtrnList.extend(int_to_bytes(lengthDmxData))
+        # DMX data:-----------------------------
+        rtrnList.append(self._dmxStartCode)  # DMX Start Code
+        rtrnList.extend(self._dmxData)
+        return tuple(rtrnList)
+
+    @staticmethod
+    def make_data_packet(raw_data) -> 'DataPacket':
+        """
+        Converts raw byte data to a sACN DataPacket. Note that the raw bytes have to come from a 2016 sACN Message.
+        This does not support DMX Start code!
+        :param raw_data: raw bytes as tuple or list
+        :raises TypeError: when the binary data does not match the criteria for a valid DMX data-packet
+        :return: a DataPacket with the properties set like the raw bytes
+        """
+        # Check if the length is sufficient
+        if len(raw_data) < 126:
+            raise TypeError('The length of the provided data is not long enough! Min length is 126!')
+        # Check if the three Vectors are correct
+        if tuple(raw_data[18:22]) != tuple(VECTOR_ROOT_E131_DATA) or \
+           tuple(raw_data[40:44]) != tuple(VECTOR_E131_DATA_PACKET) or \
+           raw_data[117] != VECTOR_DMP_SET_PROPERTY:  # REMEMBER: when slicing: [inclusive:exclusive]
+            raise TypeError('Some of the vectors in the given raw data are not compatible to the E131 Standard!')
+
+        tmpPacket = DataPacket(cid=tuple(raw_data[22:38]), sourceName=bytes(raw_data[44:108]).decode('utf-8').replace('\0', ''),
+                               universe=byte_tuple_to_int(raw_data[113:115]))  # high byte first
+        tmpPacket.priority = raw_data[108]
+        tmpPacket.syncAddr = byte_tuple_to_int(raw_data[109:111])
+        tmpPacket.sequence = raw_data[111]
+        tmpPacket.option_PreviewData = bool(raw_data[112] & 0b10000000)  # use the 7th bit as preview_data
+        tmpPacket.option_StreamTerminated = bool(raw_data[112] & 0b01000000)  # use bit 6 as stream terminated
+        tmpPacket.option_ForceSync = bool(raw_data[112] & 0b00100000)  # use bit 5 as force sync
+        tmpPacket.dmxStartCode = raw_data[125]
+        tmpPacket.dmxData = raw_data[126:638]
+        return tmpPacket
+
+    def calculate_multicast_addr(self) -> str:
+        return calculate_multicast_addr(self.universe)
+
+
+def calculate_multicast_addr(universe: int) -> str:
+    hi_byte = universe >> 8  # a little bit shifting here
+    lo_byte = universe & 0xFF  # a little bit mask there
+    return f'239.255.{hi_byte}.{lo_byte}'
diff --git a/sacn/messages/data_types.py b/sacn/messages/data_types.py
new file mode 100644
index 00000000..fba103a4
--- /dev/null
+++ b/sacn/messages/data_types.py
@@ -0,0 +1,29 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+"""
+This file includes custom types for the usage in sACN packets.
+"""
+
+
+class CID:
+    """
+    CID stores a special case of a tuple that must have a length of 16 byte elements.
+    """
+
+    def __init__(self, cid: tuple):
+        self.value = cid
+
+    @property
+    def value(self) -> tuple:
+        return self._cid
+
+    @value.setter
+    def value(self, cid: tuple):
+        if type(cid) is not tuple:
+            raise TypeError(f'cid must be a 16 byte tuple! value was {cid}')
+        if (len(cid) != 16 or not all((isinstance(x, int) and (0 <= x <= 255)) for x in cid)):
+            raise ValueError(f'cid must be a 16 byte tuple! value was {cid}')
+        self._cid = cid
+
+    def __eq__(self, other: 'CID') -> bool:
+        return self.value == other.value
diff --git a/sacn/messages/root_layer.py b/sacn/messages/root_layer.py
new file mode 100644
index 00000000..54104fc4
--- /dev/null
+++ b/sacn/messages/root_layer.py
@@ -0,0 +1,104 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+
+"""
+This represents a root layer of an ACN Message.
+Information about sACN: http://tsp.esta.org/tsp/documents/docs/E1-31-2016.pdf
+"""
+
+_FIRST_INDEX = \
+    (0, 0x10, 0, 0, 0x41, 0x53, 0x43, 0x2d, 0x45,
+     0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00)
+
+VECTOR_E131_DATA_PACKET = (0, 0, 0, 0x02)
+VECTOR_DMP_SET_PROPERTY = 0x02
+VECTOR_ROOT_E131_DATA = (0, 0, 0, 0x4)
+
+VECTOR_ROOT_E131_EXTENDED = (0, 0, 0, 0x8)
+
+VECTOR_E131_EXTENDED_SYNCHRONIZATION = (0, 0, 0, 0x1)
+VECTOR_E131_EXTENDED_DISCOVERY = (0, 0, 0, 0x2)
+VECTOR_UNIVERSE_DISCOVERY_UNIVERSE_LIST = (0, 0, 0, 0x1)
+
+
+class RootLayer:
+    def __init__(self, length: int, cid: tuple, vector: tuple):
+        self.length = length
+        if (len(vector) != 4):
+            raise ValueError('the length of the vector is not 4!')
+        self._vector = vector
+        self.cid = cid
+
+    def getBytes(self) -> list:
+        '''Returns the Root layer as list with bytes'''
+        tmpList = []
+        tmpList.extend(_FIRST_INDEX)
+        # first append the high byte from the Flags and Length
+        # high 4 bit: 0x7 then the bits 8-11(indexes) from _length
+        length = self.length - 16
+        tmpList.extend(make_flagsandlength(length))
+
+        tmpList.extend(self._vector)
+        tmpList.extend(self._cid)
+        return tmpList
+
+    @property
+    def length(self) -> int:
+        return self._length
+
+    @length.setter
+    def length(self, value: int):
+        self._length = value & 0xFFF  # only use the least 12-Bit
+
+    @property
+    def cid(self) -> tuple:
+        return self._cid
+
+    @cid.setter
+    def cid(self, cid: tuple):
+        if type(cid) is not tuple:
+            raise TypeError(f'cid must be a 16 byte tuple! value was {cid}')
+        if (len(cid) != 16 or not all((isinstance(x, int) and (0 <= x <= 255)) for x in cid)):
+            raise ValueError(f'cid must be a 16 byte tuple! value was {cid}')
+        self._cid = cid
+
+    def __eq__(self, other):
+        if self.__class__ != other.__class__:
+            return False
+        return self.__dict__ == other.__dict__
+
+
+def int_to_bytes(integer_value: int) -> list:
+    """
+    Converts a single integer number to an list with the length 2 with highest
+    byte first.
+    The returned list contains values in the range [0-255]
+    :param integer: the integer to convert
+    :return: the list with the high byte first
+    """
+    if not (isinstance(integer_value, int) and 0 <= integer_value <= 65535):
+        raise ValueError(f'integer_value to be packed must be unsigned short: [0-65535]! value was {integer_value}')
+    return [(integer_value >> 8) & 0xFF, integer_value & 0xFF]
+
+
+def byte_tuple_to_int(in_tuple: tuple) -> int:
+    """
+    Converts two element byte tuple (highest first) to integer.
+    :param in_tuple: the integer to convert
+    :return: the integer value
+    """
+    if ((len(in_tuple) != 2) or not all(isinstance(x, int) for x in in_tuple) or not all(0 <= x <= 255 for x in in_tuple)):
+        raise ValueError(f'in_tuple must be a two byte tuple! value was {in_tuple}')
+    return int((in_tuple[0] << 8) + in_tuple[1])
+
+
+def make_flagsandlength(in_length: int) -> list:
+    """
+    Converts a length value in a Flags and Length list with two bytes in the
+    correct order.
+    :param length: the length to convert. should be 12-bit value
+    :return: the list with the two bytes
+    """
+    if (in_length > 0xFFF):
+        raise ValueError(f'length must be no greater than a 12-bit value! value was {in_length}')
+    return [(0x7 << 4) + ((in_length & 0xF00) >> 8), in_length & 0xFF]
diff --git a/sacn/messages/sync_packet.py b/sacn/messages/sync_packet.py
new file mode 100644
index 00000000..ea3c52d8
--- /dev/null
+++ b/sacn/messages/sync_packet.py
@@ -0,0 +1,77 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+"""
+This implements the sync packet from the E1.31 standard.
+Information about sACN: http://tsp.esta.org/tsp/documents/docs/E1-31-2016.pdf
+"""
+from dmx.sacn.messages.root_layer import \
+    VECTOR_ROOT_E131_EXTENDED, \
+    VECTOR_E131_EXTENDED_SYNCHRONIZATION, \
+    RootLayer, \
+    byte_tuple_to_int, \
+    int_to_bytes, \
+    make_flagsandlength
+
+
+class SyncPacket(RootLayer):
+    def __init__(self, cid: tuple, syncAddr: int, sequence: int = 0):
+        self.syncAddr = syncAddr
+        self.sequence = sequence
+        super().__init__(49, cid, VECTOR_ROOT_E131_EXTENDED)
+
+    @property
+    def syncAddr(self) -> int:
+        return self._syncAddr
+
+    @syncAddr.setter
+    def syncAddr(self, sync_universe: int):
+        if type(sync_universe) is not int:
+            raise TypeError(f'sync_universe must be an integer! Type was {type(sync_universe)}')
+        if sync_universe not in range(1, 64000):
+            raise ValueError(f'sync_universe must be [1-63999]! value was {sync_universe}')
+        self._syncAddr = sync_universe
+
+    @property
+    def sequence(self) -> int:
+        return self._sequence
+
+    @sequence.setter
+    def sequence(self, sequence: int):
+        if type(sequence) is not int:
+            raise TypeError('sequence must be an integer')
+        if sequence not in range(0, 256):
+            raise ValueError(f'sequence is a byte! values: [0-255]! value was {sequence}')
+        self._sequence = sequence
+
+    def sequence_increase(self):
+        self._sequence += 1
+        if self._sequence > 0xFF:
+            self._sequence = 0
+
+    def getBytes(self) -> list:
+        rtrnList = super().getBytes()
+        rtrnList.extend(make_flagsandlength(self.length - 38))
+        rtrnList.extend(VECTOR_E131_EXTENDED_SYNCHRONIZATION)
+        rtrnList.append(self._sequence)
+        rtrnList.extend(int_to_bytes(self._syncAddr))
+        rtrnList.extend((0, 0))  # the empty reserved slots
+        return rtrnList
+
+    @staticmethod
+    def make_sync_packet(raw_data) -> 'SyncPacket':
+        """
+        Converts raw byte data to a sACN SyncPacket. Note that the raw bytes have to come from a 2016 sACN Message.
+        :param raw_data: raw bytes as tuple or list
+        :return: a SyncPacket with the properties set like the raw bytes
+        """
+        # Check if the length is sufficient
+        if len(raw_data) < 47:
+            raise TypeError('The length of the provided data is not long enough! Min length is 47!')
+        # Check if the three Vectors are correct
+        if tuple(raw_data[18:22]) != tuple(VECTOR_ROOT_E131_EXTENDED) or \
+           tuple(raw_data[40:44]) != tuple(VECTOR_E131_EXTENDED_SYNCHRONIZATION):
+            # REMEMBER: when slicing: [inclusive:exclusive]
+            raise TypeError('Some of the vectors in the given raw data are not compatible to the E131 Standard!')
+        tmpPacket = SyncPacket(cid=tuple(raw_data[22:38]), syncAddr=byte_tuple_to_int(raw_data[45:47]))
+        tmpPacket.sequence = raw_data[44]
+        return tmpPacket
diff --git a/sacn/messages/universe_discovery.py b/sacn/messages/universe_discovery.py
new file mode 100644
index 00000000..fb3314a5
--- /dev/null
+++ b/sacn/messages/universe_discovery.py
@@ -0,0 +1,168 @@
+"""
+This class represents an universe discovery packet of the E1.31 Standard.
+"""
+from typing import List
+
+from dmx.sacn.messages.root_layer import \
+    VECTOR_ROOT_E131_EXTENDED, \
+    VECTOR_E131_EXTENDED_DISCOVERY, \
+    VECTOR_UNIVERSE_DISCOVERY_UNIVERSE_LIST,\
+    RootLayer, \
+    int_to_bytes, \
+    byte_tuple_to_int, \
+    make_flagsandlength
+
+
+class UniverseDiscoveryPacket(RootLayer):
+    def __init__(self, cid: tuple, sourceName: str, universes: tuple, page: int = 0, lastPage: int = 0):
+        self.sourceName: str = sourceName
+        self.page: int = page
+        self.lastPage: int = lastPage
+        self.universes: tuple = universes
+        super().__init__((len(universes) * 2) + 120, cid, VECTOR_ROOT_E131_EXTENDED)
+
+    @property
+    def sourceName(self) -> str:
+        return self._sourceName
+
+    @sourceName.setter
+    def sourceName(self, sourceName: str):
+        if type(sourceName) is not str:
+            raise TypeError(f'sourceName must be a string! Type was {type(sourceName)}')
+        tmp_sourceName_length = len(str(sourceName).encode('UTF-8'))
+        if tmp_sourceName_length > 63:
+            raise ValueError(f'sourceName must be less than 64 bytes when UTF-8 encoded! "{sourceName}" is {tmp_sourceName_length} bytes')
+        self._sourceName = sourceName
+
+    @property
+    def page(self) -> int:
+        return self._page
+
+    @page.setter
+    def page(self, page: int):
+        if type(page) is not int:
+            raise TypeError(f'Page must be an integer! Type was {type(page)}')
+        if page not in range(0, 256):
+            raise ValueError(f'Page is a byte! values: [0-255]! value was {page}')
+        self._page = page
+
+    @property
+    def lastPage(self) -> int:
+        return self._lastPage
+
+    @lastPage.setter
+    def lastPage(self, lastPage: int):
+        if type(lastPage) is not int:
+            raise TypeError(f'lastPage must be an integer! Type was {type(lastPage)}')
+        if lastPage not in range(0, 256):
+            raise ValueError(f'lastPage is a byte! values: [0-255]! value was {lastPage}')
+        self._lastPage = lastPage
+
+    @property
+    def universes(self) -> tuple:
+        return tuple(self._universes)
+
+    @universes.setter
+    def universes(self, universes: tuple):
+        if len(universes) > 512 or \
+                not all((isinstance(x, int) and (0 <= x <= 63999)) for x in universes):
+            raise ValueError(f'Universes is a tuple with a max length of 512! The data in the tuple has to be valid universe numbers! '
+                             f'Length was {len(universes)}')
+        self._universes = sorted(universes)
+        self.length = 120 + (len(universes) * 2)  # generate new length value for the packet
+
+    def getBytes(self) -> list:
+        rtrnList = super().getBytes()
+        # Flags and Length Framing Layer:--------------------
+        rtrnList.extend(make_flagsandlength(self.length - 38))
+        # Vector Framing Layer:------------------------------
+        rtrnList.extend(VECTOR_E131_EXTENDED_DISCOVERY)
+        # source Name Framing Layer:-------------------------
+        # sourceName:---------------------------
+        # UTF-8 encode the string
+        tmpSourceName = str(self._sourceName).encode('UTF-8')
+        rtrnList.extend(tmpSourceName)
+        # pad to 64 bytes
+        rtrnList.extend([0] * (64 - len(tmpSourceName)))
+        # reserved fields:-----------------------------------
+        rtrnList.extend([0] * 4)
+        # Universe Discovery Layer:-------------------------------------
+        # Flags and Length:----------------------------------
+        rtrnList.extend(make_flagsandlength(self.length - 112))
+        # Vector UDL:----------------------------------------
+        rtrnList.extend(VECTOR_UNIVERSE_DISCOVERY_UNIVERSE_LIST)
+        # page:----------------------------------------------
+        rtrnList.append(self._page & 0xFF)
+        # last page:-----------------------------------------
+        rtrnList.append(self._lastPage & 0xFF)
+        # universes:-----------------------------------------
+        for universe in self._universes:  # universe is a 16-bit number!
+            rtrnList.extend(int_to_bytes(universe))
+
+        return rtrnList
+
+    @staticmethod
+    def make_universe_discovery_packet(raw_data) -> 'UniverseDiscoveryPacket':
+        # Check if the length is sufficient
+        if len(raw_data) < 120:
+            raise TypeError('The length of the provided data is not long enough! Min length is 120!')
+        # Check if the three Vectors are correct
+        # REMEMBER: when slicing: [inclusive:exclusive]
+        if tuple(raw_data[18:22]) != tuple(VECTOR_ROOT_E131_EXTENDED) or \
+           tuple(raw_data[40:44]) != tuple(VECTOR_E131_EXTENDED_DISCOVERY) or \
+           tuple(raw_data[114:118]) != tuple(VECTOR_UNIVERSE_DISCOVERY_UNIVERSE_LIST):
+            raise TypeError('Some of the vectors in the given raw data are not compatible to the E131 Standard!')
+
+        # tricky part: convert plain bytes to a useful list of 16-bit values for further use
+        # Problem: the given raw_byte can be longer than the dynamic length of the list of universes
+        # first: extract the length from the Universe Discovery Layer (UDL)
+        length = (byte_tuple_to_int((raw_data[112], raw_data[113])) & 0xFFF) - 8
+        # remember: UDL has 8 bytes plus the universes
+        # remember: Flags and length includes a 12-bit length field
+        universes = convert_raw_data_to_universes(raw_data[120:120 + length])
+        tmpPacket = UniverseDiscoveryPacket(cid=tuple(raw_data[22:38]), sourceName=bytes(raw_data[44:108]).decode('utf-8').replace('\0', ''),
+                                            universes=universes)
+        tmpPacket._page = raw_data[118]
+        tmpPacket._lastPage = raw_data[119]
+        return tmpPacket
+
+    @staticmethod
+    def make_multiple_uni_disc_packets(cid: tuple, sourceName: str, universes: list) -> List['UniverseDiscoveryPacket']:
+        """
+        Creates a list with universe discovery packets based on the given data. It creates automatically enough packets
+        for the given universes list.
+        :param cid: the cid to use in all packets
+        :param sourceName: the source name to use in all packets
+        :param universes: the universes. Can be longer than 512, but has to be shorter than 256*512.
+        The values in the list should be [1-63999]
+        :return: a list full of universe discovery packets
+        """
+        tmpList = []
+        # divide len(universes) with 512 and round up; // is integer division
+        num_of_packets = (len(universes) + 512 - 1) // 512
+        universes.sort()  # E1.31 wants that the send out universes are sorted
+        for i in range(0, num_of_packets):
+            if i == num_of_packets - 1:
+                tmpUniverses = universes[i * 512:len(universes)]
+                # if we are here, then the for is in the last loop
+            else:
+                tmpUniverses = universes[i * 512:(i + 1) * 512]
+            # create new UniverseDiscoveryPacket and append it to the list. Page and lastPage are getting special values
+            tmpList.append(UniverseDiscoveryPacket(cid=cid, sourceName=sourceName, universes=tmpUniverses,
+                                                   page=i, lastPage=num_of_packets - 1))
+        return tmpList
+
+
+def convert_raw_data_to_universes(raw_data) -> tuple:
+    """
+    converts the raw data to a readable universes tuple. The raw_data is scanned from index 0 and has to have
+    16-bit numbers with high byte first. The data is converted from the start to the beginning!
+    :param raw_data: the raw data to convert
+    :return: tuple full with 16-bit numbers
+    """
+    if len(raw_data) % 2 != 0:
+        raise TypeError('The given data does not have an even number of elements!')
+    rtrnList = []
+    for i in range(0, len(raw_data), 2):
+        rtrnList.append(byte_tuple_to_int((raw_data[i], raw_data[i + 1])))
+    return tuple(rtrnList)
diff --git a/sacn/receiver.py b/sacn/receiver.py
new file mode 100644
index 00000000..cbe2830b
--- /dev/null
+++ b/sacn/receiver.py
@@ -0,0 +1,145 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+from dmx.sacn.messages.data_packet import DataPacket, calculate_multicast_addr
+from dmx.sacn.receiving.receiver_handler import ReceiverHandler, ReceiverHandlerListener
+from dmx.sacn.receiving.receiver_socket_base import ReceiverSocketBase
+from typing import Tuple
+
+LISTEN_ON_OPTIONS = ('availability', 'universe')
+
+
+class sACNreceiver(ReceiverHandlerListener):
+    def __init__(self, bind_address: str = '0.0.0.0', bind_port: int = 5568, socket: ReceiverSocketBase = None):
+        """
+        Make a receiver for sACN data. Do not forget to start and add callbacks for receiving messages!
+        :param bind_address: if you are on a Windows system and want to use multicast provide a valid interface
+        IP-Address! Otherwise omit.
+        :param bind_port: Default: 5568. It is not recommended to change this value!
+        Only use when you know what you are doing!
+        :param socket: Provide a special socket implementation if necessary. Must be derived from ReceiverSocketBase,
+        only use if the default socket implementation of this library is not sufficient.
+        """
+
+        self._callbacks: dict = {}
+        self._handler: ReceiverHandler = ReceiverHandler(bind_address, bind_port, self, socket)
+
+    def on_availability_change(self, universe: int, changed: str) -> None:
+        callbacks = []
+        # call nothing, if the list with callbacks is empty
+        try:
+            callbacks = self._callbacks[LISTEN_ON_OPTIONS[0]]
+        except KeyError:
+            pass
+        for callback in callbacks:
+            # fire callbacks if this is the first received packet for this universe
+            callback(universe=universe, changed=changed)
+
+    def on_dmx_data_change(self, packet: DataPacket) -> None:
+        callbacks = []
+        # call nothing, if the list with callbacks is empty
+        try:
+            callbacks = self._callbacks[packet.universe]
+        except KeyError:
+            pass
+        for callback in callbacks:
+            callback(packet)
+
+    def listen_on(self, trigger: str, **kwargs) -> callable:
+        """
+        This is a simple decorator for registering a callback for an event. You can also use 'register_listener'.
+        A list with all possible options is available via LISTEN_ON_OPTIONS.
+        :param trigger: Currently supported options: 'availability', 'universe'
+        """
+        def decorator(f):
+            self.register_listener(trigger, f, **kwargs)
+            return f
+        return decorator
+
+    def register_listener(self, trigger: str, func: callable, **kwargs) -> None:
+        """
+        Register a listener for the given trigger. Raises an TypeError when the trigger is not a valid one.
+        To get a list with all valid triggers, use LISTEN_ON_OPTIONS.
+        :param trigger: the trigger on which the given callback should be used.
+        Currently supported: 'availability', 'universe'
+        :param func: the callback. The parameters depend on the trigger. See README for more information
+        """
+        if trigger in LISTEN_ON_OPTIONS:
+            if trigger == LISTEN_ON_OPTIONS[1]:  # if the trigger is universe, use the universe from args as key
+                universe = kwargs[LISTEN_ON_OPTIONS[1]]
+                try:
+                    self._callbacks[universe].append(func)
+                except KeyError:
+                    self._callbacks[universe] = [func]
+            try:
+                self._callbacks[trigger].append(func)
+            except KeyError:
+                self._callbacks[trigger] = [func]
+        else:
+            raise TypeError(f'The given trigger "{trigger}" is not a valid one!')
+
+    def remove_listener(self, func: callable) -> None:
+        """
+        Removes the given function from all listening options (see LISTEN_ON_OPTIONS).
+        If the function never was registered, nothing happens. Note: if a function was registered multiple times,
+        this remove function needs to be called only once.
+        :param func: the callback
+        """
+        for _trigger, listeners in self._callbacks.items():
+            while True:
+                try:
+                    listeners.remove(func)
+                except ValueError:
+                    break
+
+    def remove_listener_from_universe(self, universe: int) -> None:
+        """
+        Removes all listeners from the given universe. This does only have effect on the 'universe' listening trigger.
+        If no function was registered for this universe, nothing happens.
+        :param universe: the universe to clear
+        """
+        self._callbacks.pop(universe, None)
+
+    def join_multicast(self, universe: int) -> None:
+        """
+        Joins the multicast address that is used for the given universe. Note: If you are on Windows you must have given
+        a bind IP-Address for this feature to function properly. On the other hand you are not allowed to set a bind
+        address if you are on any other OS.
+        :param universe: the universe to join the multicast group.
+        The network hardware has to support the multicast feature!
+        """
+        self._handler.socket.join_multicast(calculate_multicast_addr(universe))
+
+    def leave_multicast(self, universe: int) -> None:
+        """
+        Try to leave the multicast group with the specified universe. This does not throw any exception if the group
+        could not be leaved.
+        :param universe: the universe to leave the multicast group.
+        The network hardware has to support the multicast feature!
+        """
+        self._handler.socket.leave_multicast(calculate_multicast_addr(universe))
+
+    def start(self) -> None:
+        """
+        Starts a new thread that handles the input. If a thread is already running, the thread will be restarted.
+        """
+        self.stop()  # stop an existing thread
+        self._handler.socket.start()
+
+    def stop(self) -> None:
+        """
+        Stops a running thread and closes the underlying socket. If no thread was started, nothing happens.
+        Do not reuse the socket after calling stop once.
+        """
+        self._handler.socket.stop()
+
+    def get_possible_universes(self) -> Tuple[int]:
+        """
+        Get all universes that are possible because a data packet was received. Timeouted data is removed from the list,
+        so the list may change over time. Depending on sources that are shutting down their streams.
+        :return: a tuple with all universes that were received so far and hadn't a timeout
+        """
+        return tuple(self._handler.get_possible_universes())
+
+    def __del__(self):
+        # stop a potential running thread
+        self.stop()
diff --git a/sacn/receiving/__init__.py b/sacn/receiving/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/sacn/receiving/receiver_handler.py b/sacn/receiving/receiver_handler.py
new file mode 100644
index 00000000..54735234
--- /dev/null
+++ b/sacn/receiving/receiver_handler.py
@@ -0,0 +1,154 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+from typing import Dict, List
+
+from dmx.sacn.messages.data_packet import DataPacket
+from dmx.sacn.receiving.receiver_socket_base import ReceiverSocketBase, ReceiverSocketListener
+from dmx.sacn.receiving.receiver_socket_udp import ReceiverSocketUDP
+
+E131_NETWORK_DATA_LOSS_TIMEOUT_ms = 2500
+
+
+class ReceiverHandlerListener:
+    """
+    Listener interface that defines methods for listening on changes on the ReceiverHandler.
+    """
+
+    def on_availability_change(self, universe: int, changed: str) -> None:
+        raise NotImplementedError
+
+    def on_dmx_data_change(self, packet: DataPacket) -> None:
+        raise NotImplementedError
+
+
+class ReceiverHandler(ReceiverSocketListener):
+    def __init__(self, bind_address: str, bind_port: int, listener: ReceiverHandlerListener, socket: ReceiverSocketBase = None):
+        """
+        This is a private class and should not be used elsewhere. It handles the receiver state with sACN specific values.
+        Calls any changes in the data streams on the listener.
+        Uses a UDP receiver socket with the given bind-address and -port, if the socket was not provided (i.e. None).
+        """
+        if socket is None:
+            self.socket: ReceiverSocketBase = ReceiverSocketUDP(self, bind_address, bind_port)
+        else:
+            self.socket: ReceiverSocketBase = socket
+        self._listener: ReceiverHandlerListener = listener
+        # previousData for storing the last data that was send in a universe to check if the data has changed
+        self._previousData: Dict[int, tuple] = {}
+        # priorities are stored here. This is for checking if the incoming data has the best priority.
+        # universes are the keys and
+        # the value is a tuple with the last priority and the time when this priority recently was received
+        self._priorities: Dict[int, tuple] = {}
+        # store the last timestamp when something on an universe arrived for checking for timeouts
+        self._lastDataTimestamps: Dict[int, float] = {}
+        # store the last sequence number of a universe here:
+        self._lastSequence: Dict[int, int] = {}
+
+    def on_data(self, data: bytes, current_time: float) -> None:
+        try:
+            tmp_packet = DataPacket.make_data_packet(data)
+        except TypeError:  # try to make a DataPacket. If it fails just ignore it
+            return
+
+        self.check_for_stream_terminated_and_refresh_timestamp(tmp_packet, current_time)
+        self.refresh_priorities(tmp_packet, current_time)
+        if not self.is_legal_priority(tmp_packet):
+            return
+        if not self.is_legal_sequence(tmp_packet):  # check for bad sequence number
+            return
+        self.fire_callbacks_universe(tmp_packet)
+
+    def on_periodic_callback(self, current_time: float) -> None:
+        # check all DataTimestamps for timeouts
+        for key, value in list(self._lastDataTimestamps.items()):
+            #  this is converted to list, because the length of the dict changes
+            if check_timeout(current_time, value):
+                self.fire_timeout_callback_and_delete(key)
+
+    def check_for_stream_terminated_and_refresh_timestamp(self, packet: DataPacket, current_time: float) -> None:
+        # refresh the last timestamp on a universe, but check if its the last message of a stream
+        # (the stream is terminated by the Stream termination bit)
+        if packet.option_StreamTerminated:
+            self.fire_timeout_callback_and_delete(packet.universe)
+        else:
+            # check if we add or refresh the data in lastDataTimestamps
+            if packet.universe not in self._lastDataTimestamps.keys():
+                # fire callbacks if this is the first received packet for this universe
+                self._listener.on_availability_change(universe=packet.universe, changed='available')
+            self._lastDataTimestamps[packet.universe] = current_time
+
+    def fire_timeout_callback_and_delete(self, universe: int):
+        self._listener.on_availability_change(universe=universe, changed='timeout')
+        # delete the timestamp so that the callback is not fired multiple times
+        try:
+            del self._lastDataTimestamps[universe]
+        except KeyError:
+            pass  # drop exception, if there was no last timestamp
+        # delete sequence entries so that no packet out of order problems occur
+        try:
+            del self._lastSequence[universe]
+        except KeyError:
+            pass  # drop exception, if there was no last sequence number
+
+    def refresh_priorities(self, packet: DataPacket, current_time: float) -> None:
+        # check the priority and refresh the priorities dict
+        # check if the stored priority has timeouted and make the current packets priority the new one
+        if packet.universe not in self._priorities.keys() or \
+           self._priorities[packet.universe] is None or \
+           check_timeout(current_time, self._priorities[packet.universe][1]) or \
+           self._priorities[packet.universe][0] <= packet.priority:  # if the send priority is higher or
+            # equal than the stored one, than make the priority the new one
+            self._priorities[packet.universe] = (packet.priority, current_time)
+
+    def is_legal_sequence(self, packet: DataPacket) -> bool:
+        """
+        Check if the Sequence number of the DataPacket is legal.
+        For more information see page 17 of http://tsp.esta.org/tsp/documents/docs/E1-31-2016.pdf.
+        :param packet: the packet to check
+        :return: true if the sequence is legal. False if the sequence number is bad
+        """
+        # if the sequence of the packet is smaller than the last received sequence, return false
+        # therefore calculate the difference between the two values:
+        try:  # try, because self.lastSequence might not been initialized
+            diff = packet.sequence - self._lastSequence[packet.universe]
+            # if diff is between ]-20,0], return False for a bad packet sequence
+            if diff <= 0 and diff > -20:
+                return False
+        except KeyError:
+            pass
+        # if the sequence is good, return True and refresh the list with the new value
+        self._lastSequence[packet.universe] = packet.sequence
+        return True
+
+    def is_legal_priority(self, packet: DataPacket):
+        """
+        Check if the given packet has high enough priority for the stored values for the packet's universe.
+        :param packet: the packet to check
+        :return: returns True if the priority is good. Otherwise False
+        """
+        # check if the packet's priority is high enough to get processed
+        if packet.priority < self._priorities[packet.universe][0]:
+            return False  # return if the universe is not interesting
+        else:
+            return True
+
+    def fire_callbacks_universe(self, packet: DataPacket) -> None:
+        # call the listeners for the universe but before check if the data has changed
+        # check if there are listeners for the universe before proceeding
+        if packet.universe not in self._previousData.keys() or \
+           self._previousData[packet.universe] is None or \
+           self._previousData[packet.universe] != packet.dmxData:
+            # set previous data and inherit callbacks
+            self._previousData[packet.universe] = packet.dmxData
+            self._listener.on_dmx_data_change(packet)
+
+    def get_possible_universes(self) -> List[int]:
+        return list(self._lastDataTimestamps.keys())
+
+
+def time_millis(current_time: float) -> int:
+    return int(round(current_time * 1000))
+
+
+def check_timeout(current_time: float, time: float) -> bool:
+    return abs(time_millis(current_time) - time_millis(time)) > E131_NETWORK_DATA_LOSS_TIMEOUT_ms
diff --git a/sacn/receiving/receiver_socket_base.py b/sacn/receiving/receiver_socket_base.py
new file mode 100644
index 00000000..6a4642e4
--- /dev/null
+++ b/sacn/receiving/receiver_socket_base.py
@@ -0,0 +1,37 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import logging
+
+
+class ReceiverSocketListener:
+    """
+    Base class for listener of a ReceiverSocketBase.
+    """
+
+    def on_data(self, data: bytes, current_time: float) -> None:
+        raise NotImplementedError
+
+    def on_periodic_callback(self, current_time: float) -> None:
+        raise NotImplementedError
+
+
+class ReceiverSocketBase:
+    """
+    Base class for abstracting a UDP receiver socket.
+    """
+
+    def __init__(self, listener: ReceiverSocketListener):
+        self._logger: logging.Logger = logging.getLogger('sacn')
+        self._listener: ReceiverSocketListener = listener
+
+    def start(self) -> None:
+        raise NotImplementedError
+
+    def stop(self) -> None:
+        raise NotImplementedError
+
+    def join_multicast(self, multicast_addr: str) -> None:
+        raise NotImplementedError
+
+    def leave_multicast(self, multicast_addr: str) -> None:
+        raise NotImplementedError
diff --git a/sacn/receiving/receiver_socket_udp.py b/sacn/receiving/receiver_socket_udp.py
new file mode 100644
index 00000000..d5a32828
--- /dev/null
+++ b/sacn/receiving/receiver_socket_udp.py
@@ -0,0 +1,95 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import socket
+import threading
+import time
+from dmx.sacn.receiving.receiver_socket_base import ReceiverSocketBase, ReceiverSocketListener
+
+THREAD_NAME = 'sACN input/receiver thread'
+
+
+class ReceiverSocketUDP(ReceiverSocketBase):
+    """
+    Implements a receiver socket with a UDP socket of the OS.
+    """
+
+    def __init__(self, listener: ReceiverSocketListener, bind_address: str, bind_port: int):
+        super().__init__(listener=listener)
+
+        self._bind_address: str = bind_address
+        self._bind_port: int = bind_port
+        self._enabled_flag: bool = True
+
+        # initialize the UDP socket
+        self._socket: socket.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+        try:
+            self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+        except socket.error:  # Not all systems support multiple sockets on the same port and interface
+            pass
+        self._socket.bind((self._bind_address, self._bind_port))
+        self._logger.info(f'Bind receiver socket to IP: {self._bind_address} port: {self._bind_port}')
+
+    def start(self):
+        # initialize thread infos
+        self._thread = threading.Thread(
+            target=self.receive_loop,
+            name=THREAD_NAME
+        )
+        # self._thread.setDaemon(True)  # TODO: might be beneficial to use a daemon thread
+        self._thread.start()
+
+    def receive_loop(self) -> None:
+        """
+        Implements the run method inherited by threading.Thread
+        """
+        self._logger.info(f'Started {THREAD_NAME}')
+        self._socket.settimeout(0.1)  # timeout as 100ms
+        self._enabled_flag = True
+        while self._enabled_flag:
+            # before receiving: invoke periodic callback
+            self._listener.on_periodic_callback(time.time())
+            # receive the data
+            try:
+                raw_data = list(self._socket.recv(2048))  # greater than 1144 because the longest possible packet
+                # in the sACN standard is the universe discovery packet with a max length of 1144
+            except socket.timeout:
+                continue  # if a timeout happens just go through while from the beginning
+            self._listener.on_data(raw_data, time.time())
+
+        self._logger.info(f'Stopped {THREAD_NAME}')
+
+    def stop(self) -> None:
+        """
+        Stops a running thread and closes the underlying socket. If no thread was started, nothing happens.
+        Do not reuse the socket after calling stop once.
+        """
+        self._enabled_flag = False
+        try:
+            self._thread.join()
+            # stop the socket, after the loop terminated
+            self._socket.close()
+        except AttributeError:
+            pass
+
+    def join_multicast(self, multicast_addr: str) -> None:
+        """
+        Join a specific multicast address by string. Only IPv4.
+        """
+        # Windows: https://learn.microsoft.com/en-us/windows/win32/winsock/ipproto-ip-socket-options
+        # Linux: https://man7.org/linux/man-pages/man7/ip.7.html
+        self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
+                                socket.inet_aton(multicast_addr) +
+                                socket.inet_aton(self._bind_address))
+
+    def leave_multicast(self, multicast_addr: str) -> None:
+        """
+        Leave a specific multicast address by string. Only IPv4.
+        """
+        # Windows: https://learn.microsoft.com/en-us/windows/win32/winsock/ipproto-ip-socket-options
+        # Linux: https://man7.org/linux/man-pages/man7/ip.7.html
+        try:
+            self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP,
+                                    socket.inet_aton(multicast_addr) +
+                                    socket.inet_aton(self._bind_address))
+        except socket.error:  # try to leave the multicast group for the universe
+            pass
diff --git a/sacn/sender.py b/sacn/sender.py
new file mode 100644
index 00000000..ae0a4ca2
--- /dev/null
+++ b/sacn/sender.py
@@ -0,0 +1,163 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+"""
+This is a server for sending out sACN and receiving sACN data.
+http://tsp.esta.org/tsp/documents/docs/E1-31-2016.pdf
+"""
+
+import random
+import time
+from typing import Dict, List, Optional
+
+from dmx.sacn.messages.data_packet import DataPacket
+from dmx.sacn.sending.output import Output
+from dmx.sacn.sending.sender_socket_base import SenderSocketBase, DEFAULT_PORT
+from dmx.sacn.sending.sender_handler import SenderHandler
+
+
+class sACNsender:
+    def __init__(self, bind_address: str = '0.0.0.0', bind_port: int = DEFAULT_PORT,
+                 source_name: str = 'default source name', cid: tuple = (),
+                 fps: int = 30, universeDiscovery: bool = True,
+                 sync_universe: int = 63999, socket: SenderSocketBase = None):
+        """
+        Creates a sender object. A sender is used to manage multiple sACN universes and handles their sending.
+        DMX data is send out every second, when no data changes. Some changes may be not send out, because the fps
+        setting defines how often packets are send out to prevent network overuse. So if you change the DMX values too
+        often in a second they may not all been send. Vary the fps parameter to your needs (Default=30).
+        Note that a bind address is needed on Windows for sending out multicast packets.
+        :param bind_address: the IP-Address to bind to.
+        For multicast on a Windows machine this must be set to a proper value otherwise omit.
+        :param bind_port: optionally bind to a specific port. Default=5568. It is not recommended to change the port.
+        Change the port number if you have trouble with another program or the sACNreceiver blocking the port
+        :param source_name: the source name used in the sACN packets.
+        :param cid: the cid. If not given, a random CID will be generated.
+        :param fps: the frames per second. See above explanation. Has to be >0
+        :param sync_universe: universe to send sync packets on.
+        :param socket: Provide a special socket implementation if necessary. Must be derived from SenderSocketBase,
+        only use if the default socket implementation of this library is not sufficient.
+        """
+        if len(cid) != 16:
+            cid = tuple(int(random.random() * 255) for _ in range(0, 16))
+        self._outputs: Dict[int, Output] = {}
+        self._sender_handler = SenderHandler(cid, source_name, self._outputs, bind_address, bind_port, fps, socket)
+        self.universeDiscovery = universeDiscovery
+        self._sync_universe: int = sync_universe
+
+    @property
+    def universeDiscovery(self) -> bool:
+        return self._sender_handler.universe_discovery
+
+    @universeDiscovery.setter
+    def universeDiscovery(self, universeDiscovery: bool) -> None:
+        self._sender_handler.universe_discovery = universeDiscovery
+
+    @property
+    def manual_flush(self) -> bool:
+        return self._sender_handler.manual_flush
+
+    @manual_flush.setter
+    def manual_flush(self, manual_flush: bool) -> None:
+        self._sender_handler.manual_flush = manual_flush
+
+    def flush(self, universes: List[int] = []):
+        """
+        Sends out all universes in one go. This is done on the caller's thread!
+        This uses the E1.31 sync mechanism to try to sync all universes.
+        Note that not all receivers support this feature.
+        :param universes: a list of universes to send. If not given, all will be sent.
+        :raises ValueError: when attempting to flush a universe that is not activated.
+        """
+        for uni in universes:
+            if uni not in self._outputs:
+                raise ValueError(f'Cannot flush universe {uni}, it is not active!')
+        self._sender_handler.send_out_all_universes(
+            self._sync_universe,
+            self._outputs if not universes else {uni: self._outputs[uni] for uni in universes},
+            time.time()
+        )
+
+    def activate_output(self, universe: int) -> None:
+        """
+        Activates a universe that's then starting to sending every second.
+        See http://tsp.esta.org/tsp/documents/docs/E1-31-2016.pdf for more information
+        :param universe: the universe to activate
+        """
+        check_universe(universe)
+        # check, if the universe already exists in the list:
+        if universe in self._outputs:
+            return
+        # add new sending:
+        new_output = Output(DataPacket(cid=self._sender_handler._CID, sourceName=self._sender_handler._source_name, universe=universe))
+        self._outputs[universe] = new_output
+
+    def deactivate_output(self, universe: int) -> None:
+        """
+        Deactivates an existing sending. Every data from the existing sending output will be lost.
+        (TTL, Multicast, DMX data, ..)
+        :param universe: the universe to deactivate. If the universe was not activated before, no error is raised
+        """
+        check_universe(universe)
+        try:  # try to send out three messages with stream_termination bit set to 1
+            self._outputs[universe]._packet.option_StreamTerminated = True
+            for _ in range(0, 3):
+                self._sender_handler.send_out(self._outputs[universe], time.time())
+        except KeyError:
+            pass
+        try:
+            del self._outputs[universe]
+        except KeyError:
+            pass
+
+    def get_active_outputs(self) -> tuple:
+        """
+        Returns a list with all active outputs. Useful when iterating over all sender indexes.
+        :return: list: a list with int (every int is a activated universe. May be not sorted)
+        """
+        return tuple(self._outputs.keys())
+
+    def move_universe(self, universe_from: int, universe_to: int) -> None:
+        """
+        Moves an sending from one universe to another. All settings are being restored and only the universe changes
+        :param universe_from: the universe that should be moved
+        :param universe_to: the target universe. An existing universe will be overwritten
+        """
+        check_universe(universe_from)
+        check_universe(universe_to)
+        # store the sending object and change the universe in the packet of the sending
+        tmp_output = self._outputs[universe_from]
+        # deactivate sending
+        self.deactivate_output(universe_from)
+        # activate new sending with the new universe
+        tmp_output._packet.universe = universe_to
+        tmp_output._packet.option_StreamTerminated = False
+        self._outputs[universe_to] = tmp_output
+
+    def __getitem__(self, item: int) -> Optional[Output]:
+        try:
+            return self._outputs[item]
+        except KeyError:
+            return None
+
+    def start(self) -> None:
+        """
+        Starts or restarts a new Thread with the parameters given in the constructor.
+        """
+        self.stop()
+        self._sender_handler.start()
+
+    def stop(self) -> None:
+        """
+        Stops a running thread and closes the underlying socket. If no thread was started, nothing happens.
+        Do not reuse the socket after calling stop once.
+        """
+        self._sender_handler.stop()
+
+    def __del__(self):
+        # stop a potential running thread
+        self.stop()
+
+
+def check_universe(universe: int):
+    if universe not in range(1, 64000):
+        raise ValueError(f'Universe must be between [1-63999]! Universe was {universe}')
diff --git a/sacn/sending/__init__.py b/sacn/sending/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/sacn/sending/output.py b/sacn/sending/output.py
new file mode 100644
index 00000000..fb1836d1
--- /dev/null
+++ b/sacn/sending/output.py
@@ -0,0 +1,43 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+from dmx.sacn.messages.data_packet import DataPacket
+
+
+class Output:
+    """
+    This class is a compact representation of an sending with all relevant information
+    """
+
+    def __init__(self, packet: DataPacket, last_time_send: int = 0, destination: str = '127.0.0.1',
+                 multicast: bool = False, ttl: int = 8):
+        self._packet: DataPacket = packet
+        self._last_time_send: int = last_time_send
+        self.destination: str = destination
+        self.multicast: bool = multicast
+        self.ttl: int = ttl
+        self._changed: bool = False
+
+    @property
+    def dmx_data(self) -> tuple:
+        return self._packet.dmxData
+
+    @dmx_data.setter
+    def dmx_data(self, dmx_data: tuple):
+        self._packet.dmxData = dmx_data
+        self._changed = True
+
+    @property
+    def priority(self) -> int:
+        return self._packet.priority
+
+    @priority.setter
+    def priority(self, priority: int):
+        self._packet.priority = priority
+
+    @property
+    def preview_data(self) -> bool:
+        return self._packet.option_PreviewData
+
+    @preview_data.setter
+    def preview_data(self, preview_data: bool):
+        self._packet.option_PreviewData = preview_data
diff --git a/sacn/sending/sender_handler.py b/sacn/sending/sender_handler.py
new file mode 100644
index 00000000..b8a50a0e
--- /dev/null
+++ b/sacn/sending/sender_handler.py
@@ -0,0 +1,95 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+from typing import Dict
+from dmx.sacn.messages.universe_discovery import UniverseDiscoveryPacket
+from dmx.sacn.messages.sync_packet import SyncPacket
+from dmx.sacn.messages.data_packet import calculate_multicast_addr
+from dmx.sacn.sending.output import Output
+from dmx.sacn.sending.sender_socket_base import SenderSocketBase, SenderSocketListener
+from dmx.sacn.sending.sender_socket_udp import SenderSocketUDP
+
+SEND_OUT_INTERVAL = 1
+E131_E131_UNIVERSE_DISCOVERY_INTERVAL = 10
+
+
+class SenderHandler(SenderSocketListener):
+    # TODO: start using type CID instead of tuple
+    def __init__(self, cid: tuple, source_name: str, outputs: Dict[int, Output], bind_address: str, bind_port: int, fps: int, socket: SenderSocketBase = None):
+        """
+        This is a private class and should not be used elsewhere. It handles the sender state with sACN specific values.
+        Uses a UDP sender socket with the given bind-address and -port, if the socket was not provided (i.e. None).
+        """
+        if socket is None:
+            self.socket: SenderSocketBase = SenderSocketUDP(self, bind_address, bind_port, fps)
+        else:
+            self.socket: SenderSocketBase = socket
+
+        self._CID = cid
+        self._source_name = source_name
+        self.universe_discovery: bool = True
+        self._last_time_universe_discover: float = 0
+        self._outputs: Dict[int, Output] = outputs
+        self.manual_flush: bool = False
+        self._sync_sequence = 0
+
+    def on_periodic_callback(self, current_time: float) -> None:
+        # send out universe discovery packets if necessary
+        if self.universe_discovery and \
+              abs(current_time - self._last_time_universe_discover) >= E131_E131_UNIVERSE_DISCOVERY_INTERVAL:
+            self.send_universe_discovery_packets()
+            self._last_time_universe_discover = current_time
+
+        # go through the list of outputs and send everything out that has to be send out
+        # Note: dict may changes size during iteration (multithreading)
+        [self.send_out(output, current_time) for output in list(self._outputs.values())
+            # only send if the manual flush feature is disabled
+            # send out when the 1 second interval is over
+            if not self.manual_flush and \
+            (output._changed or abs(current_time - output._last_time_send) >= SEND_OUT_INTERVAL)]
+
+    def send_out(self, output: Output, current_time: float):
+        # 1st: Destination (check if multicast)
+        if output.multicast:
+            udp_ip = output._packet.calculate_multicast_addr()
+            self.socket.send_multicast(output._packet, udp_ip, output.ttl)
+        else:
+            udp_ip = output.destination
+            self.socket.send_unicast(output._packet, udp_ip)
+
+        output._last_time_send = current_time
+        # increase the sequence counter
+        output._packet.sequence_increase()
+        # the changed flag is not necessary any more
+        output._changed = False
+
+    def send_universe_discovery_packets(self):
+        packets = UniverseDiscoveryPacket.make_multiple_uni_disc_packets(
+            cid=self._CID, sourceName=self._source_name, universes=list(self._outputs.keys()))
+        for packet in packets:
+            self.socket.send_broadcast(packet)
+
+    def send_out_all_universes(self, sync_universe: int, universes: dict, current_time: float):
+        """
+        Sends out all universes in one go. This is not done by this thread! This is done by the caller's thread.
+        This uses the E1.31 sync mechanism to try to sync all universes.
+        Note that not all receivers support this feature.
+        """
+        # go through the list of outputs and send everything out
+        # Note: dict may changes size during iteration (multithreading)
+        for output in list(universes.values()):
+            output._packet.syncAddr = sync_universe  # temporarily set the sync universe
+            self.send_out(output, current_time)
+            output._packet.syncAddr = 0
+
+        sync_packet = SyncPacket(cid=self._CID, syncAddr=sync_universe, sequence=self._sync_sequence)
+        # Increment sequence number for next time.
+        self._sync_sequence += 1
+        if self._sync_sequence > 255:
+            self._sync_sequence = 0
+        self.socket.send_multicast(sync_packet, calculate_multicast_addr(sync_universe), 255)
+
+    def start(self):
+        self.socket.start()
+
+    def stop(self):
+        self.socket.stop()
diff --git a/sacn/sending/sender_socket_base.py b/sacn/sending/sender_socket_base.py
new file mode 100644
index 00000000..2be51c96
--- /dev/null
+++ b/sacn/sending/sender_socket_base.py
@@ -0,0 +1,40 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import logging
+from dmx.sacn.messages.root_layer import RootLayer
+
+DEFAULT_PORT = 5568
+
+
+class SenderSocketListener:
+    """
+    Base class for listener of a SenderSocketListener.
+    """
+
+    def on_periodic_callback(self, time: float) -> None:
+        raise NotImplementedError
+
+
+class SenderSocketBase:
+    """
+    Base class for abstracting a UDP sending socket.
+    """
+
+    def __init__(self, listener: SenderSocketListener):
+        self._logger: logging.Logger = logging.getLogger('sacn')
+        self._listener: SenderSocketListener = listener
+
+    def start(self) -> None:
+        raise NotImplementedError
+
+    def stop(self) -> None:
+        raise NotImplementedError
+
+    def send_unicast(self, data: RootLayer, destination: str) -> None:
+        raise NotImplementedError
+
+    def send_multicast(self, data: RootLayer, destination: str, ttl: int) -> None:
+        raise NotImplementedError
+
+    def send_broadcast(self, data: RootLayer) -> None:
+        raise NotImplementedError
diff --git a/sacn/sending/sender_socket_udp.py b/sacn/sending/sender_socket_udp.py
new file mode 100644
index 00000000..8a6f819b
--- /dev/null
+++ b/sacn/sending/sender_socket_udp.py
@@ -0,0 +1,96 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import socket
+import time
+import threading
+
+from dmx.sacn.messages.root_layer import RootLayer
+from dmx.sacn.sending.sender_socket_base import SenderSocketBase, SenderSocketListener, DEFAULT_PORT
+
+THREAD_NAME = 'sACN sending/sender thread'
+
+
+class SenderSocketUDP(SenderSocketBase):
+    """
+    Implements a sender socket with a UDP socket of the OS.
+    """
+
+    def __init__(self, listener: SenderSocketListener, bind_address: str, bind_port: int, fps: int):
+        super().__init__(listener=listener)
+
+        self._bind_address: str = bind_address
+        self._bind_port: int = bind_port
+        self._enabled_flag: bool = True
+        self.fps: int = fps
+
+        # initialize the UDP socket
+        self._socket: socket.socket = socket.socket(socket.AF_INET,  # Internet
+                                                    socket.SOCK_DGRAM)  # UDP
+        try:
+            self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+        except socket.error:  # Not all systems support multiple sockets on the same port and interface
+            pass
+
+        try:
+            self._socket.bind((self._bind_address, self._bind_port))
+            self._logger.info(f'Bind sender thread to IP:{self._bind_address} Port:{self._bind_port}')
+        except socket.error:
+            self._logger.exception(f'Could not bind to IP:{self._bind_address} Port:{self._bind_port}')
+            raise
+
+    def start(self):
+        # initialize thread infos
+        self._thread = threading.Thread(
+            target=self.send_loop,
+            name=THREAD_NAME
+        )
+        # self._thread.setDaemon(True)  # TODO: might be beneficial to use a daemon thread
+        self._thread.start()
+
+    def send_loop(self) -> None:
+        self._logger.info(f'Started {THREAD_NAME}')
+        self._enabled_flag = True
+        while self._enabled_flag:
+            time_stamp = time.time()
+            self._listener.on_periodic_callback(time_stamp)
+            time_to_sleep = (1 / self.fps) - (time.time() - time_stamp)
+            if time_to_sleep < 0:  # if time_to_sleep is negative (because the loop has too much work to do) set it to 0
+                time_to_sleep = 0
+            time.sleep(time_to_sleep)
+            # this sleeps nearly exactly so long that the loop is called every 1/fps seconds
+
+        self._logger.info(f'Stopped {THREAD_NAME}')
+
+    def stop(self) -> None:
+        """
+        Stops a running thread and closes the underlying socket. If no thread was started, nothing happens.
+        Do not reuse the socket after calling stop once.
+        """
+        self._enabled_flag = False
+        # wait for the thread to finish
+        try:
+            self._thread.join()
+            # stop the socket, after the loop terminated
+            self._socket.close()
+        except AttributeError:
+            pass
+
+    def send_unicast(self, data: RootLayer, destination: str) -> None:
+        self.send_packet(data.getBytes(), destination)
+
+    def send_multicast(self, data: RootLayer, destination: str, ttl: int) -> None:
+        # make socket multicast-aware: (set TTL)
+        self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
+        self.send_packet(data.getBytes(), destination)
+
+    def send_broadcast(self, data: RootLayer) -> None:
+        # hint: on windows a bind address must be set, to use broadcast
+        self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+        self.send_packet(data.getBytes(), destination='<broadcast>')
+
+    def send_packet(self, data: bytearray, destination: str) -> None:
+        data_raw = bytearray(data)
+        try:
+            self._socket.sendto(data_raw, (destination, DEFAULT_PORT))
+        except OSError as e:
+            self._logger.warning('Failed to send packet', exc_info=e)
diff --git a/universe.py b/universe.py
index 9b61e9d3..b0cd6e35 100644
--- a/universe.py
+++ b/universe.py
@@ -13,7 +13,10 @@
 
 from bpy.types import (ID,
                        Property,
-                       PropertyGroup)
+                       PropertyGroup,
+                       CollectionProperty)
+
+network_options_list = (("BLENDERDMX", "BlenderDMX", "Set DMX buffer from the Programmer"), ("ARTNET", "ArtNet", "Read DMX buffer from ArtNet"), ("sACN", "sACN", "Read DMX buffer from sACN"))
 
 class DMX_Universe(PropertyGroup):
 
@@ -33,8 +36,7 @@ class DMX_Universe(PropertyGroup):
         name = "Input",
         description = "Input source of the universe",
         default = "BLENDERDMX",
-        items = (("BLENDERDMX", "BlenderDMX", "Set DMX buffer from the Programmer"), ("ARTNET", "ArtNet", "Read DMX buffer from ArtNet"))
-    )
+        items = network_options_list)
 
     input_settings: StringProperty (
         default = "Input Settings"
@@ -51,4 +53,4 @@ def add(dmx, id, name):
     @staticmethod
     def remove(dmx, i):
          if (i >= 0 and i < len(dmx.universes)):
-            dmx.universes.remove(i)
\ No newline at end of file
+            dmx.universes.remove(i)
diff --git a/util.py b/util.py
index 2a4c15c8..c82775c8 100644
--- a/util.py
+++ b/util.py
@@ -38,6 +38,9 @@ def rgb_to_cmy(rgb):
     y = 1 - rgb[2] / 255
 
     min_cmy = min(c, m, y)
+    if min_cmy == 1:
+        return [0,0,0]
+
     c = (c - min_cmy) / (1 - min_cmy)
     m = (m - min_cmy) / (1 - min_cmy)
     y = (y - min_cmy) / (1 - min_cmy)


Test patch:
diff --git a/sacn/messages/data_packet_test.py b/sacn/messages/data_packet_test.py
new file mode 100644
index 00000000..fe32f275
--- /dev/null
+++ b/sacn/messages/data_packet_test.py
@@ -0,0 +1,258 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import pytest
+from dmx.sacn.messages.data_packet import \
+    calculate_multicast_addr, \
+    DataPacket
+from dmx.sacn.messages.general_test import property_number_range_check
+
+
+def test_calculate_multicast_addr():
+    universe_1 = '239.255.0.1'
+    universe_63999 = '239.255.249.255'
+    assert calculate_multicast_addr(1) == universe_1
+    assert calculate_multicast_addr(63999) == universe_63999
+    packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1)
+    assert packet.calculate_multicast_addr() == universe_1
+    packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=63999)
+    assert packet.calculate_multicast_addr() == universe_63999
+
+
+def test_byte_construction_and_deconstruction():
+    built_packet = DataPacket(
+        cid=(16, 1, 15, 2, 14, 3, 13, 4, 12, 5, 11, 6, 10, 7, 9, 8),
+        sourceName='Test Name',
+        universe=62000,
+        dmxData=tuple(x % 256 for x in range(0, 512)),
+        priority=195,
+        sequence=34,
+        streamTerminated=True,
+        previewData=True,
+        forceSync=True,
+        sync_universe=12000,
+        dmxStartCode=12)
+    read_packet = DataPacket.make_data_packet(built_packet.getBytes())
+    assert built_packet.dmxData == read_packet.dmxData
+    assert built_packet == read_packet
+
+
+def test_property_adjustment_and_deconstruction():
+    # Converting DataPacket -> bytes -> DataPacket should produce the same result,
+    # but with changed properties that are not the default
+    built_packet = DataPacket(
+        cid=(16, 1, 15, 2, 14, 3, 13, 4, 12, 5, 11, 6, 10, 7, 9, 8),
+        sourceName='Test Name',
+        universe=30)
+    built_packet.cid = tuple(range(16))
+    built_packet.sourceName = '2nd Test Name'
+    built_packet.universe = 31425
+    built_packet.dmxData = ((200,) + tuple(range(255, 0, -1)) + tuple(range(255)) + (0,))
+    built_packet.priority = 12
+    built_packet.sequence = 45
+    built_packet.option_StreamTerminated = True
+    built_packet.option_PreviewData = True
+    built_packet.option_ForceSync = True
+    built_packet.syncAddr = 34003
+    built_packet.dmxStartCode = 8
+    read_packet = DataPacket.make_data_packet(built_packet.getBytes())
+    assert read_packet.cid == tuple(range(16))
+    assert read_packet.sourceName == '2nd Test Name'
+    assert read_packet.universe == 31425
+    assert read_packet.dmxData == ((200,) + tuple(range(255, 0, -1)) + tuple(range(255)) + (0,))
+    assert read_packet.priority == 12
+    assert read_packet.sequence == 45
+    assert read_packet.option_StreamTerminated is True
+    assert read_packet.option_PreviewData is True
+    assert read_packet.option_ForceSync is True
+    assert read_packet.syncAddr == 34003
+    assert read_packet.dmxStartCode == 8
+
+
+def test_sequence_increment():
+    # Test that the sequence number can be increased and the wrap around at 255 is correct
+    built_packet = DataPacket(
+        cid=(16, 1, 15, 2, 14, 3, 13, 4, 12, 5, 11, 6, 10, 7, 9, 8),
+        sourceName='Test Name',
+        universe=30)
+    built_packet.sequence = 78
+    built_packet.sequence_increase()
+    assert built_packet.sequence == 79
+    built_packet.sequence = 255
+    built_packet.sequence_increase()
+    assert built_packet.sequence == 0
+
+
+def test_parse_data_packet():
+    # Use the example present in the E1.31 spec in appendix B
+    raw_data = [
+        # preamble size
+        0x00, 0x10,
+        # postamble size
+        0x00, 0x00,
+        # ACN packet identifier
+        0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00,
+        # flags and length
+        0x72, 0x7d,
+        # Root vector
+        0x00, 0x00, 0x00, 0x04,
+        # CID
+        0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e,
+        # Framing flags and length
+        0x72, 0x57,
+        # Framing vector
+        0x00, 0x00, 0x00, 0x02,
+        # Source name 'Source_A'
+        0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x41, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        # priority
+        0x64,
+        # sync address
+        0x1f, 0x1a,
+        # sequence number
+        0x9a,
+        # options
+        0x00,
+        # universe
+        0x00, 0x01,
+        # DMP flags and length
+        0x72, 0x0d,
+        # DMP vector
+        0x02,
+        # address type & data type
+        0xa1,
+        # first property address
+        0x00, 0x00,
+        # address increment
+        0x00, 0x01,
+        # property value count
+        0x02, 0x01,
+        # DMX start code
+        0x00,
+    ]
+    # DMX data (starting with 0 and incrementing with wrap around at 255)
+    dmx_data = [x % 256 for x in range(0, 512)]
+    raw_data.extend(dmx_data)
+
+    # parse raw data
+    packet = DataPacket.make_data_packet(raw_data)
+    assert packet.length == 638
+    assert packet._vector == (0x00, 0x00, 0x00, 0x04)
+    assert packet.cid == (0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e)
+    assert packet.sourceName == 'Source_A'
+    assert packet.priority == 100
+    assert packet.syncAddr == 7962
+    assert packet.sequence == 154
+    assert packet.option_ForceSync is False
+    assert packet.option_PreviewData is False
+    assert packet.option_StreamTerminated is False
+    assert packet.universe == 1
+    assert packet.dmxStartCode == 0
+    assert packet.dmxData == tuple(dmx_data)
+
+    # test for invalid data
+    # test for too short data arrays
+    for i in range(1, 126):
+        with pytest.raises(TypeError):
+            DataPacket.make_data_packet([x % 256 for x in range(0, i)])
+    # test for invalid vectors
+    with pytest.raises(TypeError):
+        DataPacket.make_data_packet([x % 256 for x in range(0, 126)])
+
+
+def test_str():
+    packet = DataPacket(
+        cid=(16, 1, 15, 2, 14, 3, 13, 4, 12, 5, 11, 6, 10, 7, 9, 8),
+        sourceName='Test',
+        universe=62000,
+        priority=195,
+        sequence=34)
+    assert packet.__str__() == 'sACN DataPacket: Universe: 62000, Priority: 195, Sequence: 34, CID: (16, 1, 15, 2, 14, 3, 13, 4, 12, 5, 11, 6, 10, 7, 9, 8)'
+
+
+def test_sourceName():
+    # test string has 25 characters but is 64 bytes (1 too many) when UTF-8 encoded
+    overlength_string = "𔑑覱֪I𤵎⠣Ķ'𫳪爓Û:𢏴㓑ò4𰬀鿹џ>𖬲膬ЩJ𞄇"
+    packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1)
+    # test property setter
+    with pytest.raises(TypeError):
+        packet.sourceName = 0x33
+    with pytest.raises(ValueError):
+        packet.sourceName = overlength_string
+    # test constructor
+    with pytest.raises(ValueError):
+        DataPacket(cid=tuple(range(0, 16)), sourceName=overlength_string, universe=1)
+
+
+def test_priority():
+    packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1)
+    # test property setter
+    def property(i): packet.priority = i
+    # test constructor for the same parameter
+    def constructor(i): DataPacket(tuple(range(0, 16)), sourceName="", universe=1, priority=i)
+    property_number_range_check(0, 200, property, constructor)
+
+
+def test_universe():
+    packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1)
+    # test property setter
+    def property(i): packet.universe = i
+    # test constructor for the same parameter
+    def constructor(i): DataPacket(tuple(range(0, 16)), sourceName="", universe=i)
+    property_number_range_check(1, 63999, property, constructor)
+
+
+def test_sync_universe():
+    packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1)
+    # test property setter
+    def property(i): packet.syncAddr = i
+    # test constructor for the same parameter
+    def constructor(i): DataPacket(tuple(range(0, 16)), sourceName="", universe=1, sync_universe=i)
+    property_number_range_check(0, 63999, property, constructor)
+
+
+def test_sequence():
+    packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1)
+    # test property setter
+    def property(i): packet.sequence = i
+    # test constructor for the same parameter
+    def constructor(i): DataPacket(tuple(range(0, 16)), sourceName="", universe=1, sequence=i)
+    property_number_range_check(0, 255, property, constructor)
+
+
+def test_dmx_start_code():
+    packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1)
+    # test property setter
+    def property(i): packet.dmxStartCode = i
+    # test constructor for the same parameter
+    def constructor(i): DataPacket(tuple(range(0, 16)), sourceName="", universe=1, dmxStartCode=i)
+    property_number_range_check(0, 255, property, constructor)
+
+
+def test_dmx_data():
+    packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1)
+    # test valid lengths
+    for i in range(0, 512):
+        data = tuple(x % 256 for x in range(0, i))
+        # test property setter
+        packet.dmxData = data
+        assert len(packet.dmxData) == 512
+        assert packet.length == 638
+        # test constructor for the same parameter
+        packet2 = DataPacket(tuple(range(0, 16)), sourceName="", universe=1, dmxData=data)
+        assert len(packet2.dmxData) == 512
+        assert packet2.length == 638
+
+    def execute_universes_expect(data: tuple):
+        with pytest.raises(ValueError):
+            packet.dmxData = data
+        with pytest.raises(ValueError):
+            DataPacket(tuple(range(0, 16)), sourceName="", universe=1, dmxData=data)
+
+    # test for non-int and out of range values values in tuple
+    execute_universes_expect(tuple('string'))
+    execute_universes_expect(tuple(range(255, 257)))
+
+    # test for tuple-length > 512
+    execute_universes_expect(tuple(range(0, 513)))
diff --git a/sacn/messages/data_types_test.py b/sacn/messages/data_types_test.py
new file mode 100644
index 00000000..74636e36
--- /dev/null
+++ b/sacn/messages/data_types_test.py
@@ -0,0 +1,75 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+
+import pytest
+from typing import Any, Callable
+from dmx.sacn.messages.data_types import CID
+
+
+def create_valid_cid() -> CID:
+    return CID(tuple(range(0, 16)))
+
+
+def cid_failing_test_cases(apply_to: Callable[[Any], None]):
+    def char_range(char1, char2):
+        """Generates the characters from `c1` to `c2`, ranged just like python."""
+        for c in range(ord(char1), ord(char2)):
+            yield chr(c)
+
+    # test that constructor validates cid
+    with pytest.raises(ValueError):
+        apply_to(tuple(char_range('A', 'Q')))
+    # test that CID must be 16 elements
+    with pytest.raises(ValueError):
+        apply_to(tuple(range(0, 17)))
+    with pytest.raises(ValueError):
+        apply_to(tuple(range(0, 15)))
+    # test that CID only contains valid byte values
+    with pytest.raises(ValueError):
+        apply_to(tuple(range(250, 266)))
+    with pytest.raises(ValueError):
+        apply_to(tuple(char_range('b', 'r')))
+    # test that CID is a tuple
+    with pytest.raises(TypeError):
+        apply_to(range(0, 16))
+
+
+def test_cid_constructor():
+    # normal values must work
+    tuple1 = tuple(range(0, 16))
+    tuple2 = tuple(range(1, 17))
+    cid1 = CID(tuple1)
+    cid2 = CID(tuple2)
+    assert cid1.value == tuple1
+    assert cid2.value == tuple2
+
+    def apply_constructor(value) -> None:
+        CID(value)
+
+    cid_failing_test_cases(apply_constructor)
+
+
+def test_cid_setter():
+    tuple1 = tuple(range(0, 16))
+    tuple2 = tuple(range(1, 17))
+
+    cid = CID(tuple(range(2, 18)))
+    # normal values must work
+    cid.value = tuple1
+    assert cid.value == tuple1
+    cid.value = tuple2
+    assert cid.value == tuple2
+
+    def apply_setter(value) -> None:
+        cid.value = value
+
+    cid_failing_test_cases(apply_setter)
+
+
+def test_cid_equals():
+    tuple1 = tuple(range(0, 16))
+    tuple2 = tuple(range(0, 16))
+
+    cid1 = CID(tuple1)
+    cid2 = CID(tuple2)
+    assert cid1 == cid2
diff --git a/sacn/messages/general_test.py b/sacn/messages/general_test.py
new file mode 100644
index 00000000..2051af10
--- /dev/null
+++ b/sacn/messages/general_test.py
@@ -0,0 +1,15 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import pytest
+
+
+def property_number_range_check(lower_bound: int, upper_bound: int, *functions):
+    for function in functions:
+        for i in range(lower_bound, upper_bound + 1):
+            function(i)
+        with pytest.raises(ValueError):
+            function(lower_bound - 1)
+        with pytest.raises(ValueError):
+            function(upper_bound + 1)
+        with pytest.raises(TypeError):
+            function('Text')
diff --git a/sacn/messages/root_layer_test.py b/sacn/messages/root_layer_test.py
new file mode 100644
index 00000000..93bb1496
--- /dev/null
+++ b/sacn/messages/root_layer_test.py
@@ -0,0 +1,117 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import pytest
+from dmx.sacn.messages.root_layer import \
+    byte_tuple_to_int, \
+    int_to_bytes, \
+    make_flagsandlength, \
+    RootLayer
+
+
+def test_int_to_bytes():
+    assert int_to_bytes(0xFFFF) == [0xFF, 0xFF]
+    assert int_to_bytes(0x1234) == [0x12, 0x34]
+    # test that the value cannot exceed two bytes
+    with pytest.raises(ValueError):
+        int_to_bytes(0x123456)
+    assert int_to_bytes(0x0001) == [0x00, 0x01]
+
+
+def test_byte_tuple_to_int():
+    assert byte_tuple_to_int((0x00, 0x00)) == 0x0000
+    assert byte_tuple_to_int((0xFF, 0xFF)) == 0xFFFF
+    assert byte_tuple_to_int((0x12, 0x34)) == 0x1234
+    # test different length of tuples
+    with pytest.raises(ValueError):
+        byte_tuple_to_int(())
+    with pytest.raises(TypeError):
+        byte_tuple_to_int(1)
+    with pytest.raises(ValueError):
+        byte_tuple_to_int((1, 2, 3))
+    with pytest.raises(ValueError):
+        byte_tuple_to_int((1, 'string'))
+    with pytest.raises(ValueError):
+        byte_tuple_to_int((1, 500))
+
+
+def test_eq():
+    cid = tuple(range(0, 16))
+    cid2 = tuple(range(1, 17))
+    vec = tuple(range(0, 4))
+    vec2 = tuple(range(1, 5))
+    assert RootLayer(0, cid, vec) == RootLayer(0, cid, vec)
+    assert RootLayer(0, cid, vec) != RootLayer(1, cid, vec)
+    assert RootLayer(0, cid, vec) != RootLayer(0, cid, vec2)
+    assert RootLayer(0, cid, vec) != RootLayer(0, cid2, vec)
+    assert (RootLayer(0, cid, vec) == (1, 2, 3)) is False
+
+
+def test_make_flagsandlength():
+    assert make_flagsandlength(0x123) == [0x71, 0x23]
+    with pytest.raises(ValueError):
+        assert make_flagsandlength(0x1234) == [0x72, 0x34]
+    assert make_flagsandlength(0x001) == [0x70, 0x01]
+
+
+def test_cid():
+    cid1 = tuple(range(0, 16))
+    cid2 = tuple(range(1, 17))
+    vector1 = (1, 2, 3, 4)
+
+    def char_range(char1, char2):
+        """Generates the characters from `c1` to `c2`, ranged just like python."""
+        for c in range(ord(char1), ord(char2)):
+            yield chr(c)
+
+    # test constructor
+    packet = RootLayer(123, cid1, vector1)
+    assert packet.cid == cid1
+    packet.cid = cid2
+    assert packet.cid == cid2
+    # test that constructor validates cid
+    with pytest.raises(ValueError):
+        RootLayer(length=123, cid=tuple(char_range('A', 'Q')), vector=vector1)
+    # test that CID must be 16 elements
+    with pytest.raises(ValueError):
+        packet.cid = tuple(range(0, 17))
+    with pytest.raises(ValueError):
+        packet.cid = tuple(range(0, 15))
+    # test that CID only contains valid byte values
+    with pytest.raises(ValueError):
+        packet.cid = tuple(range(250, 266))
+    with pytest.raises(ValueError):
+        packet.cid = tuple(char_range('b', 'r'))
+    # test that CID is a tuple
+    with pytest.raises(TypeError):
+        packet.cid = range(0, 16)
+
+
+def test_root_layer_bytes():
+    cid = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)
+    vector = (1, 2, 3, 4)
+    # test that the vector length must be 4
+    with pytest.raises(ValueError):
+        RootLayer(0, cid, ())
+    # test that the cid length must be 16
+    with pytest.raises(ValueError):
+        RootLayer(0, (), vector)
+    packet = RootLayer(0x123456, cid, vector)
+    shouldBe = [
+        # initial static vector
+        0, 0x10, 0, 0, 0x41, 0x53, 0x43, 0x2d, 0x45,
+        0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00,
+        # length value
+        0x74, 0x46
+    ]
+    # vector
+    shouldBe.extend(vector)
+    # cid
+    shouldBe.extend(cid)
+    assert packet.getBytes() == shouldBe
+
+
+def test_int_byte_transitions():
+    # test the full 0-65534 range, though only using 0-63999 currently
+    for input_i in range(65536):
+        converted_i = byte_tuple_to_int(tuple(int_to_bytes(input_i)))
+        assert input_i == converted_i
diff --git a/sacn/messages/sync_packet_test.py b/sacn/messages/sync_packet_test.py
new file mode 100644
index 00000000..dba41b5f
--- /dev/null
+++ b/sacn/messages/sync_packet_test.py
@@ -0,0 +1,131 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import pytest
+from dmx.sacn.messages.sync_packet import SyncPacket
+from dmx.sacn.messages.general_test import property_number_range_check
+
+
+def test_constructor():
+    # positive tests
+    cid = tuple(range(0, 16))
+    syncAddr = 12
+    sequence = 127
+    packet = SyncPacket(cid, syncAddr, sequence)
+    assert packet.length == 49
+    assert packet.cid == cid
+    assert packet.syncAddr == syncAddr
+    assert packet.sequence == sequence
+    # using wrong values for CID
+    with pytest.raises(ValueError):
+        SyncPacket(tuple(range(0, 17)), syncAddr, sequence)
+
+
+def test_sync_universe():
+    packet = SyncPacket(tuple(range(0, 16)), 1, 1)
+    # test property setter
+    def property(i): packet.syncAddr = i
+    # test constructor for the same parameter
+    def constructor(i): SyncPacket(tuple(range(0, 16)), i, 1)
+    property_number_range_check(1, 63999, property, constructor)
+
+
+def test_sequence():
+    packet = SyncPacket(tuple(range(0, 16)), 1, 1)
+    # test property setter
+    def property(i): packet.sequence = i
+    # test constructor for the same parameter
+    def constructor(i): SyncPacket(tuple(range(0, 16)), 1, i)
+    property_number_range_check(0, 255, property, constructor)
+
+
+def test_sequence_increment():
+    # Test that the sequence number can be increased and the wrap around at 255 is correct
+    built_packet = SyncPacket(tuple(range(0, 16)), 1, 1)
+    built_packet.sequence = 78
+    built_packet.sequence_increase()
+    assert built_packet.sequence == 79
+    built_packet.sequence = 255
+    built_packet.sequence_increase()
+    assert built_packet.sequence == 0
+
+
+def test_get_bytes():
+    # Use the example present in the E1.31 spec in appendix B
+    cid = (0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e)
+    syncAddr = 7962
+    sequence = 67  # Note: the spec states 367, which is a mistake in the spec
+    packet = SyncPacket(cid, syncAddr, sequence)
+    assert packet.getBytes() == [
+        # preamble size
+        0x00, 0x10,
+        # postamble size
+        0x00, 0x00,
+        # ACN packet identifier
+        0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00,
+        # flags and length; again a mistake in the E1.31 spec, as this states '0x70, 0x30'
+        # this would violate the parent spec E1.17 (ACN) section 2.4.2
+        0x70, 0x21,
+        # Root vector
+        0x00, 0x00, 0x00, 0x08,
+        # CID
+        0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e,
+        # Framing flags and length; again propably a mistake as with the flags and length above
+        0x70, 0x0b,
+        # Framing vector
+        0x00, 0x00, 0x00, 0x01,
+        # sequence number
+        0x43,
+        # sync address
+        0x1f, 0x1a,
+        # reserved fields
+        0x00, 0x00,
+    ]
+
+
+def test_parse_sync_packet():
+    # Use the example present in the E1.31 spec in appendix B
+    raw_data = [
+        # preamble size
+        0x00, 0x10,
+        # postamble size
+        0x00, 0x00,
+        # ACN packet identifier
+        0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00,
+        # flags and length; again a mistake in the E1.31 spec, as this states '0x70, 0x30'
+        # this would violate the parent spec E1.17 (ACN) section 2.4.2
+        0x70, 0x21,
+        # Root vector
+        0x00, 0x00, 0x00, 0x08,
+        # CID
+        0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e,
+        # Framing flags and length; again propably a mistake as with the flags and length above
+        0x70, 0x0b,
+        # Framing vector
+        0x00, 0x00, 0x00, 0x01,
+        # sequence number
+        0x43,
+        # sync address
+        0x1f, 0x1a,
+        # reserved fields
+        0x00, 0x00,
+    ]
+    packet = SyncPacket.make_sync_packet(raw_data)
+    assert packet.length == 49
+    assert packet.cid == (0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e)
+    assert packet.sequence == 67
+    assert packet.syncAddr == 7962
+
+    # test for invalid data
+    # test for too short data arrays
+    for i in range(1, 47):
+        with pytest.raises(TypeError):
+            SyncPacket.make_sync_packet([x % 256 for x in range(0, i)])
+    # test for invalid vectors
+    with pytest.raises(TypeError):
+        SyncPacket.make_sync_packet([x % 256 for x in range(0, 47)])
+
+
+def test_byte_construction_and_deconstruction():
+    built_packet = SyncPacket(tuple(range(0, 16)), 12, 127)
+    read_packet = SyncPacket.make_sync_packet(built_packet.getBytes())
+    assert built_packet == read_packet
diff --git a/sacn/messages/universe_discovery_test.py b/sacn/messages/universe_discovery_test.py
new file mode 100644
index 00000000..4b04c5c0
--- /dev/null
+++ b/sacn/messages/universe_discovery_test.py
@@ -0,0 +1,213 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import pytest
+from dmx.sacn.messages.universe_discovery import UniverseDiscoveryPacket
+from dmx.sacn.messages.general_test import property_number_range_check
+
+
+def test_constructor():
+    # positive tests
+    cid = tuple(range(0, 16))
+    sourceName = 'Test'
+    universes = tuple(range(0, 512))
+    page = 0
+    lastPage = 1
+    packet = UniverseDiscoveryPacket(cid, sourceName, universes, page, lastPage)
+    assert packet.length == 120 + (2 * len(universes))
+    assert packet.cid == cid
+    assert packet.sourceName == sourceName
+    assert packet.universes == universes
+    assert packet.page == page
+    assert packet.lastPage == lastPage
+    # using wrong values for CID
+    with pytest.raises(ValueError):
+        UniverseDiscoveryPacket(tuple(range(0, 17)), sourceName, universes)
+
+
+def test_sourceName():
+    # test string has 25 characters but is 64 bytes (1 too many) when UTF-8 encoded
+    overlength_string = "𔑑覱֪I𤵎⠣Ķ'𫳪爓Û:𢏴㓑ò4𰬀鿹џ>𖬲膬ЩJ𞄇"
+    packet = UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', ())
+    # test property setter
+    with pytest.raises(TypeError):
+        packet.sourceName = 0x33
+    with pytest.raises(ValueError):
+        packet.sourceName = overlength_string
+    # test constructor
+    with pytest.raises(ValueError):
+        packet = UniverseDiscoveryPacket(tuple(range(0, 16)), overlength_string, ())
+
+
+def test_page():
+    packet = UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', ())
+    # test property setter
+    def property(i): packet.page = i
+    # test constructor for the same parameter
+    def constructor(i): UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', (), i)
+    property_number_range_check(0, 255, property, constructor)
+
+
+def test_last_page():
+    packet = UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', ())
+    # test property setter
+    def property(i): packet.lastPage = i
+    # test constructor for the same parameter
+    def constructor(i): UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', (), 0, i)
+    property_number_range_check(0, 255, property, constructor)
+
+
+def test_universes():
+    packet = UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', ())
+
+    def execute_universes_expect(universes: tuple):
+        with pytest.raises(ValueError):
+            packet.universes = universes
+        with pytest.raises(ValueError):
+            UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', universes)
+
+    # test valid lengths
+    for i in range(1, 513):
+        universes = tuple(range(0, i))
+        # test property setter
+        packet.universes = universes
+        assert packet.length == 120 + (2 * len(universes))
+        # test constructor for the same parameter
+        UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', universes)
+
+    # test that the universes list is sorted
+    packet.universes = (3, 1, 2)
+    assert packet.universes == (1, 2, 3)
+
+    # test for non-int and out of range values values in tuple
+    execute_universes_expect(tuple('string'))
+    execute_universes_expect(tuple(range(64000, 65000)))
+
+    # test for tuple-length > 512
+    execute_universes_expect(tuple(range(0, 513)))
+
+
+def test_make_multiple_uni_disc_packets():
+    # test with a list that spawns three packets
+    universes = list(range(0, 1026))
+    packets = UniverseDiscoveryPacket.make_multiple_uni_disc_packets(tuple(range(0, 16)), 'Test', universes)
+    assert len(packets) == 3
+    assert packets[0].universes == tuple(range(0, 512))
+    assert packets[1].universes == tuple(range(512, 1024))
+    assert packets[2].universes == tuple(range(1024, 1026))
+    assert packets[0].page == 0
+    assert packets[1].page == 1
+    assert packets[2].page == 2
+    assert packets[0].lastPage == 2
+    assert packets[1].lastPage == 2
+    assert packets[2].lastPage == 2
+    # test with a list that spawns one packet
+    universes = list(range(0, 2))
+    packets = UniverseDiscoveryPacket.make_multiple_uni_disc_packets(tuple(range(0, 16)), 'Test', universes)
+    assert len(packets) == 1
+    assert packets[0].universes == tuple(universes)
+    assert packets[0].page == 0
+    assert packets[0].lastPage == 0
+
+
+def test_get_bytes():
+    cid = (0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e)
+    packet = UniverseDiscoveryPacket(cid, 'Source_A', (1, 2, 3), 0, 1)
+    assert packet.getBytes() == [
+        # preamble size
+        0x00, 0x10,
+        # postamble size
+        0x00, 0x00,
+        # ACN packet identifier
+        0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00,
+        # flags and length
+        0x70, 0x6e,
+        # Root vector
+        0x00, 0x00, 0x00, 0x08,
+        # CID
+        0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e,
+        # Framing flags and length
+        0x70, 0x58,
+        # Framing vector
+        0x00, 0x00, 0x00, 0x02,
+        # Source name 'Source_A'
+        0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x41, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        # reserved
+        0x00, 0x00, 0x00, 0x00,
+        # universe discovery layer - flags and length
+        0x70, 0x0e,
+        # vector
+        0x00, 0x00, 0x00, 0x01,
+        # page
+        0x00,
+        # last page
+        0x01,
+        # universes as 16-bit integers
+        0x00, 0x01, 0x00, 0x02, 0x00, 0x03,
+    ]
+
+
+def test_parse_sync_packet():
+    raw_data = [
+        # preamble size
+        0x00, 0x10,
+        # postamble size
+        0x00, 0x00,
+        # ACN packet identifier
+        0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00,
+        # flags and length
+        0x70, 0x6e,
+        # Root vector
+        0x00, 0x00, 0x00, 0x08,
+        # CID
+        0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e,
+        # Framing flags and length
+        0x70, 0x58,
+        # Framing vector
+        0x00, 0x00, 0x00, 0x02,
+        # Source name 'Source_A'
+        0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x41, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        # reserved
+        0x00, 0x00, 0x00, 0x00,
+        # universe discovery layer - flags and length
+        0x70, 0x0e,
+        # vector
+        0x00, 0x00, 0x00, 0x01,
+        # page
+        0x00,
+        # last page
+        0x01,
+        # universes as 16-bit integers
+        0x00, 0x01, 0x00, 0x02, 0x00, 0x03,
+    ]
+    packet = UniverseDiscoveryPacket.make_universe_discovery_packet(raw_data)
+    assert packet.length == 126
+    assert packet.cid == (0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e)
+    assert packet.sourceName == 'Source_A'
+    assert packet.page == 0
+    assert packet.lastPage == 1
+    assert packet.universes == (1, 2, 3)
+
+    # test for invalid data
+    # test for too short data arrays
+    for i in range(1, 120):
+        with pytest.raises(TypeError):
+            UniverseDiscoveryPacket.make_universe_discovery_packet([x % 256 for x in range(0, i)])
+    # test for invalid vectors
+    with pytest.raises(TypeError):
+        UniverseDiscoveryPacket.make_universe_discovery_packet([x % 256 for x in range(0, 126)])
+    # test for odd universes list length
+    raw_data = raw_data[0:len(raw_data) - 1]
+    with pytest.raises(TypeError):
+        UniverseDiscoveryPacket.make_universe_discovery_packet(raw_data)
+
+
+def test_byte_construction_and_deconstruction():
+    built_packet = UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', tuple(range(0, 512)), 0, 1)
+    read_packet = UniverseDiscoveryPacket.make_universe_discovery_packet(built_packet.getBytes())
+    assert built_packet == read_packet
diff --git a/sacn/receiver_test.py b/sacn/receiver_test.py
new file mode 100644
index 00000000..e01d67c3
--- /dev/null
+++ b/sacn/receiver_test.py
@@ -0,0 +1,221 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import pytest
+import sacn
+from sacn.messages.data_packet import DataPacket
+from sacn.receiving.receiver_socket_test import ReceiverSocketTest
+
+
+def get_receiver():
+    socket = ReceiverSocketTest()
+    receiver = sacn.sACNreceiver(socket=socket)
+    # wire up for unit test
+    receiver._handler.socket._listener = receiver._handler
+    return receiver, socket
+
+
+def test_constructor():
+    receiver, _ = get_receiver()
+    assert receiver._callbacks is not None
+    assert receiver._handler is not None
+
+
+def test_listen_on_availability_change():
+    receiver, socket = get_receiver()
+
+    called = False
+
+    @receiver.listen_on('availability')
+    def callback_available(universe, changed):
+        assert changed == 'available'
+        assert universe == 1
+        nonlocal called
+        called = True
+
+    packet = DataPacket(
+        cid=tuple(range(0, 16)),
+        sourceName='Test',
+        universe=1,
+        dmxData=tuple(range(0, 16))
+    )
+    socket.call_on_data(bytes(packet.getBytes()), 0)
+    assert called
+
+
+def test_listen_on_dmx_data_change():
+    receiver, socket = get_receiver()
+
+    packetSend = DataPacket(
+        cid=tuple(range(0, 16)),
+        sourceName='Test',
+        universe=1,
+        dmxData=tuple(range(0, 16))
+    )
+
+    called = False
+
+    @receiver.listen_on('universe', universe=packetSend.universe)
+    def callback_packet(packet):
+        assert packetSend.__dict__ == packet.__dict__
+        nonlocal called
+        called = True
+
+    socket.call_on_data(bytes(packetSend.getBytes()), 0)
+    assert called
+
+
+def test_remove_listener():
+    receiver, socket = get_receiver()
+
+    packetSend = DataPacket(
+        cid=tuple(range(0, 16)),
+        sourceName='Test',
+        universe=1,
+        dmxData=tuple(range(0, 16))
+    )
+
+    called = 0
+
+    def callback_packet(packet):
+        assert packetSend.__dict__ == packet.__dict__
+        nonlocal called
+        called += 1
+
+    # register listener multiple times
+    receiver.register_listener('universe', callback_packet, universe=packetSend.universe)
+    receiver.register_listener('universe', callback_packet, universe=packetSend.universe)
+
+    socket.call_on_data(bytes(packetSend.getBytes()), 0)
+    assert called == 2
+
+    # change DMX data to trigger a change
+    packetSend.dmxData = tuple(range(16, 32))
+    packetSend.sequence_increase()
+
+    receiver.remove_listener(callback_packet)
+
+    # removing a listener does not exist, should do nothing
+    receiver.remove_listener(None)
+
+    socket.call_on_data(bytes(packetSend.getBytes()), 0)
+    assert called == 2
+
+
+def test_remove_listener_from_universe():
+    receiver, socket = get_receiver()
+
+    test_universe_one = 1
+    test_universe_two = 2
+
+    packet_send = DataPacket(
+        cid=tuple(range(0, 16)),
+        sourceName='Test',
+        universe=test_universe_one,
+        dmxData=tuple(range(0, 16))
+    )
+
+    called = 0
+
+    def callback_packet(packet):
+        assert packet_send.__dict__ == packet.__dict__
+        nonlocal called
+        called += 1
+
+    # register listener multiple times
+    receiver.register_listener('universe', callback_packet, universe=test_universe_one)
+    receiver.register_listener('universe', callback_packet, universe=test_universe_two)
+
+    packet_send.universe = test_universe_one
+    socket.call_on_data(bytes(packet_send.getBytes()), 0)
+    assert called == 1
+    packet_send.universe = test_universe_two
+    socket.call_on_data(bytes(packet_send.getBytes()), 0)
+    assert called == 2
+
+    # change DMX data to trigger a change
+    packet_send.dmxData = tuple(range(16, 32))
+    packet_send.sequence_increase()
+
+    test_universe_removed = test_universe_one
+    receiver.remove_listener_from_universe(test_universe_removed)
+
+    # removing from a universe that does not exist, should do nothing
+    receiver.remove_listener_from_universe(12345)
+
+    # call to the removed universe should not happen
+    packet_send.universe = test_universe_removed
+    socket.call_on_data(bytes(packet_send.getBytes()), 0)
+    assert called == 2
+    # other universes should not be affected
+    packet_send.universe = test_universe_two
+    socket.call_on_data(bytes(packet_send.getBytes()), 0)
+    assert called == 3
+
+
+def test_invalid_listener():
+    receiver, socket = get_receiver()
+
+    with pytest.raises(TypeError):
+        @receiver.listen_on('test')
+        def callback():
+            pass
+
+
+def test_possible_universes():
+    receiver, socket = get_receiver()
+
+    assert receiver.get_possible_universes() == ()
+    packet = DataPacket(
+        cid=tuple(range(0, 16)),
+        sourceName='Test',
+        universe=1,
+        dmxData=tuple(range(0, 16))
+    )
+    socket.call_on_data(bytes(packet.getBytes()), 0)
+    assert receiver.get_possible_universes() == tuple([1])
+
+
+def test_join_multicast():
+    receiver, socket = get_receiver()
+
+    assert socket.join_multicast_called is None
+    receiver.join_multicast(1)
+    assert socket.join_multicast_called == '239.255.0.1'
+
+    with pytest.raises(TypeError):
+        receiver.join_multicast('test')
+
+
+def test_leave_multicast():
+    receiver, socket = get_receiver()
+
+    assert socket.leave_multicast_called is None
+    receiver.leave_multicast(1)
+    assert socket.leave_multicast_called == '239.255.0.1'
+
+    with pytest.raises(TypeError):
+        receiver.leave_multicast('test')
+
+
+def test_start():
+    receiver, socket = get_receiver()
+
+    assert socket.start_called is False
+    receiver.start()
+    assert socket.start_called is True
+
+
+def test_stop():
+    receiver, socket = get_receiver()
+
+    assert socket.stop_called is False
+    receiver.stop()
+    assert socket.stop_called is True
+
+
+def test_stop_destructor():
+    receiver, socket = get_receiver()
+
+    assert socket.stop_called is False
+    receiver.__del__()
+    assert socket.stop_called is True
diff --git a/sacn/receiving/receiver_handler_test.py b/sacn/receiving/receiver_handler_test.py
new file mode 100644
index 00000000..c03f07ac
--- /dev/null
+++ b/sacn/receiving/receiver_handler_test.py
@@ -0,0 +1,225 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import pytest
+from dmx.sacn.messages.data_packet import DataPacket
+from dmx.sacn.receiving.receiver_handler import ReceiverHandler, ReceiverHandlerListener, E131_NETWORK_DATA_LOSS_TIMEOUT_ms
+from dmx.sacn.receiving.receiver_socket_test import ReceiverSocketTest
+
+
+class ReceiverHandlerListenerTest(ReceiverHandlerListener):
+    def __init__(self):
+        self.on_availability_change_universe: int = None
+        self.on_availability_change_changed: str = None
+        self.on_dmx_data_change_packet: DataPacket = None
+
+    def on_availability_change(self, universe: int, changed: str) -> None:
+        self.on_availability_change_universe = universe
+        self.on_availability_change_changed = changed
+
+    def on_dmx_data_change(self, packet: DataPacket) -> None:
+        self.on_dmx_data_change_packet = packet
+
+
+def get_handler():
+    bind_address = 'Test'
+    bind_port = 1234
+    listener = ReceiverHandlerListenerTest()
+    socket = ReceiverSocketTest()
+    handler = ReceiverHandler(bind_address, bind_port, listener, socket)
+    # wire up for unit test
+    socket._listener = handler
+    return handler, listener, socket
+
+
+def test_constructor():
+    handler, _, _ = get_handler()
+    assert handler._listener is not None
+    assert handler._previousData is not None
+    assert handler._priorities is not None
+    assert handler._lastDataTimestamps is not None
+    assert handler._lastSequence is not None
+
+
+def test_first_packet():
+    _, listener, socket = get_handler()
+    assert listener.on_availability_change_changed is None
+    assert listener.on_availability_change_universe is None
+    assert listener.on_dmx_data_change_packet is None
+    packet = DataPacket(
+        cid=tuple(range(0, 16)),
+        sourceName='Test',
+        universe=1,
+        dmxData=tuple(range(0, 16))
+    )
+    socket.call_on_data(bytes(packet.getBytes()), 0)
+    assert listener.on_availability_change_changed == 'available'
+    assert listener.on_availability_change_universe == 1
+    assert listener.on_dmx_data_change_packet.__dict__ == packet.__dict__
+
+
+def test_first_packet_stream_terminated():
+    _, listener, socket = get_handler()
+    assert listener.on_availability_change_changed is None
+    assert listener.on_availability_change_universe is None
+    assert listener.on_dmx_data_change_packet is None
+    packet = DataPacket(
+        cid=tuple(range(0, 16)),
+        sourceName='Test',
+        universe=1,
+        dmxData=tuple(range(0, 16)),
+        streamTerminated=True
+    )
+    socket.call_on_data(bytes(packet.getBytes()), 0)
+    assert listener.on_availability_change_changed == 'timeout'
+    assert listener.on_availability_change_universe == 1
+    assert listener.on_dmx_data_change_packet.__dict__ == packet.__dict__
+
+
+def test_invalid_packet_bytes():
+    _, listener, socket = get_handler()
+    assert listener.on_availability_change_changed is None
+    assert listener.on_availability_change_universe is None
+    assert listener.on_dmx_data_change_packet is None
+    # provide 'random' data that is no DataPacket
+    socket.call_on_data(bytes(x % 256 for x in range(0, 512)), 0)
+    assert listener.on_availability_change_changed is None
+    assert listener.on_availability_change_universe is None
+    assert listener.on_dmx_data_change_packet is None
+
+
+def test_invalid_priority():
+    # send a lower priority on a second packet
+    _, listener, socket = get_handler()
+    assert listener.on_dmx_data_change_packet is None
+    packet1 = DataPacket(
+        cid=tuple(range(0, 16)),
+        sourceName='Test',
+        universe=1,
+        dmxData=tuple(range(0, 16)),
+        priority=100
+    )
+    socket.call_on_data(bytes(packet1.getBytes()), 0)
+    assert listener.on_dmx_data_change_packet.__dict__ == packet1.__dict__
+    packet2 = DataPacket(
+        cid=tuple(range(0, 16)),
+        sourceName='Test',
+        universe=1,
+        dmxData=tuple(range(0, 16)),
+        priority=99
+    )
+    socket.call_on_data(bytes(packet2.getBytes()), 1)
+    # second packet does not override the previous one
+    assert listener.on_dmx_data_change_packet.__dict__ == packet1.__dict__
+
+
+def test_invalid_sequence():
+    # send a lower sequence on a second packet
+    def case_goes_through(sequence_a: int, sequence_b: int):
+        _, listener, socket = get_handler()
+        assert listener.on_dmx_data_change_packet is None
+        packet1 = DataPacket(
+            cid=tuple(range(0, 16)),
+            sourceName='Test',
+            universe=1,
+            dmxData=tuple(range(0, 16)),
+            sequence=sequence_a
+        )
+        socket.call_on_data(bytes(packet1.getBytes()), 0)
+        assert listener.on_dmx_data_change_packet.__dict__ == packet1.__dict__
+        packet2 = DataPacket(
+            cid=tuple(range(0, 16)),
+            sourceName='Test',
+            universe=1,
+            # change DMX data to simulate data from another source
+            dmxData=tuple(range(1, 17)),
+            sequence=sequence_b
+        )
+        socket.call_on_data(bytes(packet2.getBytes()), 1)
+        assert listener.on_dmx_data_change_packet.__dict__ == packet2.__dict__
+
+    case_goes_through(100, 80)
+    case_goes_through(101, 102)
+    case_goes_through(255, 0)
+    case_goes_through(0, 236)
+    with pytest.raises(AssertionError):
+        case_goes_through(100, 81)
+    with pytest.raises(AssertionError):
+        case_goes_through(100, 99)
+    # Note: this should probably also fail, but the algorithm from the E1.31 spec does not
+    # work with wrap around 255...
+    case_goes_through(0, 255)
+
+
+def test_possible_universes():
+    handler, _, socket = get_handler()
+    assert handler.get_possible_universes() == []
+    packet = DataPacket(
+        cid=tuple(range(0, 16)),
+        sourceName='Test',
+        universe=1,
+        dmxData=tuple(range(0, 16))
+    )
+    # add universe 1
+    socket.call_on_data(bytes(packet.getBytes()), 0)
+    assert handler.get_possible_universes() == [1]
+    # add universe 2
+    packet.universe = 2
+    socket.call_on_data(bytes(packet.getBytes()), 0)
+    assert handler.get_possible_universes() == [1, 2]
+    # remove universe 2
+    packet.option_StreamTerminated = True
+    socket.call_on_data(bytes(packet.getBytes()), 0)
+    assert handler.get_possible_universes() == [1]
+
+
+def test_universe_timeout():
+    _, listener, socket = get_handler()
+    assert listener.on_availability_change_changed is None
+    assert listener.on_availability_change_universe is None
+    packet = DataPacket(
+        cid=tuple(range(0, 16)),
+        sourceName='Test',
+        universe=1,
+        dmxData=tuple(range(0, 16))
+    )
+    socket.call_on_data(bytes(packet.getBytes()), 0)
+    socket.call_on_periodic_callback(0)
+    assert listener.on_availability_change_changed == 'available'
+    assert listener.on_availability_change_universe == 1
+    # wait the specified amount of time and check, that a timeout was triggered
+    # add 10ms of grace time
+    socket.call_on_periodic_callback((E131_NETWORK_DATA_LOSS_TIMEOUT_ms / 1000) + 0.01)
+    assert listener.on_availability_change_changed == 'timeout'
+    assert listener.on_availability_change_universe == 1
+
+
+def test_universe_stream_terminated():
+    _, listener, socket = get_handler()
+    assert listener.on_availability_change_changed is None
+    assert listener.on_availability_change_universe is None
+    packet = DataPacket(
+        cid=tuple(range(0, 16)),
+        sourceName='Test',
+        universe=1,
+        dmxData=tuple(range(0, 16))
+    )
+    socket.call_on_data(bytes(packet.getBytes()), 0)
+    assert listener.on_availability_change_changed == 'available'
+    assert listener.on_availability_change_universe == 1
+    packet.sequence_increase()
+    packet.option_StreamTerminated = True
+    socket.call_on_data(bytes(packet.getBytes()), 0)
+    assert listener.on_availability_change_changed == 'timeout'
+    assert listener.on_availability_change_universe == 1
+
+
+def test_abstract_receiver_handler_listener():
+    listener = ReceiverHandlerListener()
+    with pytest.raises(NotImplementedError):
+        listener.on_availability_change(1, 'test')
+    with pytest.raises(NotImplementedError):
+        listener.on_dmx_data_change(DataPacket(
+            cid=tuple(range(0, 16)),
+            sourceName='Test',
+            universe=1
+        ))
diff --git a/sacn/receiving/receiver_socket_base_test.py b/sacn/receiving/receiver_socket_base_test.py
new file mode 100644
index 00000000..1104ecfb
--- /dev/null
+++ b/sacn/receiving/receiver_socket_base_test.py
@@ -0,0 +1,24 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import pytest
+from dmx.sacn.receiving.receiver_socket_base import ReceiverSocketBase, ReceiverSocketListener
+
+
+def test_abstract_receiver_socket_listener():
+    listener = ReceiverSocketListener()
+    with pytest.raises(NotImplementedError):
+        listener.on_data([], 0)
+    with pytest.raises(NotImplementedError):
+        listener.on_periodic_callback(0)
+
+
+def test_abstract_receiver_socket_base():
+    socket = ReceiverSocketBase(None)
+    with pytest.raises(NotImplementedError):
+        socket.start()
+    with pytest.raises(NotImplementedError):
+        socket.stop()
+    with pytest.raises(NotImplementedError):
+        socket.join_multicast('test')
+    with pytest.raises(NotImplementedError):
+        socket.leave_multicast('test')
diff --git a/sacn/receiving/receiver_socket_test.py b/sacn/receiving/receiver_socket_test.py
new file mode 100644
index 00000000..dc9dd5e9
--- /dev/null
+++ b/sacn/receiving/receiver_socket_test.py
@@ -0,0 +1,31 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+
+from dmx.sacn.receiving.receiver_socket_base import ReceiverSocketBase
+
+
+class ReceiverSocketTest(ReceiverSocketBase):
+    def __init__(self, listener=None):
+        super().__init__(listener)
+        self.start_called: bool = False
+        self.stop_called: bool = False
+        self.join_multicast_called: str = None
+        self.leave_multicast_called: str = None
+
+    def start(self) -> None:
+        self.start_called = True
+
+    def stop(self) -> None:
+        self.stop_called = True
+
+    def join_multicast(self, multicast_addr: str) -> None:
+        self.join_multicast_called = multicast_addr
+
+    def leave_multicast(self, multicast_addr: str) -> None:
+        self.leave_multicast_called = multicast_addr
+
+    def call_on_data(self, data: bytes, current_time: float) -> None:
+        self._listener.on_data(data, current_time)
+
+    def call_on_periodic_callback(self, current_time: float) -> None:
+        self._listener.on_periodic_callback(current_time)
diff --git a/sacn/sender_test.py b/sacn/sender_test.py
new file mode 100644
index 00000000..0a89bd59
--- /dev/null
+++ b/sacn/sender_test.py
@@ -0,0 +1,293 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import pytest
+import sacn
+from dmx.sacn.sender import check_universe
+from dmx.sacn.messages.data_packet import DataPacket
+from dmx.sacn.sending.sender_socket_test import SenderSocketTest
+
+
+def test_constructor():
+    cid = tuple(range(0, 16))
+    source_name = 'test'
+    socket = SenderSocketTest()
+
+    # test default values for constructor
+    sender = sacn.sACNsender(socket=socket)
+    assert sender._sender_handler._source_name == 'default source name'
+    assert len(sender._sender_handler._CID) == 16
+    assert sender.universeDiscovery is True
+    assert sender._sync_universe == 63999
+    assert sender.manual_flush is False
+
+    # test explicit values for constructor
+    bind_address = 'test_address'
+    bind_port = 1234
+    fps = 40
+    universe_discovery = False
+    sync_universe = 4567
+    sender = sacn.sACNsender(bind_address, bind_port, source_name, cid, fps, universe_discovery, sync_universe, socket)
+    assert sender._sender_handler._source_name == source_name
+    assert sender._sender_handler._CID == cid
+    assert sender.universeDiscovery == universe_discovery
+    assert sender._sync_universe == sync_universe
+    assert sender.manual_flush is False
+
+
+def test_universe_discovery_setting():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+    assert sender.universeDiscovery is True
+    assert sender._sender_handler.universe_discovery is True
+    sender.universeDiscovery = False
+    assert sender.universeDiscovery is False
+    assert sender._sender_handler.universe_discovery is False
+
+
+def test_manual_flush_setting():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+    assert sender.manual_flush is False
+    assert sender._sender_handler.manual_flush is False
+    sender.manual_flush = True
+    assert sender.manual_flush is True
+    assert sender._sender_handler.manual_flush is True
+
+
+def test_flush():
+    socket = SenderSocketTest()
+    sync_universe = 1234
+    sender = sacn.sACNsender(sync_universe=sync_universe, socket=socket)
+
+    assert socket.send_unicast_called is None
+    # test that non-active universes throw exception
+    with pytest.raises(ValueError):
+        sender.flush([1, 2])
+
+    assert socket.send_unicast_called is None
+    # test that no active universes triggers nothing
+    sender.flush()
+    assert socket.send_unicast_called is None
+
+    # activate universe 1
+    sender.activate_output(1)
+    assert socket.send_unicast_called is None
+    # test that no parameters triggers flushing of all universes
+    sender.flush()
+    assert socket.send_unicast_called[0].__dict__ == DataPacket(
+        sender._sender_handler._CID, sender._sender_handler._source_name, 1, sync_universe=sync_universe).__dict__
+
+    # activate universe 2
+    sender.activate_output(2)
+    # test that a list with only universe 1 triggers flushing of only this universe
+    sender.flush([1])
+    assert socket.send_unicast_called[0].__dict__ == DataPacket(
+        sender._sender_handler._CID, sender._sender_handler._source_name, 1, sequence=1, sync_universe=sync_universe).__dict__
+
+
+def test_activate_output():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+
+    # start with no universes active
+    assert list(sender._outputs.keys()) == []
+
+    # activate one universe
+    sender.activate_output(1)
+    assert list(sender._outputs.keys()) == [1]
+
+    # activate another universe
+    sender.activate_output(63999)
+    assert list(sender._outputs.keys()) == [1, 63999]
+
+    # check that a universe can not be enabled twice
+    sender.activate_output(1)
+    assert list(sender._outputs.keys()) == [1, 63999]
+
+
+def test_deactivate_output():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+
+    # check that three packets with stream-termination bit set are send out on deactivation
+    sender.activate_output(100)
+    assert socket.send_unicast_called is None
+    sender.deactivate_output(100)
+    assert socket.send_unicast_called[0].__dict__ == DataPacket(
+        sender._sender_handler._CID, sender._sender_handler._source_name, 100, sequence=2, streamTerminated=True).__dict__
+
+    # start with no universes active
+    assert list(sender._outputs.keys()) == []
+    sender.deactivate_output(1)
+    assert list(sender._outputs.keys()) == []
+
+    # one universe active
+    sender.activate_output(1)
+    assert list(sender._outputs.keys()) == [1]
+    sender.deactivate_output(1)
+    assert list(sender._outputs.keys()) == []
+
+    # two universes active
+    sender.activate_output(10)
+    sender.activate_output(11)
+    assert list(sender._outputs.keys()) == [10, 11]
+    sender.deactivate_output(10)
+    assert list(sender._outputs.keys()) == [11]
+
+    # deactivate no active universe
+    assert list(sender._outputs.keys()) == [11]
+    sender.deactivate_output(99)
+    assert list(sender._outputs.keys()) == [11]
+
+
+def test_get_active_outputs():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+
+    # none active
+    assert sender.get_active_outputs() == tuple([])
+
+    # one active
+    sender.activate_output(1)
+    assert sender.get_active_outputs() == tuple([1])
+
+    # two active
+    sender.activate_output(2)
+    assert sender.get_active_outputs() == tuple([1, 2])
+
+
+def test_move_universe():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+
+    sender.activate_output(1)
+    output = sender._outputs[1]
+    assert list(sender._outputs.keys()) == [1]
+    assert sender._outputs[1] == output
+    sender.move_universe(1, 2)
+    assert list(sender._outputs.keys()) == [2]
+    assert sender._outputs[2] == output
+
+
+def test_getitem():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+
+    assert sender[1] is None
+    sender.activate_output(1)
+    assert sender[1] == sender._outputs[1]
+
+
+def test_start():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+
+    assert socket.start_called is False
+    sender.start()
+    assert socket.start_called is True
+
+    # a second time is not allowed to throw an exception
+    assert socket.start_called is True
+    sender.start()
+    assert socket.start_called is True
+
+
+def test_stop():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+
+    assert socket.stop_called is False
+    sender.stop()
+    assert socket.stop_called is True
+
+    # a second time is not allowed to throw an exception
+    assert socket.stop_called is True
+    sender.stop()
+    assert socket.stop_called is True
+
+
+def test_output_destination():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+    sender.activate_output(1)
+
+    # test default
+    assert sender[1].destination == '127.0.0.1'
+    # test setting and retriving the value
+    test = 'test'
+    sender[1].destination = test
+    assert sender[1].destination == test
+
+
+def test_output_multicast():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+    sender.activate_output(1)
+
+    # test default
+    assert sender[1].multicast is False
+    # test setting and retriving the value
+    test = True
+    sender[1].multicast = test
+    assert sender[1].multicast == test
+
+
+def test_output_ttl():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+    sender.activate_output(1)
+
+    # test default
+    assert sender[1].ttl == 8
+    # test setting and retriving the value
+    test = 16
+    sender[1].ttl = test
+    assert sender[1].ttl == test
+
+
+def test_output_priority():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+    sender.activate_output(1)
+
+    # test default
+    assert sender[1].priority == 100
+    # test setting and retriving the value
+    test = 200
+    sender[1].priority = test
+    assert sender[1].priority == test
+
+
+def test_output_preview_data():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+    sender.activate_output(1)
+
+    # test default
+    assert sender[1].preview_data is False
+    # test setting and retriving the value
+    test = True
+    sender[1].preview_data = test
+    assert sender[1].preview_data == test
+
+
+def test_output_dmx_data():
+    socket = SenderSocketTest()
+    sender = sacn.sACNsender(socket=socket)
+    sender.activate_output(1)
+
+    # test default
+    assert sender[1].dmx_data == tuple([0]*512)
+    # test setting and retriving the value
+    test = tuple([x % 256 for x in range(0, 512)])
+    sender[1].dmx_data = test
+    assert sender[1].dmx_data == test
+
+
+def test_check_universe():
+    with pytest.raises(ValueError):
+        check_universe(0)
+    with pytest.raises(ValueError):
+        check_universe(64000)
+    check_universe(1)
+    check_universe(63999)
diff --git a/sacn/sending/sender_handler_test.py b/sacn/sending/sender_handler_test.py
new file mode 100644
index 00000000..04e959b3
--- /dev/null
+++ b/sacn/sending/sender_handler_test.py
@@ -0,0 +1,187 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+from typing import Dict
+from dmx.sacn.messages.data_packet import DataPacket, calculate_multicast_addr
+from dmx.sacn.messages.sync_packet import SyncPacket
+from dmx.sacn.messages.universe_discovery import UniverseDiscoveryPacket
+from dmx.sacn.sending.output import Output
+from dmx.sacn.sending.sender_handler import SenderHandler
+from dmx.sacn.sending.sender_socket_test import SenderSocketTest
+
+
+def get_handler():
+    cid = tuple(range(0, 16))
+    source_name = 'test'
+    outputs: Dict[int, Output] = {
+        1: Output(
+            packet=DataPacket(
+                cid=cid,
+                sourceName=source_name,
+                universe=1,
+            )
+        )
+    }
+    socket = SenderSocketTest()
+    handler = SenderHandler(
+        cid=cid,
+        source_name=source_name,
+        outputs=outputs,
+        bind_address='0.0.0.0',
+        bind_port=5568,
+        fps=30,
+        socket=socket,
+    )
+    handler.manual_flush = True
+    # wire up listener for tests
+    socket._listener = handler
+    return handler, socket, cid, source_name, outputs
+
+
+def test_universe_discovery_packets():
+    handler, socket, cid, source_name, outputs = get_handler()
+    handler.manual_flush = True
+    current_time = 100.0
+
+    assert handler.universe_discovery is True
+    assert socket.send_broadcast_called is None
+
+    # test that the universe discovery can be disabled
+    handler.universe_discovery = False
+    socket.call_on_periodic_callback(current_time)
+    assert socket.send_broadcast_called is None
+    handler.universe_discovery = True
+
+    # if no outputs are specified, there is an empty universe packet send
+    socket.call_on_periodic_callback(current_time)
+    assert socket.send_broadcast_called == UniverseDiscoveryPacket(cid, source_name, (1,))
+
+
+def test_send_out_interval():
+    handler, socket, cid, source_name, outputs = get_handler()
+    handler.manual_flush = False
+    current_time = 100.0
+
+    assert handler.manual_flush is False
+    assert socket.send_unicast_called is None
+
+    # first send packet due to interval
+    socket.call_on_periodic_callback(current_time)
+    assert socket.send_unicast_called[0].__dict__ == DataPacket(cid, source_name, 1, sequence=0).__dict__
+    assert socket.send_unicast_called[1] == '127.0.0.1'
+
+    # interval must be 1 seconds
+    socket.call_on_periodic_callback(current_time+0.99)
+    assert socket.send_unicast_called[0].__dict__ == DataPacket(cid, source_name, 1, sequence=0).__dict__
+    socket.call_on_periodic_callback(current_time+1.01)
+    assert socket.send_unicast_called[0].__dict__ == DataPacket(cid, source_name, 1, sequence=1).__dict__
+
+
+def test_multicast():
+    handler, socket, cid, source_name, outputs = get_handler()
+    handler.manual_flush = False
+    current_time = 100.0
+    outputs[1].multicast = True
+    outputs[1].ttl = 123
+
+    assert handler.manual_flush is False
+    assert socket.send_multicast_called is None
+    assert outputs[1].multicast is True
+    assert outputs[1].ttl == 123
+
+    # first send packet due to interval
+    socket.call_on_periodic_callback(current_time)
+    assert socket.send_multicast_called[0].__dict__ == DataPacket(cid, source_name, 1, sequence=0).__dict__
+    assert socket.send_multicast_called[1] == calculate_multicast_addr(1)
+
+    # only send out on dmx change
+    # test same data as before
+    # TODO: currently there is no "are the values different" check.
+    # If it is implemented, enable the following line:
+    # outputs[1].dmx_data = (0, 0)
+    socket.call_on_periodic_callback(current_time)
+    assert socket.send_multicast_called[0].__dict__ == DataPacket(cid, source_name, 1, sequence=0).__dict__
+    assert socket.send_multicast_called[1] == calculate_multicast_addr(1)
+
+    # test change in data as before
+    outputs[1].dmx_data = (1, 2)
+    socket.call_on_periodic_callback(current_time)
+    assert socket.send_multicast_called[0].__dict__ == DataPacket(cid, source_name, 1, sequence=1, dmxData=(1, 2)).__dict__
+    assert socket.send_multicast_called[1] == calculate_multicast_addr(1)
+
+    # assert that no unicast was send
+    assert socket.send_unicast_called is None
+
+
+def test_unicast():
+    handler, socket, cid, source_name, outputs = get_handler()
+    handler.manual_flush = False
+    current_time = 100.0
+    outputs[1].multicast = False
+    destination = "1.2.3.4"
+    outputs[1].destination = destination
+
+    assert handler.manual_flush is False
+    assert socket.send_unicast_called is None
+    assert outputs[1].multicast is False
+
+    # first send packet due to interval
+    socket.call_on_periodic_callback(current_time)
+    assert socket.send_unicast_called[0].__dict__ == DataPacket(cid, source_name, 1, sequence=0).__dict__
+    assert socket.send_unicast_called[1] == destination
+
+    # only send out on dmx change
+    # test same data as before
+    # TODO: currently there is no "are the values different" check.
+    # If it is implemented, enable the following line:
+    # outputs[1].dmx_data = (0, 0)
+    socket.call_on_periodic_callback(current_time)
+    assert socket.send_unicast_called[0].__dict__ == DataPacket(cid, source_name, 1, sequence=0).__dict__
+    assert socket.send_unicast_called[1] == destination
+
+    # test change in data as before
+    outputs[1].dmx_data = (1, 2)
+    socket.call_on_periodic_callback(current_time)
+    assert socket.send_unicast_called[0].__dict__ == DataPacket(cid, source_name, 1, sequence=1, dmxData=(1, 2)).__dict__
+    assert socket.send_unicast_called[1] == destination
+
+    # assert that no multicast was send
+    assert socket.send_multicast_called is None
+
+
+def test_send_out_all_universes():
+    handler, socket, cid, source_name, outputs = get_handler()
+    handler.manual_flush = True
+    current_time = 100.0
+    outputs[1].multicast = False
+    destination = "1.2.3.4"
+    outputs[1].destination = destination
+
+    assert handler.manual_flush is True
+    assert socket.send_unicast_called is None
+    assert socket.send_multicast_called is None
+    assert outputs[1].multicast is False
+
+    # check that send packets due to interval are suppressed
+    socket.call_on_periodic_callback(current_time)
+    assert socket.send_unicast_called is None
+    assert socket.send_multicast_called is None
+
+    # after calling send_out_all_universes, the DataPackets need to send, as well as one SyncPacket
+    sync_universe = 63999
+    handler.send_out_all_universes(sync_universe, outputs, current_time)
+    assert socket.send_unicast_called[0].__dict__ == DataPacket(cid, source_name, 1, sequence=0, sync_universe=sync_universe).__dict__
+    assert socket.send_unicast_called[1] == destination
+    assert socket.send_multicast_called[0].__dict__ == SyncPacket(cid, sync_universe, 0).__dict__
+    assert socket.send_multicast_called[1] == calculate_multicast_addr(sync_universe)
+
+
+def test_send_out_all_universes_sequence_increment():
+    handler, socket, cid, source_name, outputs = get_handler()
+    handler.manual_flush = True
+    current_time = 100.0
+    sync_universe = 63999
+
+    # check that the sequence number never exceeds the range [0-255]
+    for i in range(0, 300):
+        handler.send_out_all_universes(sync_universe, outputs, current_time)
+        assert socket.send_multicast_called[0].__dict__ == SyncPacket(cid, sync_universe, (i % 256)).__dict__
diff --git a/sacn/sending/sender_socket_base_test.py b/sacn/sending/sender_socket_base_test.py
new file mode 100644
index 00000000..d3f257a9
--- /dev/null
+++ b/sacn/sending/sender_socket_base_test.py
@@ -0,0 +1,25 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import pytest
+from dmx.sacn.messages.root_layer import RootLayer
+from dmx.sacn.sending.sender_socket_base import SenderSocketBase, SenderSocketListener
+
+
+def test_abstract_sender_socket_listener():
+    listener = SenderSocketListener()
+    with pytest.raises(NotImplementedError):
+        listener.on_periodic_callback(1.0)
+
+
+def test_abstract_sender_socket_base():
+    socket = SenderSocketBase(None)
+    with pytest.raises(NotImplementedError):
+        socket.start()
+    with pytest.raises(NotImplementedError):
+        socket.stop()
+    with pytest.raises(NotImplementedError):
+        socket.send_unicast(RootLayer(1, tuple(range(0, 16)), (0, 0, 0, 0)), 'test')
+    with pytest.raises(NotImplementedError):
+        socket.send_multicast(RootLayer(1, tuple(range(0, 16)), (0, 0, 0, 0)), 'test', 12)
+    with pytest.raises(NotImplementedError):
+        socket.send_broadcast(RootLayer(1, tuple(range(0, 16)), (0, 0, 0, 0)))
diff --git a/sacn/sending/sender_socket_test.py b/sacn/sending/sender_socket_test.py
new file mode 100644
index 00000000..112427ea
--- /dev/null
+++ b/sacn/sending/sender_socket_test.py
@@ -0,0 +1,33 @@
+# This file is under MIT license. The license file can be obtained in the root directory of this module.
+
+import copy
+from dmx.sacn.messages.root_layer import RootLayer
+from dmx.sacn.sending.sender_socket_base import SenderSocketBase
+
+
+class SenderSocketTest(SenderSocketBase):
+    def __init__(self, listener=None):
+        super().__init__(listener)
+        self.start_called: bool = False
+        self.stop_called: bool = False
+        self.send_unicast_called: (RootLayer, str) = None
+        self.send_multicast_called: (RootLayer, str, int) = None
+        self.send_broadcast_called: RootLayer = None
+
+    def start(self) -> None:
+        self.start_called = True
+
+    def stop(self) -> None:
+        self.stop_called = True
+
+    def send_unicast(self, data: RootLayer, destination: str) -> None:
+        self.send_unicast_called = (copy.deepcopy(data), copy.deepcopy(destination))
+
+    def send_multicast(self, data: RootLayer, destination: str, ttl: int) -> None:
+        self.send_multicast_called = (copy.deepcopy(data), copy.deepcopy(destination), ttl)
+
+    def send_broadcast(self, data: RootLayer) -> None:
+        self.send_broadcast_called = copy.deepcopy(data)
+
+    def call_on_periodic_callback(self, time: float) -> None:
+        self._listener.on_periodic_callback(time)