├── .coveragerc ├── .flake8 ├── .github └── workflows │ ├── publish.yml │ └── pythonpackage.yml ├── .gitignore ├── LICENSE ├── README.md ├── conftest.py ├── integration_test.py ├── sacn ├── __init__.py ├── messages │ ├── __init__.py │ ├── data_packet.py │ ├── data_packet_test.py │ ├── data_types.py │ ├── data_types_test.py │ ├── general_test.py │ ├── root_layer.py │ ├── root_layer_test.py │ ├── sync_packet.py │ ├── sync_packet_test.py │ ├── universe_discovery.py │ └── universe_discovery_test.py ├── receiver.py ├── receiver_test.py ├── receiving │ ├── __init__.py │ ├── receiver_handler.py │ ├── receiver_handler_test.py │ ├── receiver_socket_base.py │ ├── receiver_socket_base_test.py │ ├── receiver_socket_test.py │ └── receiver_socket_udp.py ├── sender.py ├── sender_test.py └── sending │ ├── __init__.py │ ├── output.py │ ├── sender_handler.py │ ├── sender_handler_test.py │ ├── sender_socket_base.py │ ├── sender_socket_base_test.py │ ├── sender_socket_test.py │ └── sender_socket_udp.py └── setup.py /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | omit = 4 | # omit any test files 5 | *_test.py 6 | test_*.py 7 | 8 | include = 9 | # only include source files from this project 10 | sacn/* 11 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 160 3 | exclude = build/* 4 | max-complexity = 10 5 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created, edited] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Set up Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: '3.12' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install setuptools wheel twine 20 | - name: Build and publish 21 | env: 22 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 23 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 24 | run: | 25 | python setup.py sdist bdist_wheel 26 | twine upload dist/* 27 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | strategy: 9 | matrix: 10 | os: ["ubuntu-latest", "windows-latest"] 11 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 12 | 13 | 14 | runs-on: ${{ matrix.os }} 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | # there are currently no dependencies 26 | # pip install -r requirements.txt 27 | - name: Install flake8 28 | run: pip install flake8 29 | - name: Lint with flake8 30 | run: | 31 | # stop the build if flake reports any issues 32 | flake8 . --statistics --show-source 33 | - name: Install pytest 34 | run: pip install pytest pytest-timeout 35 | - name: Test with pytest 36 | run: pytest --run-integration-tests 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | sacn.egg-info/ 2 | .* 3 | !.flake8 4 | !.coveragerc 5 | !.github/ 6 | !/.gitignore 7 | __pycache__/ 8 | testing.py 9 | testing2.py 10 | setup.cfg 11 | dist/ 12 | build/ 13 | htmlcov/ 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2021 Hundemeier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Project Status: Inactive – The project has reached a stable, usable state but is no longer being actively developed; support/maintenance will be provided as time allows.](https://www.repostatus.org/badges/latest/inactive.svg)](https://www.repostatus.org/#inactive) 2 | 3 | # sACN / E1.31 module 4 | 5 | This module is a simple sACN library that support the standard DMX message of the protocol. 6 | It is based on the [2016][e1.31] version of the official ANSI E1.31 standard. 7 | It has support for sending out DMX data and receiving it. Multiple and multicast universes are supported. 8 | For full blown DMX support use [OLA](https://www.openlighting.org/ola/). 9 | 10 | Currently missing features: 11 | * discovery messages (receiving) 12 | * E1.31 sync feature (on the receiver side) 13 | * custom ports (because this is not recommended) 14 | 15 | Features: 16 | * out-of-order packet detection like the [E1.31][e1.31] 6.7.2 17 | * multicast 18 | * auto flow control (see [The Internals/Sending](#sending)) 19 | * E1.31 sync feature (see manual_flush) 20 | 21 | ## Setup 22 | This Package is in the [pypi](https://pypi.org/project/sacn/). To install the package use `pip install sacn`. Python 3.6 or newer required! 23 | To use the library `import sacn`. 24 | If you want to install directly from source, download or clone the repository and execute `pip install .` where the setup.py is located. 25 | For more information on pip installation see: https://packaging.python.org/tutorials/installing-packages/#installing-from-a-local-src-tree 26 | 27 | ## The Internals 28 | ### Sending 29 | You can create a new `sACNsender` object and provide the necessary information. Then you have to use `start()`. 30 | This creates a new thread that is responsible for sending out the data. Do not forget to `stop()` the thread when 31 | finished! If the data is not changed, the same DMX data is sent out every second. 32 | 33 | The thread sends out the data every *1/fps* seconds. This reduces network traffic even if you give the sender new data 34 | more often than the *fps*. 35 | A simple description would be to say that the data that you give the sACNsender is subsampled by the *fps*. 36 | You can tweak this *fps* by simply change it when creating the `sACNsender` object. 37 | 38 | This function works according to the [E1.31][e1.31]. See 6.6.1 for more information. 39 | 40 | Note: Since Version 1.4 there is a manual flush feature available. See Usage/Sending for more info. 41 | This feature also uses the sync feature of the sACN protocol (see page 36 on [E1.31][e1.31]). 42 | Currently this is not implemented like the recommended way (this does not wait before sending out the sync packet), but 43 | it should work on a normal local network without too many latency differences. 44 | When the `flush()` function is called, all data is send out at the same time and immediately a sync packet is send out. 45 | 46 | ### Receiving 47 | A very simple solution, as you just create a `sACNreceiver` object and use `start()` a new thread that is running in 48 | the background and calls the callbacks when new sACN data arrives. 49 | 50 | --- 51 | ## Usage 52 | ### Sending 53 | To use the sending functionality you have to use the `sACNsender`. 54 | 55 | ```python 56 | import sacn 57 | import time 58 | 59 | sender = sacn.sACNsender() # provide an IP-Address to bind to if you want to send multicast packets from a specific interface 60 | sender.start() # start the sending thread 61 | sender.activate_output(1) # start sending out data in the 1st universe 62 | sender[1].multicast = True # set multicast to True 63 | # sender[1].destination = "192.168.1.20" # or provide unicast information. 64 | # Keep in mind that if multicast is on, unicast is not used 65 | sender[1].dmx_data = (1, 2, 3, 4) # some test DMX data 66 | # sender[1].per_address_priority = tuple([123]*512) # sets the priority per address to 123 but you could set it different for each channel 67 | 68 | time.sleep(10) # send the data for 10 seconds 69 | sender.stop() # do not forget to stop the sender 70 | ``` 71 | 72 | You can activate an output universe via `activate_output()` and then change the attributes of this universe 73 | via `sender[].`. To deactivate an output use `deactivate_output()`. The output is 74 | terminated like the [E1.31][e1.31] describes it on page 14. 75 | 76 | If you want to flush manually and the sender thread should not send out automatic, use the 77 | `sACNsender.manual_flush` option. This is useful when you want to use a fixture that is using more than one universe 78 | and all the data on multiple universes should send out at the same time. 79 | 80 | Tip: you can get the activated outputs with `get_active_outputs()` and you can move an output with all its settings 81 | from one universe to another with `move_universe(, )`. 82 | 83 | Available Attributes for `sender[].` are: 84 | * `destination: str`: the unicast destination as string. (eg "192.168.1.150") Default: "127.0.0.1" 85 | * `multicast: bool`: set whether to send out via multicast or not. Default: False 86 | If True the data is send out via multicast and not unicast. 87 | * `ttl: int`: the time-to-live for the packets that are send out via multicast on this universe. Default: 8 88 | * `priority: int`: (must be between 0-200) the priority for this universe that is send out. If multiple sources in a 89 | network are sending to the same receiver the data with the highest priority wins. Default: 100 90 | * `per_address_priority: tuple`: ([proprietary extension of sACN by ETC](https://support.etcconnect.com/ETC/Networking/General/Difference_between_sACN_per-address_and_per-port_priority#Per-Address_Priority)) optional; set a tuple with a length of 0 or 512 that contains values between 0 and 255 but that are interpreted as priority values by some sACN implementations/devices. If not set, no per-address-priority is send out. Default: empty tuple with length 0 91 | * `preview_data: bool`: Flag to mark the data as preview data for visualization purposes. Default: False 92 | * `dmx_data: tuple`: the DMX data as a tuple. Max length is 512 and for legacy devices all data that is smaller than 93 | 512 is merged to a 512 length tuple with 0 as filler value. The values in the tuple have to be [0-255]! 94 | 95 | `sACNsender` Creates a sender object. A sender is used to manage multiple sACN universes and handles their output. 96 | DMX data is send out every second, when no data changes. Some changes may be not send out, because the fps 97 | setting defines how often packets are send out to prevent network overuse. So if you change the DMX values too 98 | often in a second they may not all been send. Vary the fps parameter to your needs (Default=30). 99 | * `bind_address: str`: the IP-Address to bind to. 100 | Provide an IP-Address to bind to if you want to send multicast packets from a specific interface. 101 | * `bind_port: int`: optionally bind to a specific port. Default=5568. It is not recommended to change the port. 102 | Change the port number if you have trouble with another program or the sACNreceiver blocking the port 103 | * `source_name: str`: the source name used in the sACN packets. See the [standard][e1.31] for more information. 104 | * `cid: tuple`: the cid. If not given, a random CID will be generated. See the [standard][e1.31] for more information. 105 | * `fps: int` the frames per second. See explanation above. Has to be >0. Default: 30 106 | * `universeDiscovery: bool` if true, universe discovery messages are send out via broadcast every 10s. Default: True 107 | * `sync_universe: int` set a specific universe used in the sync-packets. Default: 63999 108 | 109 | When manually flushed, the E1.31 sync feature is used. So all universe data is send out, and after all data was send out 110 | a sync packet is send to all receivers and then they are allowed to display the received data. Note that not all 111 | receiver implemented this feature of the sACN protocol. 112 | 113 | Example for the usage of the manual_flush: 114 | ```python 115 | import sacn 116 | import time 117 | 118 | sender = sacn.sACNsender() 119 | sender.start() 120 | sender.activate_output(1) 121 | sender.activate_output(2) 122 | sender[1].multicast = True 123 | sender[2].multicast = True 124 | 125 | sender.manual_flush = True # turning off the automatic sending of packets 126 | sender[1].dmx_data = (1, 2, 3, 4) # some test DMX data 127 | sender[2].dmx_data = (5, 6, 7, 8) # by the time we are here, the above data would be already send out, 128 | # if manual_flush would be False. This could cause some jitter 129 | # so instead we are flushing manual 130 | time.sleep(1) # let the sender initialize itself 131 | sender.flush() 132 | sender.manual_flush = False # keep manual flush off as long as possible, because if it is on, the automatic 133 | # sending of packets is turned off and that is not recommended 134 | sender.stop() # stop sending out 135 | ``` 136 | 137 | ### Receiving 138 | To use the receiving functionality you have to use the `sACNreceiver`. 139 | 140 | ```python 141 | import sacn 142 | import time 143 | 144 | # provide an IP-Address to bind to if you want to receive multicast packets from a specific interface 145 | receiver = sacn.sACNreceiver() 146 | receiver.start() # start the receiving thread 147 | 148 | # define a callback function 149 | @receiver.listen_on('universe', universe=1) # listens on universe 1 150 | def callback(packet): # packet type: sacn.DataPacket 151 | if packet.dmxStartCode == 0x00: # ignore non-DMX-data packets 152 | print(packet.dmxData) # print the received DMX data 153 | 154 | # optional: if multicast is desired, join with the universe number as parameter 155 | receiver.join_multicast(1) 156 | 157 | time.sleep(10) # receive for 10 seconds 158 | 159 | # optional: if multicast was previously joined 160 | receiver.leave_multicast(1) 161 | 162 | receiver.stop() 163 | ``` 164 | 165 | The usage of the receiver is simpler than the sender. 166 | The `sACNreceiver` can be initialized with the following parameters: 167 | * `bind_address: str`: Provide an IP-Address to bind to if you want to receive multicast packets from a specific interface. 168 | _Note:_ This parameter is ignored on Linux when binding the socket to an address, but is used in subscribing the 169 | multicast group to a correct interface. If you have multiple interfaces within your system you will need to 170 | specify this parameter to ensure the multicast group join is completed on the correct interface and you receive 171 | sACN traffic. 172 | * `bind_port: int`: Default: 5568. It is not recommended to change this value! 173 | Only use when you are know what you are doing! 174 | 175 | Please keep in mind to not use the callbacks for time consuming tasks! 176 | If you do this, then the receiver can not react fast enough on incoming messages! 177 | 178 | Functions: 179 | * `join_multicast()`: joins the multicast group for the specific universe. 180 | * `leave_multicast()`: leave the multicast group specified by the universe. 181 | * `get_possible_universes()`: Returns a tuple with all universes that have sources that are sending out data and this 182 | data is received by this machine 183 | * `register_listener(, , **kwargs)`: register a listener for the given trigger. 184 | You can also use the decorator `listen_on(, **kwargs)`. Possible trigger so far: 185 | * `availability`: gets called when there is no data for a universe anymore or there is now data 186 | available. This gets also fired if a source terminates a stream via the stream_termination bit. 187 | The callback should get two arguments: `callback(universe, changed)` 188 | * `universe: int`: is the universe where the action happened 189 | * `changed: str`: can be 'timeout' or 'available' 190 | * `universe`: registers a listener for the given universe. The callback gets only one parameter, the DataPacket. 191 | You can also use the decorator `@listen_on('universe', universe=)`. 192 | The callback should have one argument: `callback(packet)` 193 | * `packet: DataPacket`: the received DataPacket with all information 194 | * `remove_listener()`: removes a previously registered listener regardless of the trigger. 195 | This means a listener can only be removed completely, even if it was listening to multiple universes. 196 | If the function never was registered, nothing happens. 197 | Note: if a function was registered multiple times, this remove function needs to be called only once. 198 | * `remove_listener_from_universe()`: removes all listeners from the given universe. 199 | This does only have effect on the 'universe' listening trigger. 200 | If no function was registered for this universe, nothing happens. 201 | 202 | ### DataPacket 203 | This is an abstract representation of an sACN Data packet that carries the DMX data. This class is used internally by 204 | the module and is used in the callbacks of the receiver. 205 | 206 | The DataPacket provides following attributes: 207 | * `sourceName: str`: a string that is used to identify the source. Only the first 64 bytes are used. 208 | * `priority: int`: the priority used to manage multiple DMX data on one receiver. [1-200] Default: 100 209 | * `universe: int`: the universe for the whole message and its DMX data. [1-63999] 210 | * `sequence: int`: the sequence number. Should increment for every new message and can be used to check for wrong 211 | order of arriving packets. 212 | * `option_StreamTerminated: bool`: True if this packet is the last one of the stream for the given universe. 213 | * `option_PreviewData: bool`: True if this data is for visualization purposes. 214 | * `option_ForceSync: bool`: True if this should only function in a synchronized state. 215 | * `dmxStartCode: int`: the start code for the data tuple. [1-255] Default: 0x00 for streaming level data. See 216 | [Alternate START Codes](https://tsp.esta.org/tsp/working_groups/CP/DMXAlternateCodes.php) for more information. 217 | * `dmxData: tuple`: the DMX data as tuple. Max length is 512 and shorter tuples getting normalized to a length of 512. 218 | Filled with 0 for empty spaces. 219 | 220 | ## Development 221 | Some tools are used to help with development of this library. These are [flake8](https://flake8.pycqa.org), [pytest](https://pytest.org) and [coverage.py](https://coverage.readthedocs.io). 222 | 223 | Install those tools with pip: 224 | 225 | ``` 226 | pip install flake8 pytest pytest-timeout coverage 227 | ``` 228 | 229 | `flake8` checks for formatting issues and can be run with `flake8` or `python -m flake8` in the root directory of this repository. 230 | 231 | `pytest` is used for unit testing and can be executed with `pytest` or `python -m pytest` in the root directory of this repository. 232 | By default, this skips the integration test, which uses real hardware I/O and might not run in every configuration. 233 | Use the flag `--run-integration-tests` to run the additional tests (e.g. `python -m pytest --run-integration-tests`) 234 | 235 | It is useful to check if the test coverage changed with `coverage run -m pytest` and then `coverage html`, which generates a `htmlcov/index.html` file with all the information. 236 | 237 | ### Changelog 238 | * 1.11.0: added per-address-priority feature for the `sACNsender`; see documentation above for more details 239 | * 1.10.0: *Important change*: the `bind_address` of the `sACNreceiver` is ignored on Linux when binding the socket to an address. This is part of a bugfix, but might alter behavior in certain cases. (Thanks to andrewyager! See #51 for more information) 240 | * 1.9.1: When a sACN packet could not be send out, the exception is raised instead of silently dropped. (Thanks to andrewyager! See #48 for more information) 241 | * 1.9.0: The behavior of multicast sending and receiving is now unified across most operating systems. This means Windows no longer requires to set a `bind_address` to be able to use multicast or universe discovery. (Thanks to mthespian! See #42 for more information) 242 | * 1.8.1: Calling `stop` on a sender or receiver now closes the underlying socket too. Note: after stopping a sender or receiver, it can not be started again with `start`. (See #39 for more information) 243 | * 1.8.0: Added function for removing a listener on a receiver by universe. See `sACNreceiver.remove_listener_from_universe()` for more information. 244 | * 1.7.1: Small changes that might improve timing on the sender. (Thanks to mthespian! See #36 for more information) 245 | * 1.7.0: Added function for removing a listener on a receiver. See `sACNreceiver.remove_listener()` for more information. 246 | * 1.6.4: Functionality related to sending of sACN data is now mostly covered by tests. Removed undocumented parameters for `sACNsender.start()`. 247 | * 1.6.3: Functionality related to receiving sACN data is now mostly covered by tests. Fixed a bug, where an exception was thrown on the first `DataPacket` when the stream-termination option was set. (Additional thanks to mthespian! See #31 for more information) 248 | * 1.6.2: Test coverage of sub-module `messages` is now 100%. Fixed a bug where a too long source name did not throw an exception. 249 | Fixed a bug where invalid DMX data could be set on the `DataPacket`. (Thanks to mthespian! See #30 for more information) 250 | * 1.6.1: Fixed a bug, where the DMX start code was not set on received packets (Thanks to mthespian! See #29 for more information) 251 | * 1.6: Added dmxStartCode property to DataPacket (Thanks to mthespian! see #27 for more information) 252 | * 1.5: Performance improvement: Deleted debugging statements in hot path of packet sending and receiving (Thanks to shauneccles! See #25 for more information) 253 | * 1.4.6: Fix: When creating a DataPacket with invalid DMX start codes (i.e. not `0x00`) an exception is thrown (Thanks to niggiover9000! See #11 for more information) 254 | * 1.4.5: When using a manual flush, only a specified list of universes can be flushed (Thanks to CrazyIvan359! See #22 for more information) 255 | * 1.4.4: The universe used for the sACN-sync messages can now be set when creating a `sACNsender` (Thanks to CrazyIvan359! See #21 for more information) 256 | * 1.4.3: The sequence number of the sync-packet when using manual flush was not increased (Thanks to @BlakeGarner ! See #19 for more information) 257 | * 1.4.2: The internal logging of the receiver_thread and output_thread was using the root logger instead of its module-logger. (Thanks to @mje-nz ! See #18 for more information) 258 | * 1.4: Added a manual flush feature for sending out all universes at the same time. Thanks to ahodges9 for the idea. 259 | 260 | 261 | [e1.31]: http://tsp.esta.org/tsp/documents/docs/E1-31-2016.pdf 262 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def pytest_addoption(parser): 5 | parser.addoption( 6 | "--run-integration-tests", action="store_true", default=False, help="run integration tests with hardware I/O" 7 | ) 8 | 9 | 10 | def pytest_configure(config): 11 | config.addinivalue_line("markers", "integration_test: mark test as integration test") 12 | 13 | 14 | def pytest_collection_modifyitems(config, items): 15 | if config.getoption("--run-integration-tests"): 16 | # --run-integration-tests given in cli: do not skip integration tests 17 | return 18 | skip_integration_test = pytest.mark.skip(reason="need --run-integration-tests option to run") 19 | for item in items: 20 | if "integration_test" in item.keywords: 21 | item.add_marker(skip_integration_test) 22 | -------------------------------------------------------------------------------- /integration_test.py: -------------------------------------------------------------------------------- 1 | # This is an example in how to use the sacn library to send and receive DMX data via sACN. 2 | # It assumes a multicast ready environment and the packets probably can be captured on the loopback traffic interface. 3 | # A sender and receiver is created, so that the receiver receives the values transmitted from the sender. 4 | # Each first eight bytes that the receiver gets are printed to the console. 5 | # Note that not each packet that is set by the sender loop is actually send out. 6 | # (e.g. (5, 6, 7, 8, ...) is followed by (8, 9, 10, 11, ...)) 7 | # This is due to the fact, that the sending loop sets the DMX data approx. every 10ms, 8 | # but the sending thread subsamples with approx. 33ms. 9 | # The universe that is used to transmit the data is switched from 1 to 2 in between. 10 | 11 | import time 12 | import sacn 13 | import logging 14 | import pytest 15 | from collections import OrderedDict 16 | 17 | 18 | @pytest.mark.timeout(10) 19 | @pytest.mark.integration_test 20 | def test_integration(): 21 | logging.basicConfig(level=logging.DEBUG) # enable logging of sacn module 22 | universes_changes = [] # used by the integration test 23 | universe_packet_counter = {1: 0, 2: 0} 24 | 25 | receiver = sacn.sACNreceiver() 26 | receiver.start() # start the receiving thread 27 | 28 | # Note that MacOS can not bind two sockets to the same port, 29 | # but sender and receiver both use one socket on the same port by default. 30 | # The workaround is to use a different port on the sender on MacOS. 31 | sender = sacn.sACNsender(bind_port=5569) 32 | sender.start() # start the sending thread 33 | 34 | @receiver.listen_on('availability') # check for availability of universes 35 | def callback_available(universe, changed): 36 | print(f'universe {universe}: {changed}') 37 | universes_changes.append((universe, changed)) 38 | 39 | @receiver.listen_on('universe', universe=1) # listens on universes 1 and 2 40 | @receiver.listen_on('universe', universe=2) 41 | def callback(packet): # packet type: sacn.DataPacket 42 | print(f'{packet.universe}: {packet.dmxData[:8]}') # print the received DMX data, but only the first 8 values 43 | universe_packet_counter[packet.universe] += 1 44 | 45 | receiver.join_multicast(1) 46 | receiver.join_multicast(2) 47 | 48 | sender.activate_output(1) # start sending out data in the 1st universe 49 | sender[1].multicast = True # set multicast to True 50 | 51 | def send_out_for_2s(universe: int): 52 | # with 200 value changes and each taking 10ms, this for-loop runs for 2s 53 | for i in range(0, 200): 54 | # set test DMX data that increases its first four values each iteration 55 | sender[universe].dmx_data = tuple(x % 256 for x in range(i, i + 4)) 56 | time.sleep(0.01) # sleep for 10ms 57 | 58 | send_out_for_2s(1) 59 | sender.move_universe(1, 2) 60 | send_out_for_2s(2) 61 | 62 | sender.deactivate_output(2) 63 | 64 | receiver.leave_multicast(1) 65 | receiver.leave_multicast(2) 66 | 67 | # stop both threads 68 | receiver.stop() 69 | sender.stop() 70 | 71 | # assertions of the integration test: 72 | # use an ordered dict to retain the order in which the events ocurred, 73 | # but not the amount of events (e.g. the timeout event can be called multiple times, depending on thread timings) 74 | assert OrderedDict(universes_changes) == OrderedDict([(1, "available"), (1, "timeout"), (2, "available"), (2, "timeout")]) 75 | # depending on the thread timing, the amount of packets received might be higher than 60 packets 76 | # depending on the os and network, the UDP packets might be dropped, so the amount of packets might be lower than 60 77 | # there are 30 packets per second and the test runs for at least two seconds 78 | assert universe_packet_counter[1] in range(40, 80) 79 | assert universe_packet_counter[2] in range(40, 80) 80 | 81 | 82 | if __name__ == '__main__': 83 | test_integration() 84 | -------------------------------------------------------------------------------- /sacn/__init__.py: -------------------------------------------------------------------------------- 1 | # re-export the classes available to consumers of this library 2 | from sacn.receiver import sACNreceiver, LISTEN_ON_OPTIONS # noqa: F401 3 | from sacn.sender import sACNsender # noqa: F401 4 | from sacn.messages.data_packet import DataPacket # noqa: F401 5 | from sacn.messages.universe_discovery import UniverseDiscoveryPacket # noqa: F401 6 | 7 | import logging 8 | logging.getLogger('sacn').addHandler(logging.NullHandler()) 9 | -------------------------------------------------------------------------------- /sacn/messages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hundemeier/sacn/d5fd886c2bbd57c945f9c8ffa0588c327859dd3a/sacn/messages/__init__.py -------------------------------------------------------------------------------- /sacn/messages/data_packet.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | """ 4 | This represents a framing layer and a DMP layer from the E1.31 Standard 5 | Information about sACN: http://tsp.esta.org/tsp/documents/docs/E1-31-2016.pdf 6 | """ 7 | 8 | from sacn.messages.root_layer import \ 9 | VECTOR_DMP_SET_PROPERTY, \ 10 | VECTOR_E131_DATA_PACKET, \ 11 | VECTOR_ROOT_E131_DATA, \ 12 | RootLayer, \ 13 | int_to_bytes, \ 14 | byte_tuple_to_int, \ 15 | make_flagsandlength 16 | 17 | 18 | class DataPacket(RootLayer): 19 | def __init__(self, cid: tuple, sourceName: str, universe: int, dmxData: tuple = (), priority: int = 100, 20 | sequence: int = 0, streamTerminated: bool = False, previewData: bool = False, 21 | forceSync: bool = False, sync_universe: int = 0, dmxStartCode: int = 0x00): 22 | super().__init__(126 + len(dmxData), cid, VECTOR_ROOT_E131_DATA) 23 | self.sourceName: str = sourceName 24 | self.priority = priority 25 | self.syncAddr = sync_universe 26 | self.universe = universe 27 | self.option_StreamTerminated: bool = streamTerminated 28 | self.option_PreviewData: bool = previewData 29 | self.option_ForceSync: bool = forceSync 30 | self.sequence = sequence 31 | self.dmxStartCode = dmxStartCode 32 | self.dmxData = dmxData 33 | 34 | def __str__(self): 35 | return f'sACN DataPacket: Universe: {self._universe}, Priority: {self._priority}, Sequence: {self._sequence}, ' \ 36 | f'CID: {self._cid}' 37 | 38 | @property 39 | def sourceName(self) -> str: 40 | return self._sourceName 41 | 42 | @sourceName.setter 43 | def sourceName(self, sourceName: str): 44 | if type(sourceName) is not str: 45 | raise TypeError(f'sourceName must be a string! Type was {type(sourceName)}') 46 | tmp_sourceName_length = len(str(sourceName).encode('UTF-8')) 47 | if tmp_sourceName_length > 63: 48 | raise ValueError(f'sourceName must be less than 64 bytes when UTF-8 encoded! "{sourceName}" is {tmp_sourceName_length} bytes') 49 | self._sourceName = sourceName 50 | 51 | @property 52 | def priority(self) -> int: 53 | return self._priority 54 | 55 | @priority.setter 56 | def priority(self, priority: int): 57 | if type(priority) is not int: 58 | raise TypeError(f'priority must be an integer! Type was {type(priority)}') 59 | if priority not in range(0, 201): 60 | raise ValueError(f'priority must be in range [0-200]! value was {priority}') 61 | self._priority = priority 62 | 63 | @property 64 | def universe(self) -> int: 65 | return self._universe 66 | 67 | @universe.setter 68 | def universe(self, universe: int): 69 | if type(universe) is not int: 70 | raise TypeError(f'universe must be an integer! Type was {type(universe)}') 71 | if universe not in range(1, 64000): 72 | raise ValueError(f'universe must be [1-63999]! value was {universe}') 73 | self._universe = universe 74 | 75 | @property 76 | def syncAddr(self) -> int: 77 | return self._syncAddr 78 | 79 | @syncAddr.setter 80 | def syncAddr(self, sync_universe: int): 81 | if type(sync_universe) is not int: 82 | raise TypeError(f'sync_universe must be an integer! Type was {type(sync_universe)}') 83 | if sync_universe not in range(0, 64000): 84 | raise ValueError(f'sync_universe must be [1-63999]! value was {sync_universe}') 85 | self._syncAddr = sync_universe 86 | 87 | @property 88 | def sequence(self) -> int: 89 | return self._sequence 90 | 91 | @sequence.setter 92 | def sequence(self, sequence: int): 93 | if type(sequence) is not int: 94 | raise TypeError(f'sequence must be an integer! Type was {type(sequence)}') 95 | if sequence not in range(0, 256): 96 | raise ValueError(f'sequence is a byte! values: [0-255]! value was {sequence}') 97 | self._sequence = sequence 98 | 99 | def sequence_increase(self): 100 | self._sequence += 1 101 | if self._sequence > 0xFF: 102 | self._sequence = 0 103 | 104 | @property 105 | def dmxStartCode(self) -> int: 106 | return self._dmxStartCode 107 | 108 | @dmxStartCode.setter 109 | def dmxStartCode(self, dmxStartCode: int): 110 | """ 111 | DMX start code values: 0x00 for level data; 0xDD for per address priority data 112 | """ 113 | if type(dmxStartCode) is not int: 114 | raise TypeError(f'dmx start code must be an integer! Type was {type(dmxStartCode)}') 115 | if dmxStartCode not in range(0, 256): 116 | raise ValueError(f'dmx start code is a byte! values: [0-255]! value was {dmxStartCode}') 117 | self._dmxStartCode = dmxStartCode 118 | 119 | @property 120 | def dmxData(self) -> tuple: 121 | return self._dmxData 122 | 123 | @dmxData.setter 124 | def dmxData(self, data: tuple): 125 | """ 126 | For legacy devices and to prevent errors, the length of the DMX data is normalized to 512 127 | """ 128 | if len(data) > 512 or \ 129 | not all((isinstance(x, int) and (0 <= x <= 255)) for x in data): 130 | raise ValueError(f'dmxData is a tuple with a max length of 512! The data in the tuple has to be valid bytes! ' 131 | f'Length was {len(data)}') 132 | newData = [0]*512 133 | for i in range(0, min(len(data), 512)): 134 | newData[i] = data[i] 135 | self._dmxData = tuple(newData) 136 | # in theory this class supports dynamic length, so the next line is correcting the length 137 | self.length = 126 + len(self._dmxData) 138 | 139 | def getBytes(self) -> tuple: 140 | rtrnList = super().getBytes() 141 | # Flags and Length Framing Layer:------- 142 | rtrnList.extend(make_flagsandlength(self.length - 38)) 143 | # Vector Framing Layer:----------------- 144 | rtrnList.extend(VECTOR_E131_DATA_PACKET) 145 | # sourceName:--------------------------- 146 | # UTF-8 encode the string 147 | tmpSourceName = str(self._sourceName).encode('UTF-8') 148 | rtrnList.extend(tmpSourceName) 149 | # pad to 64 bytes 150 | rtrnList.extend([0] * (64 - len(tmpSourceName))) 151 | # priority------------------------------ 152 | rtrnList.append(self._priority) 153 | # syncAddress--------------------------- 154 | rtrnList.extend(int_to_bytes(self._syncAddr)) 155 | # sequence------------------------------ 156 | rtrnList.append(self._sequence) 157 | # Options Flags:------------------------ 158 | tmpOptionsFlags = 0 159 | # stream terminated: 160 | tmpOptionsFlags += int(self.option_StreamTerminated) << 6 161 | # preview data: 162 | tmpOptionsFlags += int(self.option_PreviewData) << 7 163 | # force synchronization 164 | tmpOptionsFlags += int(self.option_ForceSync) << 5 165 | rtrnList.append(tmpOptionsFlags) 166 | # universe:----------------------------- 167 | rtrnList.extend(int_to_bytes(self._universe)) 168 | # DMP Layer:--------------------------------------------------- 169 | # Flags and Length DMP Layer:----------- 170 | rtrnList.extend(make_flagsandlength(self.length - 115)) 171 | # Vector DMP Layer:--------------------- 172 | rtrnList.append(VECTOR_DMP_SET_PROPERTY) 173 | # Some static values (Address & Data Type, First Property addr, ...) 174 | rtrnList.extend([0xa1, 0x00, 0x00, 0x00, 0x01]) 175 | # Length of the data:------------------- 176 | lengthDmxData = len(self._dmxData)+1 177 | rtrnList.extend(int_to_bytes(lengthDmxData)) 178 | # DMX data:----------------------------- 179 | rtrnList.append(self._dmxStartCode) # DMX Start Code 180 | rtrnList.extend(self._dmxData) 181 | return tuple(rtrnList) 182 | 183 | @staticmethod 184 | def make_data_packet(raw_data) -> 'DataPacket': 185 | """ 186 | Converts raw byte data to a sACN DataPacket. Note that the raw bytes have to come from a 2016 sACN Message. 187 | This does not support DMX Start code! 188 | :param raw_data: raw bytes as tuple or list 189 | :raises TypeError: when the binary data does not match the criteria for a valid DMX data-packet 190 | :return: a DataPacket with the properties set like the raw bytes 191 | """ 192 | # Check if the length is sufficient 193 | if len(raw_data) < 126: 194 | raise TypeError('The length of the provided data is not long enough! Min length is 126!') 195 | # Check if the three Vectors are correct 196 | if tuple(raw_data[18:22]) != tuple(VECTOR_ROOT_E131_DATA) or \ 197 | tuple(raw_data[40:44]) != tuple(VECTOR_E131_DATA_PACKET) or \ 198 | raw_data[117] != VECTOR_DMP_SET_PROPERTY: # REMEMBER: when slicing: [inclusive:exclusive] 199 | raise TypeError('Some of the vectors in the given raw data are not compatible to the E131 Standard!') 200 | 201 | tmpPacket = DataPacket(cid=tuple(raw_data[22:38]), sourceName=bytes(raw_data[44:108]).decode('utf-8').replace('\0', ''), 202 | universe=byte_tuple_to_int(raw_data[113:115])) # high byte first 203 | tmpPacket.priority = raw_data[108] 204 | tmpPacket.syncAddr = byte_tuple_to_int(raw_data[109:111]) 205 | tmpPacket.sequence = raw_data[111] 206 | tmpPacket.option_PreviewData = bool(raw_data[112] & 0b10000000) # use the 7th bit as preview_data 207 | tmpPacket.option_StreamTerminated = bool(raw_data[112] & 0b01000000) # use bit 6 as stream terminated 208 | tmpPacket.option_ForceSync = bool(raw_data[112] & 0b00100000) # use bit 5 as force sync 209 | tmpPacket.dmxStartCode = raw_data[125] 210 | tmpPacket.dmxData = raw_data[126:638] 211 | return tmpPacket 212 | 213 | def calculate_multicast_addr(self) -> str: 214 | return calculate_multicast_addr(self.universe) 215 | 216 | 217 | def calculate_multicast_addr(universe: int) -> str: 218 | hi_byte = universe >> 8 # a little bit shifting here 219 | lo_byte = universe & 0xFF # a little bit mask there 220 | return f'239.255.{hi_byte}.{lo_byte}' 221 | -------------------------------------------------------------------------------- /sacn/messages/data_packet_test.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import pytest 4 | from sacn.messages.data_packet import \ 5 | calculate_multicast_addr, \ 6 | DataPacket 7 | from sacn.messages.general_test import property_number_range_check 8 | 9 | 10 | def test_calculate_multicast_addr(): 11 | universe_1 = '239.255.0.1' 12 | universe_63999 = '239.255.249.255' 13 | assert calculate_multicast_addr(1) == universe_1 14 | assert calculate_multicast_addr(63999) == universe_63999 15 | packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1) 16 | assert packet.calculate_multicast_addr() == universe_1 17 | packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=63999) 18 | assert packet.calculate_multicast_addr() == universe_63999 19 | 20 | 21 | def test_byte_construction_and_deconstruction(): 22 | built_packet = DataPacket( 23 | cid=(16, 1, 15, 2, 14, 3, 13, 4, 12, 5, 11, 6, 10, 7, 9, 8), 24 | sourceName='Test Name', 25 | universe=62000, 26 | dmxData=tuple(x % 256 for x in range(0, 512)), 27 | priority=195, 28 | sequence=34, 29 | streamTerminated=True, 30 | previewData=True, 31 | forceSync=True, 32 | sync_universe=12000, 33 | dmxStartCode=12) 34 | read_packet = DataPacket.make_data_packet(built_packet.getBytes()) 35 | assert built_packet.dmxData == read_packet.dmxData 36 | assert built_packet == read_packet 37 | 38 | 39 | def test_property_adjustment_and_deconstruction(): 40 | # Converting DataPacket -> bytes -> DataPacket should produce the same result, 41 | # but with changed properties that are not the default 42 | built_packet = DataPacket( 43 | cid=(16, 1, 15, 2, 14, 3, 13, 4, 12, 5, 11, 6, 10, 7, 9, 8), 44 | sourceName='Test Name', 45 | universe=30) 46 | built_packet.cid = tuple(range(16)) 47 | built_packet.sourceName = '2nd Test Name' 48 | built_packet.universe = 31425 49 | built_packet.dmxData = ((200,) + tuple(range(255, 0, -1)) + tuple(range(255)) + (0,)) 50 | built_packet.priority = 12 51 | built_packet.sequence = 45 52 | built_packet.option_StreamTerminated = True 53 | built_packet.option_PreviewData = True 54 | built_packet.option_ForceSync = True 55 | built_packet.syncAddr = 34003 56 | built_packet.dmxStartCode = 8 57 | read_packet = DataPacket.make_data_packet(built_packet.getBytes()) 58 | assert read_packet.cid == tuple(range(16)) 59 | assert read_packet.sourceName == '2nd Test Name' 60 | assert read_packet.universe == 31425 61 | assert read_packet.dmxData == ((200,) + tuple(range(255, 0, -1)) + tuple(range(255)) + (0,)) 62 | assert read_packet.priority == 12 63 | assert read_packet.sequence == 45 64 | assert read_packet.option_StreamTerminated is True 65 | assert read_packet.option_PreviewData is True 66 | assert read_packet.option_ForceSync is True 67 | assert read_packet.syncAddr == 34003 68 | assert read_packet.dmxStartCode == 8 69 | 70 | 71 | def test_sequence_increment(): 72 | # Test that the sequence number can be increased and the wrap around at 255 is correct 73 | built_packet = DataPacket( 74 | cid=(16, 1, 15, 2, 14, 3, 13, 4, 12, 5, 11, 6, 10, 7, 9, 8), 75 | sourceName='Test Name', 76 | universe=30) 77 | built_packet.sequence = 78 78 | built_packet.sequence_increase() 79 | assert built_packet.sequence == 79 80 | built_packet.sequence = 255 81 | built_packet.sequence_increase() 82 | assert built_packet.sequence == 0 83 | 84 | 85 | def test_parse_data_packet(): 86 | # Use the example present in the E1.31 spec in appendix B 87 | raw_data = [ 88 | # preamble size 89 | 0x00, 0x10, 90 | # postamble size 91 | 0x00, 0x00, 92 | # ACN packet identifier 93 | 0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00, 94 | # flags and length 95 | 0x72, 0x7d, 96 | # Root vector 97 | 0x00, 0x00, 0x00, 0x04, 98 | # CID 99 | 0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e, 100 | # Framing flags and length 101 | 0x72, 0x57, 102 | # Framing vector 103 | 0x00, 0x00, 0x00, 0x02, 104 | # Source name 'Source_A' 105 | 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x41, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 106 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 107 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 108 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 109 | # priority 110 | 0x64, 111 | # sync address 112 | 0x1f, 0x1a, 113 | # sequence number 114 | 0x9a, 115 | # options 116 | 0x00, 117 | # universe 118 | 0x00, 0x01, 119 | # DMP flags and length 120 | 0x72, 0x0d, 121 | # DMP vector 122 | 0x02, 123 | # address type & data type 124 | 0xa1, 125 | # first property address 126 | 0x00, 0x00, 127 | # address increment 128 | 0x00, 0x01, 129 | # property value count 130 | 0x02, 0x01, 131 | # DMX start code 132 | 0x00, 133 | ] 134 | # DMX data (starting with 0 and incrementing with wrap around at 255) 135 | dmx_data = [x % 256 for x in range(0, 512)] 136 | raw_data.extend(dmx_data) 137 | 138 | # parse raw data 139 | packet = DataPacket.make_data_packet(raw_data) 140 | assert packet.length == 638 141 | assert packet._vector == (0x00, 0x00, 0x00, 0x04) 142 | assert packet.cid == (0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e) 143 | assert packet.sourceName == 'Source_A' 144 | assert packet.priority == 100 145 | assert packet.syncAddr == 7962 146 | assert packet.sequence == 154 147 | assert packet.option_ForceSync is False 148 | assert packet.option_PreviewData is False 149 | assert packet.option_StreamTerminated is False 150 | assert packet.universe == 1 151 | assert packet.dmxStartCode == 0 152 | assert packet.dmxData == tuple(dmx_data) 153 | 154 | # test for invalid data 155 | # test for too short data arrays 156 | for i in range(1, 126): 157 | with pytest.raises(TypeError): 158 | DataPacket.make_data_packet([x % 256 for x in range(0, i)]) 159 | # test for invalid vectors 160 | with pytest.raises(TypeError): 161 | DataPacket.make_data_packet([x % 256 for x in range(0, 126)]) 162 | 163 | 164 | def test_str(): 165 | packet = DataPacket( 166 | cid=(16, 1, 15, 2, 14, 3, 13, 4, 12, 5, 11, 6, 10, 7, 9, 8), 167 | sourceName='Test', 168 | universe=62000, 169 | priority=195, 170 | sequence=34) 171 | 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)' 172 | 173 | 174 | def test_sourceName(): 175 | # test string has 25 characters but is 64 bytes (1 too many) when UTF-8 encoded 176 | overlength_string = "𔑑覱֪I𤵎⠣Ķ'𫳪爓Û:𢏴㓑ò4𰬀鿹џ>𖬲膬ЩJ𞄇" 177 | packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1) 178 | # test property setter 179 | with pytest.raises(TypeError): 180 | packet.sourceName = 0x33 181 | with pytest.raises(ValueError): 182 | packet.sourceName = overlength_string 183 | # test constructor 184 | with pytest.raises(ValueError): 185 | DataPacket(cid=tuple(range(0, 16)), sourceName=overlength_string, universe=1) 186 | 187 | 188 | def test_priority(): 189 | packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1) 190 | # test property setter 191 | def property(i): packet.priority = i 192 | # test constructor for the same parameter 193 | def constructor(i): DataPacket(tuple(range(0, 16)), sourceName="", universe=1, priority=i) 194 | property_number_range_check(0, 200, property, constructor) 195 | 196 | 197 | def test_universe(): 198 | packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1) 199 | # test property setter 200 | def property(i): packet.universe = i 201 | # test constructor for the same parameter 202 | def constructor(i): DataPacket(tuple(range(0, 16)), sourceName="", universe=i) 203 | property_number_range_check(1, 63999, property, constructor) 204 | 205 | 206 | def test_sync_universe(): 207 | packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1) 208 | # test property setter 209 | def property(i): packet.syncAddr = i 210 | # test constructor for the same parameter 211 | def constructor(i): DataPacket(tuple(range(0, 16)), sourceName="", universe=1, sync_universe=i) 212 | property_number_range_check(0, 63999, property, constructor) 213 | 214 | 215 | def test_sequence(): 216 | packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1) 217 | # test property setter 218 | def property(i): packet.sequence = i 219 | # test constructor for the same parameter 220 | def constructor(i): DataPacket(tuple(range(0, 16)), sourceName="", universe=1, sequence=i) 221 | property_number_range_check(0, 255, property, constructor) 222 | 223 | 224 | def test_dmx_start_code(): 225 | packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1) 226 | # test property setter 227 | def property(i): packet.dmxStartCode = i 228 | # test constructor for the same parameter 229 | def constructor(i): DataPacket(tuple(range(0, 16)), sourceName="", universe=1, dmxStartCode=i) 230 | property_number_range_check(0, 255, property, constructor) 231 | 232 | 233 | def test_dmx_data(): 234 | packet = DataPacket(cid=tuple(range(0, 16)), sourceName="", universe=1) 235 | # test valid lengths 236 | for i in range(0, 512): 237 | data = tuple(x % 256 for x in range(0, i)) 238 | # test property setter 239 | packet.dmxData = data 240 | assert len(packet.dmxData) == 512 241 | assert packet.length == 638 242 | # test constructor for the same parameter 243 | packet2 = DataPacket(tuple(range(0, 16)), sourceName="", universe=1, dmxData=data) 244 | assert len(packet2.dmxData) == 512 245 | assert packet2.length == 638 246 | 247 | def execute_universes_expect(data: tuple): 248 | with pytest.raises(ValueError): 249 | packet.dmxData = data 250 | with pytest.raises(ValueError): 251 | DataPacket(tuple(range(0, 16)), sourceName="", universe=1, dmxData=data) 252 | 253 | # test for non-int and out of range values values in tuple 254 | execute_universes_expect(tuple('string')) 255 | execute_universes_expect(tuple(range(255, 257))) 256 | 257 | # test for tuple-length > 512 258 | execute_universes_expect(tuple(range(0, 513))) 259 | -------------------------------------------------------------------------------- /sacn/messages/data_types.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | """ 4 | This file includes custom types for the usage in sACN packets. 5 | """ 6 | 7 | 8 | class CID: 9 | """ 10 | CID stores a special case of a tuple that must have a length of 16 byte elements. 11 | """ 12 | 13 | def __init__(self, cid: tuple): 14 | self.value = cid 15 | 16 | @property 17 | def value(self) -> tuple: 18 | return self._cid 19 | 20 | @value.setter 21 | def value(self, cid: tuple): 22 | if type(cid) is not tuple: 23 | raise TypeError(f'cid must be a 16 byte tuple! value was {cid}') 24 | if (len(cid) != 16 or not all((isinstance(x, int) and (0 <= x <= 255)) for x in cid)): 25 | raise ValueError(f'cid must be a 16 byte tuple! value was {cid}') 26 | self._cid = cid 27 | 28 | def __eq__(self, other: 'CID') -> bool: 29 | return self.value == other.value 30 | -------------------------------------------------------------------------------- /sacn/messages/data_types_test.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | 4 | import pytest 5 | from typing import Any, Callable 6 | from sacn.messages.data_types import CID 7 | 8 | 9 | def create_valid_cid() -> CID: 10 | return CID(tuple(range(0, 16))) 11 | 12 | 13 | def cid_failing_test_cases(apply_to: Callable[[Any], None]): 14 | def char_range(char1, char2): 15 | """Generates the characters from `c1` to `c2`, ranged just like python.""" 16 | for c in range(ord(char1), ord(char2)): 17 | yield chr(c) 18 | 19 | # test that constructor validates cid 20 | with pytest.raises(ValueError): 21 | apply_to(tuple(char_range('A', 'Q'))) 22 | # test that CID must be 16 elements 23 | with pytest.raises(ValueError): 24 | apply_to(tuple(range(0, 17))) 25 | with pytest.raises(ValueError): 26 | apply_to(tuple(range(0, 15))) 27 | # test that CID only contains valid byte values 28 | with pytest.raises(ValueError): 29 | apply_to(tuple(range(250, 266))) 30 | with pytest.raises(ValueError): 31 | apply_to(tuple(char_range('b', 'r'))) 32 | # test that CID is a tuple 33 | with pytest.raises(TypeError): 34 | apply_to(range(0, 16)) 35 | 36 | 37 | def test_cid_constructor(): 38 | # normal values must work 39 | tuple1 = tuple(range(0, 16)) 40 | tuple2 = tuple(range(1, 17)) 41 | cid1 = CID(tuple1) 42 | cid2 = CID(tuple2) 43 | assert cid1.value == tuple1 44 | assert cid2.value == tuple2 45 | 46 | def apply_constructor(value) -> None: 47 | CID(value) 48 | 49 | cid_failing_test_cases(apply_constructor) 50 | 51 | 52 | def test_cid_setter(): 53 | tuple1 = tuple(range(0, 16)) 54 | tuple2 = tuple(range(1, 17)) 55 | 56 | cid = CID(tuple(range(2, 18))) 57 | # normal values must work 58 | cid.value = tuple1 59 | assert cid.value == tuple1 60 | cid.value = tuple2 61 | assert cid.value == tuple2 62 | 63 | def apply_setter(value) -> None: 64 | cid.value = value 65 | 66 | cid_failing_test_cases(apply_setter) 67 | 68 | 69 | def test_cid_equals(): 70 | tuple1 = tuple(range(0, 16)) 71 | tuple2 = tuple(range(0, 16)) 72 | 73 | cid1 = CID(tuple1) 74 | cid2 = CID(tuple2) 75 | assert cid1 == cid2 76 | -------------------------------------------------------------------------------- /sacn/messages/general_test.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import pytest 4 | 5 | 6 | def property_number_range_check(lower_bound: int, upper_bound: int, *functions): 7 | for function in functions: 8 | for i in range(lower_bound, upper_bound + 1): 9 | function(i) 10 | with pytest.raises(ValueError): 11 | function(lower_bound - 1) 12 | with pytest.raises(ValueError): 13 | function(upper_bound + 1) 14 | with pytest.raises(TypeError): 15 | function('Text') 16 | -------------------------------------------------------------------------------- /sacn/messages/root_layer.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | 4 | """ 5 | This represents a root layer of an ACN Message. 6 | Information about sACN: http://tsp.esta.org/tsp/documents/docs/E1-31-2016.pdf 7 | """ 8 | 9 | _FIRST_INDEX = \ 10 | (0, 0x10, 0, 0, 0x41, 0x53, 0x43, 0x2d, 0x45, 11 | 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00) 12 | 13 | VECTOR_E131_DATA_PACKET = (0, 0, 0, 0x02) 14 | VECTOR_DMP_SET_PROPERTY = 0x02 15 | VECTOR_ROOT_E131_DATA = (0, 0, 0, 0x4) 16 | 17 | VECTOR_ROOT_E131_EXTENDED = (0, 0, 0, 0x8) 18 | 19 | VECTOR_E131_EXTENDED_SYNCHRONIZATION = (0, 0, 0, 0x1) 20 | VECTOR_E131_EXTENDED_DISCOVERY = (0, 0, 0, 0x2) 21 | VECTOR_UNIVERSE_DISCOVERY_UNIVERSE_LIST = (0, 0, 0, 0x1) 22 | 23 | 24 | class RootLayer: 25 | def __init__(self, length: int, cid: tuple, vector: tuple): 26 | self.length = length 27 | if (len(vector) != 4): 28 | raise ValueError('the length of the vector is not 4!') 29 | self._vector = vector 30 | self.cid = cid 31 | 32 | def getBytes(self) -> list: 33 | '''Returns the Root layer as list with bytes''' 34 | tmpList = [] 35 | tmpList.extend(_FIRST_INDEX) 36 | # first append the high byte from the Flags and Length 37 | # high 4 bit: 0x7 then the bits 8-11(indexes) from _length 38 | length = self.length - 16 39 | tmpList.extend(make_flagsandlength(length)) 40 | 41 | tmpList.extend(self._vector) 42 | tmpList.extend(self._cid) 43 | return tmpList 44 | 45 | @property 46 | def length(self) -> int: 47 | return self._length 48 | 49 | @length.setter 50 | def length(self, value: int): 51 | self._length = value & 0xFFF # only use the least 12-Bit 52 | 53 | @property 54 | def cid(self) -> tuple: 55 | return self._cid 56 | 57 | @cid.setter 58 | def cid(self, cid: tuple): 59 | if type(cid) is not tuple: 60 | raise TypeError(f'cid must be a 16 byte tuple! value was {cid}') 61 | if (len(cid) != 16 or not all((isinstance(x, int) and (0 <= x <= 255)) for x in cid)): 62 | raise ValueError(f'cid must be a 16 byte tuple! value was {cid}') 63 | self._cid = cid 64 | 65 | def __eq__(self, other): 66 | if self.__class__ != other.__class__: 67 | return False 68 | return self.__dict__ == other.__dict__ 69 | 70 | 71 | def int_to_bytes(integer_value: int) -> list: 72 | """ 73 | Converts a single integer number to an list with the length 2 with highest 74 | byte first. 75 | The returned list contains values in the range [0-255] 76 | :param integer: the integer to convert 77 | :return: the list with the high byte first 78 | """ 79 | if not (isinstance(integer_value, int) and 0 <= integer_value <= 65535): 80 | raise ValueError(f'integer_value to be packed must be unsigned short: [0-65535]! value was {integer_value}') 81 | return [(integer_value >> 8) & 0xFF, integer_value & 0xFF] 82 | 83 | 84 | def byte_tuple_to_int(in_tuple: tuple) -> int: 85 | """ 86 | Converts two element byte tuple (highest first) to integer. 87 | :param in_tuple: the integer to convert 88 | :return: the integer value 89 | """ 90 | 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)): 91 | raise ValueError(f'in_tuple must be a two byte tuple! value was {in_tuple}') 92 | return int((in_tuple[0] << 8) + in_tuple[1]) 93 | 94 | 95 | def make_flagsandlength(in_length: int) -> list: 96 | """ 97 | Converts a length value in a Flags and Length list with two bytes in the 98 | correct order. 99 | :param length: the length to convert. should be 12-bit value 100 | :return: the list with the two bytes 101 | """ 102 | if (in_length > 0xFFF): 103 | raise ValueError(f'length must be no greater than a 12-bit value! value was {in_length}') 104 | return [(0x7 << 4) + ((in_length & 0xF00) >> 8), in_length & 0xFF] 105 | -------------------------------------------------------------------------------- /sacn/messages/root_layer_test.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import pytest 4 | from sacn.messages.root_layer import \ 5 | byte_tuple_to_int, \ 6 | int_to_bytes, \ 7 | make_flagsandlength, \ 8 | RootLayer 9 | 10 | 11 | def test_int_to_bytes(): 12 | assert int_to_bytes(0xFFFF) == [0xFF, 0xFF] 13 | assert int_to_bytes(0x1234) == [0x12, 0x34] 14 | # test that the value cannot exceed two bytes 15 | with pytest.raises(ValueError): 16 | int_to_bytes(0x123456) 17 | assert int_to_bytes(0x0001) == [0x00, 0x01] 18 | 19 | 20 | def test_byte_tuple_to_int(): 21 | assert byte_tuple_to_int((0x00, 0x00)) == 0x0000 22 | assert byte_tuple_to_int((0xFF, 0xFF)) == 0xFFFF 23 | assert byte_tuple_to_int((0x12, 0x34)) == 0x1234 24 | # test different length of tuples 25 | with pytest.raises(ValueError): 26 | byte_tuple_to_int(()) 27 | with pytest.raises(TypeError): 28 | byte_tuple_to_int(1) 29 | with pytest.raises(ValueError): 30 | byte_tuple_to_int((1, 2, 3)) 31 | with pytest.raises(ValueError): 32 | byte_tuple_to_int((1, 'string')) 33 | with pytest.raises(ValueError): 34 | byte_tuple_to_int((1, 500)) 35 | 36 | 37 | def test_eq(): 38 | cid = tuple(range(0, 16)) 39 | cid2 = tuple(range(1, 17)) 40 | vec = tuple(range(0, 4)) 41 | vec2 = tuple(range(1, 5)) 42 | assert RootLayer(0, cid, vec) == RootLayer(0, cid, vec) 43 | assert RootLayer(0, cid, vec) != RootLayer(1, cid, vec) 44 | assert RootLayer(0, cid, vec) != RootLayer(0, cid, vec2) 45 | assert RootLayer(0, cid, vec) != RootLayer(0, cid2, vec) 46 | assert (RootLayer(0, cid, vec) == (1, 2, 3)) is False 47 | 48 | 49 | def test_make_flagsandlength(): 50 | assert make_flagsandlength(0x123) == [0x71, 0x23] 51 | with pytest.raises(ValueError): 52 | assert make_flagsandlength(0x1234) == [0x72, 0x34] 53 | assert make_flagsandlength(0x001) == [0x70, 0x01] 54 | 55 | 56 | def test_cid(): 57 | cid1 = tuple(range(0, 16)) 58 | cid2 = tuple(range(1, 17)) 59 | vector1 = (1, 2, 3, 4) 60 | 61 | def char_range(char1, char2): 62 | """Generates the characters from `c1` to `c2`, ranged just like python.""" 63 | for c in range(ord(char1), ord(char2)): 64 | yield chr(c) 65 | 66 | # test constructor 67 | packet = RootLayer(123, cid1, vector1) 68 | assert packet.cid == cid1 69 | packet.cid = cid2 70 | assert packet.cid == cid2 71 | # test that constructor validates cid 72 | with pytest.raises(ValueError): 73 | RootLayer(length=123, cid=tuple(char_range('A', 'Q')), vector=vector1) 74 | # test that CID must be 16 elements 75 | with pytest.raises(ValueError): 76 | packet.cid = tuple(range(0, 17)) 77 | with pytest.raises(ValueError): 78 | packet.cid = tuple(range(0, 15)) 79 | # test that CID only contains valid byte values 80 | with pytest.raises(ValueError): 81 | packet.cid = tuple(range(250, 266)) 82 | with pytest.raises(ValueError): 83 | packet.cid = tuple(char_range('b', 'r')) 84 | # test that CID is a tuple 85 | with pytest.raises(TypeError): 86 | packet.cid = range(0, 16) 87 | 88 | 89 | def test_root_layer_bytes(): 90 | cid = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) 91 | vector = (1, 2, 3, 4) 92 | # test that the vector length must be 4 93 | with pytest.raises(ValueError): 94 | RootLayer(0, cid, ()) 95 | # test that the cid length must be 16 96 | with pytest.raises(ValueError): 97 | RootLayer(0, (), vector) 98 | packet = RootLayer(0x123456, cid, vector) 99 | shouldBe = [ 100 | # initial static vector 101 | 0, 0x10, 0, 0, 0x41, 0x53, 0x43, 0x2d, 0x45, 102 | 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00, 103 | # length value 104 | 0x74, 0x46 105 | ] 106 | # vector 107 | shouldBe.extend(vector) 108 | # cid 109 | shouldBe.extend(cid) 110 | assert packet.getBytes() == shouldBe 111 | 112 | 113 | def test_int_byte_transitions(): 114 | # test the full 0-65534 range, though only using 0-63999 currently 115 | for input_i in range(65536): 116 | converted_i = byte_tuple_to_int(tuple(int_to_bytes(input_i))) 117 | assert input_i == converted_i 118 | -------------------------------------------------------------------------------- /sacn/messages/sync_packet.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | """ 4 | This implements the sync packet from the E1.31 standard. 5 | Information about sACN: http://tsp.esta.org/tsp/documents/docs/E1-31-2016.pdf 6 | """ 7 | from sacn.messages.root_layer import \ 8 | VECTOR_ROOT_E131_EXTENDED, \ 9 | VECTOR_E131_EXTENDED_SYNCHRONIZATION, \ 10 | RootLayer, \ 11 | byte_tuple_to_int, \ 12 | int_to_bytes, \ 13 | make_flagsandlength 14 | 15 | 16 | class SyncPacket(RootLayer): 17 | def __init__(self, cid: tuple, syncAddr: int, sequence: int = 0): 18 | self.syncAddr = syncAddr 19 | self.sequence = sequence 20 | super().__init__(49, cid, VECTOR_ROOT_E131_EXTENDED) 21 | 22 | @property 23 | def syncAddr(self) -> int: 24 | return self._syncAddr 25 | 26 | @syncAddr.setter 27 | def syncAddr(self, sync_universe: int): 28 | if type(sync_universe) is not int: 29 | raise TypeError(f'sync_universe must be an integer! Type was {type(sync_universe)}') 30 | if sync_universe not in range(1, 64000): 31 | raise ValueError(f'sync_universe must be [1-63999]! value was {sync_universe}') 32 | self._syncAddr = sync_universe 33 | 34 | @property 35 | def sequence(self) -> int: 36 | return self._sequence 37 | 38 | @sequence.setter 39 | def sequence(self, sequence: int): 40 | if type(sequence) is not int: 41 | raise TypeError('sequence must be an integer') 42 | if sequence not in range(0, 256): 43 | raise ValueError(f'sequence is a byte! values: [0-255]! value was {sequence}') 44 | self._sequence = sequence 45 | 46 | def sequence_increase(self): 47 | self._sequence += 1 48 | if self._sequence > 0xFF: 49 | self._sequence = 0 50 | 51 | def getBytes(self) -> list: 52 | rtrnList = super().getBytes() 53 | rtrnList.extend(make_flagsandlength(self.length - 38)) 54 | rtrnList.extend(VECTOR_E131_EXTENDED_SYNCHRONIZATION) 55 | rtrnList.append(self._sequence) 56 | rtrnList.extend(int_to_bytes(self._syncAddr)) 57 | rtrnList.extend((0, 0)) # the empty reserved slots 58 | return rtrnList 59 | 60 | @staticmethod 61 | def make_sync_packet(raw_data) -> 'SyncPacket': 62 | """ 63 | Converts raw byte data to a sACN SyncPacket. Note that the raw bytes have to come from a 2016 sACN Message. 64 | :param raw_data: raw bytes as tuple or list 65 | :return: a SyncPacket with the properties set like the raw bytes 66 | """ 67 | # Check if the length is sufficient 68 | if len(raw_data) < 47: 69 | raise TypeError('The length of the provided data is not long enough! Min length is 47!') 70 | # Check if the three Vectors are correct 71 | if tuple(raw_data[18:22]) != tuple(VECTOR_ROOT_E131_EXTENDED) or \ 72 | tuple(raw_data[40:44]) != tuple(VECTOR_E131_EXTENDED_SYNCHRONIZATION): 73 | # REMEMBER: when slicing: [inclusive:exclusive] 74 | raise TypeError('Some of the vectors in the given raw data are not compatible to the E131 Standard!') 75 | tmpPacket = SyncPacket(cid=tuple(raw_data[22:38]), syncAddr=byte_tuple_to_int(raw_data[45:47])) 76 | tmpPacket.sequence = raw_data[44] 77 | return tmpPacket 78 | -------------------------------------------------------------------------------- /sacn/messages/sync_packet_test.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import pytest 4 | from sacn.messages.sync_packet import SyncPacket 5 | from sacn.messages.general_test import property_number_range_check 6 | 7 | 8 | def test_constructor(): 9 | # positive tests 10 | cid = tuple(range(0, 16)) 11 | syncAddr = 12 12 | sequence = 127 13 | packet = SyncPacket(cid, syncAddr, sequence) 14 | assert packet.length == 49 15 | assert packet.cid == cid 16 | assert packet.syncAddr == syncAddr 17 | assert packet.sequence == sequence 18 | # using wrong values for CID 19 | with pytest.raises(ValueError): 20 | SyncPacket(tuple(range(0, 17)), syncAddr, sequence) 21 | 22 | 23 | def test_sync_universe(): 24 | packet = SyncPacket(tuple(range(0, 16)), 1, 1) 25 | # test property setter 26 | def property(i): packet.syncAddr = i 27 | # test constructor for the same parameter 28 | def constructor(i): SyncPacket(tuple(range(0, 16)), i, 1) 29 | property_number_range_check(1, 63999, property, constructor) 30 | 31 | 32 | def test_sequence(): 33 | packet = SyncPacket(tuple(range(0, 16)), 1, 1) 34 | # test property setter 35 | def property(i): packet.sequence = i 36 | # test constructor for the same parameter 37 | def constructor(i): SyncPacket(tuple(range(0, 16)), 1, i) 38 | property_number_range_check(0, 255, property, constructor) 39 | 40 | 41 | def test_sequence_increment(): 42 | # Test that the sequence number can be increased and the wrap around at 255 is correct 43 | built_packet = SyncPacket(tuple(range(0, 16)), 1, 1) 44 | built_packet.sequence = 78 45 | built_packet.sequence_increase() 46 | assert built_packet.sequence == 79 47 | built_packet.sequence = 255 48 | built_packet.sequence_increase() 49 | assert built_packet.sequence == 0 50 | 51 | 52 | def test_get_bytes(): 53 | # Use the example present in the E1.31 spec in appendix B 54 | cid = (0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e) 55 | syncAddr = 7962 56 | sequence = 67 # Note: the spec states 367, which is a mistake in the spec 57 | packet = SyncPacket(cid, syncAddr, sequence) 58 | assert packet.getBytes() == [ 59 | # preamble size 60 | 0x00, 0x10, 61 | # postamble size 62 | 0x00, 0x00, 63 | # ACN packet identifier 64 | 0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00, 65 | # flags and length; again a mistake in the E1.31 spec, as this states '0x70, 0x30' 66 | # this would violate the parent spec E1.17 (ACN) section 2.4.2 67 | 0x70, 0x21, 68 | # Root vector 69 | 0x00, 0x00, 0x00, 0x08, 70 | # CID 71 | 0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e, 72 | # Framing flags and length; again propably a mistake as with the flags and length above 73 | 0x70, 0x0b, 74 | # Framing vector 75 | 0x00, 0x00, 0x00, 0x01, 76 | # sequence number 77 | 0x43, 78 | # sync address 79 | 0x1f, 0x1a, 80 | # reserved fields 81 | 0x00, 0x00, 82 | ] 83 | 84 | 85 | def test_parse_sync_packet(): 86 | # Use the example present in the E1.31 spec in appendix B 87 | raw_data = [ 88 | # preamble size 89 | 0x00, 0x10, 90 | # postamble size 91 | 0x00, 0x00, 92 | # ACN packet identifier 93 | 0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00, 94 | # flags and length; again a mistake in the E1.31 spec, as this states '0x70, 0x30' 95 | # this would violate the parent spec E1.17 (ACN) section 2.4.2 96 | 0x70, 0x21, 97 | # Root vector 98 | 0x00, 0x00, 0x00, 0x08, 99 | # CID 100 | 0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e, 101 | # Framing flags and length; again propably a mistake as with the flags and length above 102 | 0x70, 0x0b, 103 | # Framing vector 104 | 0x00, 0x00, 0x00, 0x01, 105 | # sequence number 106 | 0x43, 107 | # sync address 108 | 0x1f, 0x1a, 109 | # reserved fields 110 | 0x00, 0x00, 111 | ] 112 | packet = SyncPacket.make_sync_packet(raw_data) 113 | assert packet.length == 49 114 | assert packet.cid == (0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e) 115 | assert packet.sequence == 67 116 | assert packet.syncAddr == 7962 117 | 118 | # test for invalid data 119 | # test for too short data arrays 120 | for i in range(1, 47): 121 | with pytest.raises(TypeError): 122 | SyncPacket.make_sync_packet([x % 256 for x in range(0, i)]) 123 | # test for invalid vectors 124 | with pytest.raises(TypeError): 125 | SyncPacket.make_sync_packet([x % 256 for x in range(0, 47)]) 126 | 127 | 128 | def test_byte_construction_and_deconstruction(): 129 | built_packet = SyncPacket(tuple(range(0, 16)), 12, 127) 130 | read_packet = SyncPacket.make_sync_packet(built_packet.getBytes()) 131 | assert built_packet == read_packet 132 | -------------------------------------------------------------------------------- /sacn/messages/universe_discovery.py: -------------------------------------------------------------------------------- 1 | """ 2 | This class represents an universe discovery packet of the E1.31 Standard. 3 | """ 4 | from typing import List 5 | 6 | from sacn.messages.root_layer import \ 7 | VECTOR_ROOT_E131_EXTENDED, \ 8 | VECTOR_E131_EXTENDED_DISCOVERY, \ 9 | VECTOR_UNIVERSE_DISCOVERY_UNIVERSE_LIST, \ 10 | RootLayer, \ 11 | int_to_bytes, \ 12 | byte_tuple_to_int, \ 13 | make_flagsandlength 14 | 15 | 16 | class UniverseDiscoveryPacket(RootLayer): 17 | def __init__(self, cid: tuple, sourceName: str, universes: tuple, page: int = 0, lastPage: int = 0): 18 | self.sourceName: str = sourceName 19 | self.page: int = page 20 | self.lastPage: int = lastPage 21 | self.universes: tuple = universes 22 | super().__init__((len(universes) * 2) + 120, cid, VECTOR_ROOT_E131_EXTENDED) 23 | 24 | @property 25 | def sourceName(self) -> str: 26 | return self._sourceName 27 | 28 | @sourceName.setter 29 | def sourceName(self, sourceName: str): 30 | if type(sourceName) is not str: 31 | raise TypeError(f'sourceName must be a string! Type was {type(sourceName)}') 32 | tmp_sourceName_length = len(str(sourceName).encode('UTF-8')) 33 | if tmp_sourceName_length > 63: 34 | raise ValueError(f'sourceName must be less than 64 bytes when UTF-8 encoded! "{sourceName}" is {tmp_sourceName_length} bytes') 35 | self._sourceName = sourceName 36 | 37 | @property 38 | def page(self) -> int: 39 | return self._page 40 | 41 | @page.setter 42 | def page(self, page: int): 43 | if type(page) is not int: 44 | raise TypeError(f'Page must be an integer! Type was {type(page)}') 45 | if page not in range(0, 256): 46 | raise ValueError(f'Page is a byte! values: [0-255]! value was {page}') 47 | self._page = page 48 | 49 | @property 50 | def lastPage(self) -> int: 51 | return self._lastPage 52 | 53 | @lastPage.setter 54 | def lastPage(self, lastPage: int): 55 | if type(lastPage) is not int: 56 | raise TypeError(f'lastPage must be an integer! Type was {type(lastPage)}') 57 | if lastPage not in range(0, 256): 58 | raise ValueError(f'lastPage is a byte! values: [0-255]! value was {lastPage}') 59 | self._lastPage = lastPage 60 | 61 | @property 62 | def universes(self) -> tuple: 63 | return tuple(self._universes) 64 | 65 | @universes.setter 66 | def universes(self, universes: tuple): 67 | if len(universes) > 512 or \ 68 | not all((isinstance(x, int) and (0 <= x <= 63999)) for x in universes): 69 | raise ValueError(f'Universes is a tuple with a max length of 512! The data in the tuple has to be valid universe numbers! ' 70 | f'Length was {len(universes)}') 71 | self._universes = sorted(universes) 72 | self.length = 120 + (len(universes) * 2) # generate new length value for the packet 73 | 74 | def getBytes(self) -> list: 75 | rtrnList = super().getBytes() 76 | # Flags and Length Framing Layer:-------------------- 77 | rtrnList.extend(make_flagsandlength(self.length - 38)) 78 | # Vector Framing Layer:------------------------------ 79 | rtrnList.extend(VECTOR_E131_EXTENDED_DISCOVERY) 80 | # source Name Framing Layer:------------------------- 81 | # sourceName:--------------------------- 82 | # UTF-8 encode the string 83 | tmpSourceName = str(self._sourceName).encode('UTF-8') 84 | rtrnList.extend(tmpSourceName) 85 | # pad to 64 bytes 86 | rtrnList.extend([0] * (64 - len(tmpSourceName))) 87 | # reserved fields:----------------------------------- 88 | rtrnList.extend([0] * 4) 89 | # Universe Discovery Layer:------------------------------------- 90 | # Flags and Length:---------------------------------- 91 | rtrnList.extend(make_flagsandlength(self.length - 112)) 92 | # Vector UDL:---------------------------------------- 93 | rtrnList.extend(VECTOR_UNIVERSE_DISCOVERY_UNIVERSE_LIST) 94 | # page:---------------------------------------------- 95 | rtrnList.append(self._page & 0xFF) 96 | # last page:----------------------------------------- 97 | rtrnList.append(self._lastPage & 0xFF) 98 | # universes:----------------------------------------- 99 | for universe in self._universes: # universe is a 16-bit number! 100 | rtrnList.extend(int_to_bytes(universe)) 101 | 102 | return rtrnList 103 | 104 | @staticmethod 105 | def make_universe_discovery_packet(raw_data) -> 'UniverseDiscoveryPacket': 106 | # Check if the length is sufficient 107 | if len(raw_data) < 120: 108 | raise TypeError('The length of the provided data is not long enough! Min length is 120!') 109 | # Check if the three Vectors are correct 110 | # REMEMBER: when slicing: [inclusive:exclusive] 111 | if tuple(raw_data[18:22]) != tuple(VECTOR_ROOT_E131_EXTENDED) or \ 112 | tuple(raw_data[40:44]) != tuple(VECTOR_E131_EXTENDED_DISCOVERY) or \ 113 | tuple(raw_data[114:118]) != tuple(VECTOR_UNIVERSE_DISCOVERY_UNIVERSE_LIST): 114 | raise TypeError('Some of the vectors in the given raw data are not compatible to the E131 Standard!') 115 | 116 | # tricky part: convert plain bytes to a useful list of 16-bit values for further use 117 | # Problem: the given raw_byte can be longer than the dynamic length of the list of universes 118 | # first: extract the length from the Universe Discovery Layer (UDL) 119 | length = (byte_tuple_to_int((raw_data[112], raw_data[113])) & 0xFFF) - 8 120 | # remember: UDL has 8 bytes plus the universes 121 | # remember: Flags and length includes a 12-bit length field 122 | universes = convert_raw_data_to_universes(raw_data[120:120 + length]) 123 | tmpPacket = UniverseDiscoveryPacket(cid=tuple(raw_data[22:38]), sourceName=bytes(raw_data[44:108]).decode('utf-8').replace('\0', ''), 124 | universes=universes) 125 | tmpPacket._page = raw_data[118] 126 | tmpPacket._lastPage = raw_data[119] 127 | return tmpPacket 128 | 129 | @staticmethod 130 | def make_multiple_uni_disc_packets(cid: tuple, sourceName: str, universes: list) -> List['UniverseDiscoveryPacket']: 131 | """ 132 | Creates a list with universe discovery packets based on the given data. It creates automatically enough packets 133 | for the given universes list. 134 | :param cid: the cid to use in all packets 135 | :param sourceName: the source name to use in all packets 136 | :param universes: the universes. Can be longer than 512, but has to be shorter than 256*512. 137 | The values in the list should be [1-63999] 138 | :return: a list full of universe discovery packets 139 | """ 140 | tmpList = [] 141 | # divide len(universes) with 512 and round up; // is integer division 142 | num_of_packets = (len(universes) + 512 - 1) // 512 143 | universes.sort() # E1.31 wants that the send out universes are sorted 144 | for i in range(0, num_of_packets): 145 | if i == num_of_packets - 1: 146 | tmpUniverses = universes[i * 512:len(universes)] 147 | # if we are here, then the for is in the last loop 148 | else: 149 | tmpUniverses = universes[i * 512:(i + 1) * 512] 150 | # create new UniverseDiscoveryPacket and append it to the list. Page and lastPage are getting special values 151 | tmpList.append(UniverseDiscoveryPacket(cid=cid, sourceName=sourceName, universes=tmpUniverses, 152 | page=i, lastPage=num_of_packets - 1)) 153 | return tmpList 154 | 155 | 156 | def convert_raw_data_to_universes(raw_data) -> tuple: 157 | """ 158 | converts the raw data to a readable universes tuple. The raw_data is scanned from index 0 and has to have 159 | 16-bit numbers with high byte first. The data is converted from the start to the beginning! 160 | :param raw_data: the raw data to convert 161 | :return: tuple full with 16-bit numbers 162 | """ 163 | if len(raw_data) % 2 != 0: 164 | raise TypeError('The given data does not have an even number of elements!') 165 | rtrnList = [] 166 | for i in range(0, len(raw_data), 2): 167 | rtrnList.append(byte_tuple_to_int((raw_data[i], raw_data[i + 1]))) 168 | return tuple(rtrnList) 169 | -------------------------------------------------------------------------------- /sacn/messages/universe_discovery_test.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import pytest 4 | from sacn.messages.universe_discovery import UniverseDiscoveryPacket 5 | from sacn.messages.general_test import property_number_range_check 6 | 7 | 8 | def test_constructor(): 9 | # positive tests 10 | cid = tuple(range(0, 16)) 11 | sourceName = 'Test' 12 | universes = tuple(range(0, 512)) 13 | page = 0 14 | lastPage = 1 15 | packet = UniverseDiscoveryPacket(cid, sourceName, universes, page, lastPage) 16 | assert packet.length == 120 + (2 * len(universes)) 17 | assert packet.cid == cid 18 | assert packet.sourceName == sourceName 19 | assert packet.universes == universes 20 | assert packet.page == page 21 | assert packet.lastPage == lastPage 22 | # using wrong values for CID 23 | with pytest.raises(ValueError): 24 | UniverseDiscoveryPacket(tuple(range(0, 17)), sourceName, universes) 25 | 26 | 27 | def test_sourceName(): 28 | # test string has 25 characters but is 64 bytes (1 too many) when UTF-8 encoded 29 | overlength_string = "𔑑覱֪I𤵎⠣Ķ'𫳪爓Û:𢏴㓑ò4𰬀鿹џ>𖬲膬ЩJ𞄇" 30 | packet = UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', ()) 31 | # test property setter 32 | with pytest.raises(TypeError): 33 | packet.sourceName = 0x33 34 | with pytest.raises(ValueError): 35 | packet.sourceName = overlength_string 36 | # test constructor 37 | with pytest.raises(ValueError): 38 | packet = UniverseDiscoveryPacket(tuple(range(0, 16)), overlength_string, ()) 39 | 40 | 41 | def test_page(): 42 | packet = UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', ()) 43 | # test property setter 44 | def property(i): packet.page = i 45 | # test constructor for the same parameter 46 | def constructor(i): UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', (), i) 47 | property_number_range_check(0, 255, property, constructor) 48 | 49 | 50 | def test_last_page(): 51 | packet = UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', ()) 52 | # test property setter 53 | def property(i): packet.lastPage = i 54 | # test constructor for the same parameter 55 | def constructor(i): UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', (), 0, i) 56 | property_number_range_check(0, 255, property, constructor) 57 | 58 | 59 | def test_universes(): 60 | packet = UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', ()) 61 | 62 | def execute_universes_expect(universes: tuple): 63 | with pytest.raises(ValueError): 64 | packet.universes = universes 65 | with pytest.raises(ValueError): 66 | UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', universes) 67 | 68 | # test valid lengths 69 | for i in range(1, 513): 70 | universes = tuple(range(0, i)) 71 | # test property setter 72 | packet.universes = universes 73 | assert packet.length == 120 + (2 * len(universes)) 74 | # test constructor for the same parameter 75 | UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', universes) 76 | 77 | # test that the universes list is sorted 78 | packet.universes = (3, 1, 2) 79 | assert packet.universes == (1, 2, 3) 80 | 81 | # test for non-int and out of range values values in tuple 82 | execute_universes_expect(tuple('string')) 83 | execute_universes_expect(tuple(range(64000, 65000))) 84 | 85 | # test for tuple-length > 512 86 | execute_universes_expect(tuple(range(0, 513))) 87 | 88 | 89 | def test_make_multiple_uni_disc_packets(): 90 | # test with a list that spawns three packets 91 | universes = list(range(0, 1026)) 92 | packets = UniverseDiscoveryPacket.make_multiple_uni_disc_packets(tuple(range(0, 16)), 'Test', universes) 93 | assert len(packets) == 3 94 | assert packets[0].universes == tuple(range(0, 512)) 95 | assert packets[1].universes == tuple(range(512, 1024)) 96 | assert packets[2].universes == tuple(range(1024, 1026)) 97 | assert packets[0].page == 0 98 | assert packets[1].page == 1 99 | assert packets[2].page == 2 100 | assert packets[0].lastPage == 2 101 | assert packets[1].lastPage == 2 102 | assert packets[2].lastPage == 2 103 | # test with a list that spawns one packet 104 | universes = list(range(0, 2)) 105 | packets = UniverseDiscoveryPacket.make_multiple_uni_disc_packets(tuple(range(0, 16)), 'Test', universes) 106 | assert len(packets) == 1 107 | assert packets[0].universes == tuple(universes) 108 | assert packets[0].page == 0 109 | assert packets[0].lastPage == 0 110 | 111 | 112 | def test_get_bytes(): 113 | cid = (0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e) 114 | packet = UniverseDiscoveryPacket(cid, 'Source_A', (1, 2, 3), 0, 1) 115 | assert packet.getBytes() == [ 116 | # preamble size 117 | 0x00, 0x10, 118 | # postamble size 119 | 0x00, 0x00, 120 | # ACN packet identifier 121 | 0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00, 122 | # flags and length 123 | 0x70, 0x6e, 124 | # Root vector 125 | 0x00, 0x00, 0x00, 0x08, 126 | # CID 127 | 0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e, 128 | # Framing flags and length 129 | 0x70, 0x58, 130 | # Framing vector 131 | 0x00, 0x00, 0x00, 0x02, 132 | # Source name 'Source_A' 133 | 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x41, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 134 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 135 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 137 | # reserved 138 | 0x00, 0x00, 0x00, 0x00, 139 | # universe discovery layer - flags and length 140 | 0x70, 0x0e, 141 | # vector 142 | 0x00, 0x00, 0x00, 0x01, 143 | # page 144 | 0x00, 145 | # last page 146 | 0x01, 147 | # universes as 16-bit integers 148 | 0x00, 0x01, 0x00, 0x02, 0x00, 0x03, 149 | ] 150 | 151 | 152 | def test_parse_sync_packet(): 153 | raw_data = [ 154 | # preamble size 155 | 0x00, 0x10, 156 | # postamble size 157 | 0x00, 0x00, 158 | # ACN packet identifier 159 | 0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00, 160 | # flags and length 161 | 0x70, 0x6e, 162 | # Root vector 163 | 0x00, 0x00, 0x00, 0x08, 164 | # CID 165 | 0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e, 166 | # Framing flags and length 167 | 0x70, 0x58, 168 | # Framing vector 169 | 0x00, 0x00, 0x00, 0x02, 170 | # Source name 'Source_A' 171 | 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x41, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 172 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 173 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 174 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 175 | # reserved 176 | 0x00, 0x00, 0x00, 0x00, 177 | # universe discovery layer - flags and length 178 | 0x70, 0x0e, 179 | # vector 180 | 0x00, 0x00, 0x00, 0x01, 181 | # page 182 | 0x00, 183 | # last page 184 | 0x01, 185 | # universes as 16-bit integers 186 | 0x00, 0x01, 0x00, 0x02, 0x00, 0x03, 187 | ] 188 | packet = UniverseDiscoveryPacket.make_universe_discovery_packet(raw_data) 189 | assert packet.length == 126 190 | assert packet.cid == (0xef, 0x07, 0xc8, 0xdd, 0x00, 0x64, 0x44, 0x01, 0xa3, 0xa2, 0x45, 0x9e, 0xf8, 0xe6, 0x14, 0x3e) 191 | assert packet.sourceName == 'Source_A' 192 | assert packet.page == 0 193 | assert packet.lastPage == 1 194 | assert packet.universes == (1, 2, 3) 195 | 196 | # test for invalid data 197 | # test for too short data arrays 198 | for i in range(1, 120): 199 | with pytest.raises(TypeError): 200 | UniverseDiscoveryPacket.make_universe_discovery_packet([x % 256 for x in range(0, i)]) 201 | # test for invalid vectors 202 | with pytest.raises(TypeError): 203 | UniverseDiscoveryPacket.make_universe_discovery_packet([x % 256 for x in range(0, 126)]) 204 | # test for odd universes list length 205 | raw_data = raw_data[0:len(raw_data) - 1] 206 | with pytest.raises(TypeError): 207 | UniverseDiscoveryPacket.make_universe_discovery_packet(raw_data) 208 | 209 | 210 | def test_byte_construction_and_deconstruction(): 211 | built_packet = UniverseDiscoveryPacket(tuple(range(0, 16)), 'Test', tuple(range(0, 512)), 0, 1) 212 | read_packet = UniverseDiscoveryPacket.make_universe_discovery_packet(built_packet.getBytes()) 213 | assert built_packet == read_packet 214 | -------------------------------------------------------------------------------- /sacn/receiver.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | from sacn.messages.data_packet import DataPacket, calculate_multicast_addr 4 | from sacn.receiving.receiver_handler import ReceiverHandler, ReceiverHandlerListener 5 | from sacn.receiving.receiver_socket_base import ReceiverSocketBase 6 | from typing import Tuple 7 | 8 | LISTEN_ON_OPTIONS = ('availability', 'universe') 9 | 10 | 11 | class sACNreceiver(ReceiverHandlerListener): 12 | def __init__(self, bind_address: str = '0.0.0.0', bind_port: int = 5568, socket: ReceiverSocketBase = None): 13 | """ 14 | Make a receiver for sACN data. Do not forget to start and add callbacks for receiving messages! 15 | :param bind_address: if you are on a Windows system and want to use multicast provide a valid interface 16 | IP-Address! Otherwise omit. 17 | :param bind_port: Default: 5568. It is not recommended to change this value! 18 | Only use when you know what you are doing! 19 | :param socket: Provide a special socket implementation if necessary. Must be derived from ReceiverSocketBase, 20 | only use if the default socket implementation of this library is not sufficient. 21 | """ 22 | 23 | self._callbacks: dict = {} 24 | self._handler: ReceiverHandler = ReceiverHandler(bind_address, bind_port, self, socket) 25 | 26 | def on_availability_change(self, universe: int, changed: str) -> None: 27 | callbacks = [] 28 | # call nothing, if the list with callbacks is empty 29 | try: 30 | callbacks = self._callbacks[LISTEN_ON_OPTIONS[0]] 31 | except KeyError: 32 | pass 33 | for callback in callbacks: 34 | # fire callbacks if this is the first received packet for this universe 35 | callback(universe=universe, changed=changed) 36 | 37 | def on_dmx_data_change(self, packet: DataPacket) -> None: 38 | callbacks = [] 39 | # call nothing, if the list with callbacks is empty 40 | try: 41 | callbacks = self._callbacks[packet.universe] 42 | except KeyError: 43 | pass 44 | for callback in callbacks: 45 | callback(packet) 46 | 47 | def listen_on(self, trigger: str, **kwargs) -> callable: 48 | """ 49 | This is a simple decorator for registering a callback for an event. You can also use 'register_listener'. 50 | A list with all possible options is available via LISTEN_ON_OPTIONS. 51 | :param trigger: Currently supported options: 'availability', 'universe' 52 | """ 53 | def decorator(f): 54 | self.register_listener(trigger, f, **kwargs) 55 | return f 56 | return decorator 57 | 58 | def register_listener(self, trigger: str, func: callable, **kwargs) -> None: 59 | """ 60 | Register a listener for the given trigger. Raises an TypeError when the trigger is not a valid one. 61 | To get a list with all valid triggers, use LISTEN_ON_OPTIONS. 62 | :param trigger: the trigger on which the given callback should be used. 63 | Currently supported: 'availability', 'universe' 64 | :param func: the callback. The parameters depend on the trigger. See README for more information 65 | """ 66 | if trigger in LISTEN_ON_OPTIONS: 67 | if trigger == LISTEN_ON_OPTIONS[1]: # if the trigger is universe, use the universe from args as key 68 | universe = kwargs[LISTEN_ON_OPTIONS[1]] 69 | try: 70 | self._callbacks[universe].append(func) 71 | except KeyError: 72 | self._callbacks[universe] = [func] 73 | try: 74 | self._callbacks[trigger].append(func) 75 | except KeyError: 76 | self._callbacks[trigger] = [func] 77 | else: 78 | raise TypeError(f'The given trigger "{trigger}" is not a valid one!') 79 | 80 | def remove_listener(self, func: callable) -> None: 81 | """ 82 | Removes the given function from all listening options (see LISTEN_ON_OPTIONS). 83 | If the function never was registered, nothing happens. Note: if a function was registered multiple times, 84 | this remove function needs to be called only once. 85 | :param func: the callback 86 | """ 87 | for _trigger, listeners in self._callbacks.items(): 88 | while True: 89 | try: 90 | listeners.remove(func) 91 | except ValueError: 92 | break 93 | 94 | def remove_listener_from_universe(self, universe: int) -> None: 95 | """ 96 | Removes all listeners from the given universe. This does only have effect on the 'universe' listening trigger. 97 | If no function was registered for this universe, nothing happens. 98 | :param universe: the universe to clear 99 | """ 100 | self._callbacks.pop(universe, None) 101 | 102 | def join_multicast(self, universe: int) -> None: 103 | """ 104 | Joins the multicast address that is used for the given universe. Note: If you are on Windows you must have given 105 | a bind IP-Address for this feature to function properly. On the other hand you are not allowed to set a bind 106 | address if you are on any other OS. 107 | :param universe: the universe to join the multicast group. 108 | The network hardware has to support the multicast feature! 109 | """ 110 | self._handler.socket.join_multicast(calculate_multicast_addr(universe)) 111 | 112 | def leave_multicast(self, universe: int) -> None: 113 | """ 114 | Try to leave the multicast group with the specified universe. This does not throw any exception if the group 115 | could not be leaved. 116 | :param universe: the universe to leave the multicast group. 117 | The network hardware has to support the multicast feature! 118 | """ 119 | self._handler.socket.leave_multicast(calculate_multicast_addr(universe)) 120 | 121 | def start(self) -> None: 122 | """ 123 | Starts a new thread that handles the input. If a thread is already running, the thread will be restarted. 124 | """ 125 | self.stop() # stop an existing thread 126 | self._handler.socket.start() 127 | 128 | def stop(self) -> None: 129 | """ 130 | Stops a running thread and closes the underlying socket. If no thread was started, nothing happens. 131 | Do not reuse the socket after calling stop once. 132 | """ 133 | self._handler.socket.stop() 134 | 135 | def get_possible_universes(self) -> Tuple[int]: 136 | """ 137 | Get all universes that are possible because a data packet was received. Timeouted data is removed from the list, 138 | so the list may change over time. Depending on sources that are shutting down their streams. 139 | :return: a tuple with all universes that were received so far and hadn't a timeout 140 | """ 141 | return tuple(self._handler.get_possible_universes()) 142 | 143 | def __del__(self): 144 | # stop a potential running thread 145 | self.stop() 146 | -------------------------------------------------------------------------------- /sacn/receiver_test.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import pytest 4 | import sacn 5 | from sacn.messages.data_packet import DataPacket 6 | from sacn.receiving.receiver_socket_test import ReceiverSocketTest 7 | 8 | 9 | def get_receiver(): 10 | socket = ReceiverSocketTest() 11 | receiver = sacn.sACNreceiver(socket=socket) 12 | # wire up for unit test 13 | receiver._handler.socket._listener = receiver._handler 14 | return receiver, socket 15 | 16 | 17 | def test_constructor(): 18 | receiver, _ = get_receiver() 19 | assert receiver._callbacks is not None 20 | assert receiver._handler is not None 21 | 22 | 23 | def test_listen_on_availability_change(): 24 | receiver, socket = get_receiver() 25 | 26 | called = False 27 | 28 | @receiver.listen_on('availability') 29 | def callback_available(universe, changed): 30 | assert changed == 'available' 31 | assert universe == 1 32 | nonlocal called 33 | called = True 34 | 35 | packet = DataPacket( 36 | cid=tuple(range(0, 16)), 37 | sourceName='Test', 38 | universe=1, 39 | dmxData=tuple(range(0, 16)) 40 | ) 41 | socket.call_on_data(bytes(packet.getBytes()), 0) 42 | assert called 43 | 44 | 45 | def test_listen_on_dmx_data_change(): 46 | receiver, socket = get_receiver() 47 | 48 | packetSend = DataPacket( 49 | cid=tuple(range(0, 16)), 50 | sourceName='Test', 51 | universe=1, 52 | dmxData=tuple(range(0, 16)) 53 | ) 54 | 55 | called = False 56 | 57 | @receiver.listen_on('universe', universe=packetSend.universe) 58 | def callback_packet(packet): 59 | assert packetSend.__dict__ == packet.__dict__ 60 | nonlocal called 61 | called = True 62 | 63 | socket.call_on_data(bytes(packetSend.getBytes()), 0) 64 | assert called 65 | 66 | 67 | def test_remove_listener(): 68 | receiver, socket = get_receiver() 69 | 70 | packetSend = DataPacket( 71 | cid=tuple(range(0, 16)), 72 | sourceName='Test', 73 | universe=1, 74 | dmxData=tuple(range(0, 16)) 75 | ) 76 | 77 | called = 0 78 | 79 | def callback_packet(packet): 80 | assert packetSend.__dict__ == packet.__dict__ 81 | nonlocal called 82 | called += 1 83 | 84 | # register listener multiple times 85 | receiver.register_listener('universe', callback_packet, universe=packetSend.universe) 86 | receiver.register_listener('universe', callback_packet, universe=packetSend.universe) 87 | 88 | socket.call_on_data(bytes(packetSend.getBytes()), 0) 89 | assert called == 2 90 | 91 | # change DMX data to trigger a change 92 | packetSend.dmxData = tuple(range(16, 32)) 93 | packetSend.sequence_increase() 94 | 95 | receiver.remove_listener(callback_packet) 96 | 97 | # removing a listener does not exist, should do nothing 98 | receiver.remove_listener(None) 99 | 100 | socket.call_on_data(bytes(packetSend.getBytes()), 0) 101 | assert called == 2 102 | 103 | 104 | def test_remove_listener_from_universe(): 105 | receiver, socket = get_receiver() 106 | 107 | test_universe_one = 1 108 | test_universe_two = 2 109 | 110 | packet_send = DataPacket( 111 | cid=tuple(range(0, 16)), 112 | sourceName='Test', 113 | universe=test_universe_one, 114 | dmxData=tuple(range(0, 16)) 115 | ) 116 | 117 | called = 0 118 | 119 | def callback_packet(packet): 120 | assert packet_send.__dict__ == packet.__dict__ 121 | nonlocal called 122 | called += 1 123 | 124 | # register listener multiple times 125 | receiver.register_listener('universe', callback_packet, universe=test_universe_one) 126 | receiver.register_listener('universe', callback_packet, universe=test_universe_two) 127 | 128 | packet_send.universe = test_universe_one 129 | socket.call_on_data(bytes(packet_send.getBytes()), 0) 130 | assert called == 1 131 | packet_send.universe = test_universe_two 132 | socket.call_on_data(bytes(packet_send.getBytes()), 0) 133 | assert called == 2 134 | 135 | # change DMX data to trigger a change 136 | packet_send.dmxData = tuple(range(16, 32)) 137 | packet_send.sequence_increase() 138 | 139 | test_universe_removed = test_universe_one 140 | receiver.remove_listener_from_universe(test_universe_removed) 141 | 142 | # removing from a universe that does not exist, should do nothing 143 | receiver.remove_listener_from_universe(12345) 144 | 145 | # call to the removed universe should not happen 146 | packet_send.universe = test_universe_removed 147 | socket.call_on_data(bytes(packet_send.getBytes()), 0) 148 | assert called == 2 149 | # other universes should not be affected 150 | packet_send.universe = test_universe_two 151 | socket.call_on_data(bytes(packet_send.getBytes()), 0) 152 | assert called == 3 153 | 154 | 155 | def test_invalid_listener(): 156 | receiver, socket = get_receiver() 157 | 158 | with pytest.raises(TypeError): 159 | @receiver.listen_on('test') 160 | def callback(): 161 | pass 162 | 163 | 164 | def test_possible_universes(): 165 | receiver, socket = get_receiver() 166 | 167 | assert receiver.get_possible_universes() == () 168 | packet = DataPacket( 169 | cid=tuple(range(0, 16)), 170 | sourceName='Test', 171 | universe=1, 172 | dmxData=tuple(range(0, 16)) 173 | ) 174 | socket.call_on_data(bytes(packet.getBytes()), 0) 175 | assert receiver.get_possible_universes() == tuple([1]) 176 | 177 | 178 | def test_join_multicast(): 179 | receiver, socket = get_receiver() 180 | 181 | assert socket.join_multicast_called is None 182 | receiver.join_multicast(1) 183 | assert socket.join_multicast_called == '239.255.0.1' 184 | 185 | with pytest.raises(TypeError): 186 | receiver.join_multicast('test') 187 | 188 | 189 | def test_leave_multicast(): 190 | receiver, socket = get_receiver() 191 | 192 | assert socket.leave_multicast_called is None 193 | receiver.leave_multicast(1) 194 | assert socket.leave_multicast_called == '239.255.0.1' 195 | 196 | with pytest.raises(TypeError): 197 | receiver.leave_multicast('test') 198 | 199 | 200 | def test_start(): 201 | receiver, socket = get_receiver() 202 | 203 | assert socket.start_called is False 204 | receiver.start() 205 | assert socket.start_called is True 206 | 207 | 208 | def test_stop(): 209 | receiver, socket = get_receiver() 210 | 211 | assert socket.stop_called is False 212 | receiver.stop() 213 | assert socket.stop_called is True 214 | 215 | 216 | def test_stop_destructor(): 217 | receiver, socket = get_receiver() 218 | 219 | assert socket.stop_called is False 220 | receiver.__del__() 221 | assert socket.stop_called is True 222 | -------------------------------------------------------------------------------- /sacn/receiving/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hundemeier/sacn/d5fd886c2bbd57c945f9c8ffa0588c327859dd3a/sacn/receiving/__init__.py -------------------------------------------------------------------------------- /sacn/receiving/receiver_handler.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | from typing import Dict, List 4 | 5 | from sacn.messages.data_packet import DataPacket 6 | from sacn.receiving.receiver_socket_base import ReceiverSocketBase, ReceiverSocketListener 7 | from sacn.receiving.receiver_socket_udp import ReceiverSocketUDP 8 | 9 | E131_NETWORK_DATA_LOSS_TIMEOUT_ms = 2500 10 | 11 | 12 | class ReceiverHandlerListener: 13 | """ 14 | Listener interface that defines methods for listening on changes on the ReceiverHandler. 15 | """ 16 | 17 | def on_availability_change(self, universe: int, changed: str) -> None: 18 | raise NotImplementedError 19 | 20 | def on_dmx_data_change(self, packet: DataPacket) -> None: 21 | raise NotImplementedError 22 | 23 | 24 | class ReceiverHandler(ReceiverSocketListener): 25 | def __init__(self, bind_address: str, bind_port: int, listener: ReceiverHandlerListener, socket: ReceiverSocketBase = None): 26 | """ 27 | This is a private class and should not be used elsewhere. It handles the receiver state with sACN specific values. 28 | Calls any changes in the data streams on the listener. 29 | Uses a UDP receiver socket with the given bind-address and -port, if the socket was not provided (i.e. None). 30 | """ 31 | if socket is None: 32 | self.socket: ReceiverSocketBase = ReceiverSocketUDP(self, bind_address, bind_port) 33 | else: 34 | self.socket: ReceiverSocketBase = socket 35 | self._listener: ReceiverHandlerListener = listener 36 | # previousData for storing the last data that was send in a universe to check if the data has changed 37 | self._previousData: Dict[int, tuple] = {} 38 | # priorities are stored here. This is for checking if the incoming data has the best priority. 39 | # universes are the keys and 40 | # the value is a tuple with the last priority and the time when this priority recently was received 41 | self._priorities: Dict[int, tuple] = {} 42 | # store the last timestamp when something on an universe arrived for checking for timeouts 43 | self._lastDataTimestamps: Dict[int, float] = {} 44 | # store the last sequence number of a universe here: 45 | self._lastSequence: Dict[int, int] = {} 46 | 47 | def on_data(self, data: bytes, current_time: float) -> None: 48 | try: 49 | tmp_packet = DataPacket.make_data_packet(data) 50 | except TypeError: # try to make a DataPacket. If it fails just ignore it 51 | return 52 | 53 | self.check_for_stream_terminated_and_refresh_timestamp(tmp_packet, current_time) 54 | self.refresh_priorities(tmp_packet, current_time) 55 | if not self.is_legal_priority(tmp_packet): 56 | return 57 | if not self.is_legal_sequence(tmp_packet): # check for bad sequence number 58 | return 59 | self.fire_callbacks_universe(tmp_packet) 60 | 61 | def on_periodic_callback(self, current_time: float) -> None: 62 | # check all DataTimestamps for timeouts 63 | for key, value in list(self._lastDataTimestamps.items()): 64 | # this is converted to list, because the length of the dict changes 65 | if check_timeout(current_time, value): 66 | self.fire_timeout_callback_and_delete(key) 67 | 68 | def check_for_stream_terminated_and_refresh_timestamp(self, packet: DataPacket, current_time: float) -> None: 69 | # refresh the last timestamp on a universe, but check if its the last message of a stream 70 | # (the stream is terminated by the Stream termination bit) 71 | if packet.option_StreamTerminated: 72 | self.fire_timeout_callback_and_delete(packet.universe) 73 | else: 74 | # check if we add or refresh the data in lastDataTimestamps 75 | if packet.universe not in self._lastDataTimestamps.keys(): 76 | # fire callbacks if this is the first received packet for this universe 77 | self._listener.on_availability_change(universe=packet.universe, changed='available') 78 | self._lastDataTimestamps[packet.universe] = current_time 79 | 80 | def fire_timeout_callback_and_delete(self, universe: int): 81 | self._listener.on_availability_change(universe=universe, changed='timeout') 82 | # delete the timestamp so that the callback is not fired multiple times 83 | try: 84 | del self._lastDataTimestamps[universe] 85 | except KeyError: 86 | pass # drop exception, if there was no last timestamp 87 | # delete sequence entries so that no packet out of order problems occur 88 | try: 89 | del self._lastSequence[universe] 90 | except KeyError: 91 | pass # drop exception, if there was no last sequence number 92 | 93 | def refresh_priorities(self, packet: DataPacket, current_time: float) -> None: 94 | # check the priority and refresh the priorities dict 95 | # check if the stored priority has timeouted and make the current packets priority the new one 96 | if packet.universe not in self._priorities.keys() or \ 97 | self._priorities[packet.universe] is None or \ 98 | check_timeout(current_time, self._priorities[packet.universe][1]) or \ 99 | self._priorities[packet.universe][0] <= packet.priority: # if the send priority is higher or 100 | # equal than the stored one, than make the priority the new one 101 | self._priorities[packet.universe] = (packet.priority, current_time) 102 | 103 | def is_legal_sequence(self, packet: DataPacket) -> bool: 104 | """ 105 | Check if the Sequence number of the DataPacket is legal. 106 | For more information see page 17 of http://tsp.esta.org/tsp/documents/docs/E1-31-2016.pdf. 107 | :param packet: the packet to check 108 | :return: true if the sequence is legal. False if the sequence number is bad 109 | """ 110 | # if the sequence of the packet is smaller than the last received sequence, return false 111 | # therefore calculate the difference between the two values: 112 | try: # try, because self.lastSequence might not been initialized 113 | diff = packet.sequence - self._lastSequence[packet.universe] 114 | # if diff is between ]-20,0], return False for a bad packet sequence 115 | if diff <= 0 and diff > -20: 116 | return False 117 | except KeyError: 118 | pass 119 | # if the sequence is good, return True and refresh the list with the new value 120 | self._lastSequence[packet.universe] = packet.sequence 121 | return True 122 | 123 | def is_legal_priority(self, packet: DataPacket): 124 | """ 125 | Check if the given packet has high enough priority for the stored values for the packet's universe. 126 | :param packet: the packet to check 127 | :return: returns True if the priority is good. Otherwise False 128 | """ 129 | # check if the packet's priority is high enough to get processed 130 | if packet.priority < self._priorities[packet.universe][0]: 131 | return False # return if the universe is not interesting 132 | else: 133 | return True 134 | 135 | def fire_callbacks_universe(self, packet: DataPacket) -> None: 136 | # call the listeners for the universe but before check if the data has changed 137 | # check if there are listeners for the universe before proceeding 138 | if packet.universe not in self._previousData.keys() or \ 139 | self._previousData[packet.universe] is None or \ 140 | self._previousData[packet.universe] != packet.dmxData: 141 | # set previous data and inherit callbacks 142 | self._previousData[packet.universe] = packet.dmxData 143 | self._listener.on_dmx_data_change(packet) 144 | 145 | def get_possible_universes(self) -> List[int]: 146 | return list(self._lastDataTimestamps.keys()) 147 | 148 | 149 | def time_millis(current_time: float) -> int: 150 | return int(round(current_time * 1000)) 151 | 152 | 153 | def check_timeout(current_time: float, time: float) -> bool: 154 | return abs(time_millis(current_time) - time_millis(time)) > E131_NETWORK_DATA_LOSS_TIMEOUT_ms 155 | -------------------------------------------------------------------------------- /sacn/receiving/receiver_handler_test.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import pytest 4 | from sacn.messages.data_packet import DataPacket 5 | from sacn.receiving.receiver_handler import ReceiverHandler, ReceiverHandlerListener, E131_NETWORK_DATA_LOSS_TIMEOUT_ms 6 | from sacn.receiving.receiver_socket_test import ReceiverSocketTest 7 | 8 | 9 | class ReceiverHandlerListenerTest(ReceiverHandlerListener): 10 | def __init__(self): 11 | self.on_availability_change_universe: int = None 12 | self.on_availability_change_changed: str = None 13 | self.on_dmx_data_change_packet: DataPacket = None 14 | 15 | def on_availability_change(self, universe: int, changed: str) -> None: 16 | self.on_availability_change_universe = universe 17 | self.on_availability_change_changed = changed 18 | 19 | def on_dmx_data_change(self, packet: DataPacket) -> None: 20 | self.on_dmx_data_change_packet = packet 21 | 22 | 23 | def get_handler(): 24 | bind_address = 'Test' 25 | bind_port = 1234 26 | listener = ReceiverHandlerListenerTest() 27 | socket = ReceiverSocketTest() 28 | handler = ReceiverHandler(bind_address, bind_port, listener, socket) 29 | # wire up for unit test 30 | socket._listener = handler 31 | return handler, listener, socket 32 | 33 | 34 | def test_constructor(): 35 | handler, _, _ = get_handler() 36 | assert handler._listener is not None 37 | assert handler._previousData is not None 38 | assert handler._priorities is not None 39 | assert handler._lastDataTimestamps is not None 40 | assert handler._lastSequence is not None 41 | 42 | 43 | def test_first_packet(): 44 | _, listener, socket = get_handler() 45 | assert listener.on_availability_change_changed is None 46 | assert listener.on_availability_change_universe is None 47 | assert listener.on_dmx_data_change_packet is None 48 | packet = DataPacket( 49 | cid=tuple(range(0, 16)), 50 | sourceName='Test', 51 | universe=1, 52 | dmxData=tuple(range(0, 16)) 53 | ) 54 | socket.call_on_data(bytes(packet.getBytes()), 0) 55 | assert listener.on_availability_change_changed == 'available' 56 | assert listener.on_availability_change_universe == 1 57 | assert listener.on_dmx_data_change_packet.__dict__ == packet.__dict__ 58 | 59 | 60 | def test_first_packet_stream_terminated(): 61 | _, listener, socket = get_handler() 62 | assert listener.on_availability_change_changed is None 63 | assert listener.on_availability_change_universe is None 64 | assert listener.on_dmx_data_change_packet is None 65 | packet = DataPacket( 66 | cid=tuple(range(0, 16)), 67 | sourceName='Test', 68 | universe=1, 69 | dmxData=tuple(range(0, 16)), 70 | streamTerminated=True 71 | ) 72 | socket.call_on_data(bytes(packet.getBytes()), 0) 73 | assert listener.on_availability_change_changed == 'timeout' 74 | assert listener.on_availability_change_universe == 1 75 | assert listener.on_dmx_data_change_packet.__dict__ == packet.__dict__ 76 | 77 | 78 | def test_invalid_packet_bytes(): 79 | _, listener, socket = get_handler() 80 | assert listener.on_availability_change_changed is None 81 | assert listener.on_availability_change_universe is None 82 | assert listener.on_dmx_data_change_packet is None 83 | # provide 'random' data that is no DataPacket 84 | socket.call_on_data(bytes(x % 256 for x in range(0, 512)), 0) 85 | assert listener.on_availability_change_changed is None 86 | assert listener.on_availability_change_universe is None 87 | assert listener.on_dmx_data_change_packet is None 88 | 89 | 90 | def test_invalid_priority(): 91 | # send a lower priority on a second packet 92 | _, listener, socket = get_handler() 93 | assert listener.on_dmx_data_change_packet is None 94 | packet1 = DataPacket( 95 | cid=tuple(range(0, 16)), 96 | sourceName='Test', 97 | universe=1, 98 | dmxData=tuple(range(0, 16)), 99 | priority=100 100 | ) 101 | socket.call_on_data(bytes(packet1.getBytes()), 0) 102 | assert listener.on_dmx_data_change_packet.__dict__ == packet1.__dict__ 103 | packet2 = DataPacket( 104 | cid=tuple(range(0, 16)), 105 | sourceName='Test', 106 | universe=1, 107 | dmxData=tuple(range(0, 16)), 108 | priority=99 109 | ) 110 | socket.call_on_data(bytes(packet2.getBytes()), 1) 111 | # second packet does not override the previous one 112 | assert listener.on_dmx_data_change_packet.__dict__ == packet1.__dict__ 113 | 114 | 115 | def test_invalid_sequence(): 116 | # send a lower sequence on a second packet 117 | def case_goes_through(sequence_a: int, sequence_b: int): 118 | _, listener, socket = get_handler() 119 | assert listener.on_dmx_data_change_packet is None 120 | packet1 = DataPacket( 121 | cid=tuple(range(0, 16)), 122 | sourceName='Test', 123 | universe=1, 124 | dmxData=tuple(range(0, 16)), 125 | sequence=sequence_a 126 | ) 127 | socket.call_on_data(bytes(packet1.getBytes()), 0) 128 | assert listener.on_dmx_data_change_packet.__dict__ == packet1.__dict__ 129 | packet2 = DataPacket( 130 | cid=tuple(range(0, 16)), 131 | sourceName='Test', 132 | universe=1, 133 | # change DMX data to simulate data from another source 134 | dmxData=tuple(range(1, 17)), 135 | sequence=sequence_b 136 | ) 137 | socket.call_on_data(bytes(packet2.getBytes()), 1) 138 | assert listener.on_dmx_data_change_packet.__dict__ == packet2.__dict__ 139 | 140 | case_goes_through(100, 80) 141 | case_goes_through(101, 102) 142 | case_goes_through(255, 0) 143 | case_goes_through(0, 236) 144 | with pytest.raises(AssertionError): 145 | case_goes_through(100, 81) 146 | with pytest.raises(AssertionError): 147 | case_goes_through(100, 99) 148 | # Note: this should probably also fail, but the algorithm from the E1.31 spec does not 149 | # work with wrap around 255... 150 | case_goes_through(0, 255) 151 | 152 | 153 | def test_possible_universes(): 154 | handler, _, socket = get_handler() 155 | assert handler.get_possible_universes() == [] 156 | packet = DataPacket( 157 | cid=tuple(range(0, 16)), 158 | sourceName='Test', 159 | universe=1, 160 | dmxData=tuple(range(0, 16)) 161 | ) 162 | # add universe 1 163 | socket.call_on_data(bytes(packet.getBytes()), 0) 164 | assert handler.get_possible_universes() == [1] 165 | # add universe 2 166 | packet.universe = 2 167 | socket.call_on_data(bytes(packet.getBytes()), 0) 168 | assert handler.get_possible_universes() == [1, 2] 169 | # remove universe 2 170 | packet.option_StreamTerminated = True 171 | socket.call_on_data(bytes(packet.getBytes()), 0) 172 | assert handler.get_possible_universes() == [1] 173 | 174 | 175 | def test_universe_timeout(): 176 | _, listener, socket = get_handler() 177 | assert listener.on_availability_change_changed is None 178 | assert listener.on_availability_change_universe is None 179 | packet = DataPacket( 180 | cid=tuple(range(0, 16)), 181 | sourceName='Test', 182 | universe=1, 183 | dmxData=tuple(range(0, 16)) 184 | ) 185 | socket.call_on_data(bytes(packet.getBytes()), 0) 186 | socket.call_on_periodic_callback(0) 187 | assert listener.on_availability_change_changed == 'available' 188 | assert listener.on_availability_change_universe == 1 189 | # wait the specified amount of time and check, that a timeout was triggered 190 | # add 10ms of grace time 191 | socket.call_on_periodic_callback((E131_NETWORK_DATA_LOSS_TIMEOUT_ms / 1000) + 0.01) 192 | assert listener.on_availability_change_changed == 'timeout' 193 | assert listener.on_availability_change_universe == 1 194 | 195 | 196 | def test_universe_stream_terminated(): 197 | _, listener, socket = get_handler() 198 | assert listener.on_availability_change_changed is None 199 | assert listener.on_availability_change_universe is None 200 | packet = DataPacket( 201 | cid=tuple(range(0, 16)), 202 | sourceName='Test', 203 | universe=1, 204 | dmxData=tuple(range(0, 16)) 205 | ) 206 | socket.call_on_data(bytes(packet.getBytes()), 0) 207 | assert listener.on_availability_change_changed == 'available' 208 | assert listener.on_availability_change_universe == 1 209 | packet.sequence_increase() 210 | packet.option_StreamTerminated = True 211 | socket.call_on_data(bytes(packet.getBytes()), 0) 212 | assert listener.on_availability_change_changed == 'timeout' 213 | assert listener.on_availability_change_universe == 1 214 | 215 | 216 | def test_abstract_receiver_handler_listener(): 217 | listener = ReceiverHandlerListener() 218 | with pytest.raises(NotImplementedError): 219 | listener.on_availability_change(1, 'test') 220 | with pytest.raises(NotImplementedError): 221 | listener.on_dmx_data_change(DataPacket( 222 | cid=tuple(range(0, 16)), 223 | sourceName='Test', 224 | universe=1 225 | )) 226 | -------------------------------------------------------------------------------- /sacn/receiving/receiver_socket_base.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import logging 4 | 5 | 6 | class ReceiverSocketListener: 7 | """ 8 | Base class for listener of a ReceiverSocketBase. 9 | """ 10 | 11 | def on_data(self, data: bytes, current_time: float) -> None: 12 | raise NotImplementedError 13 | 14 | def on_periodic_callback(self, current_time: float) -> None: 15 | raise NotImplementedError 16 | 17 | 18 | class ReceiverSocketBase: 19 | """ 20 | Base class for abstracting a UDP receiver socket. 21 | """ 22 | 23 | def __init__(self, listener: ReceiverSocketListener): 24 | self._logger: logging.Logger = logging.getLogger('sacn') 25 | self._listener: ReceiverSocketListener = listener 26 | 27 | def start(self) -> None: 28 | raise NotImplementedError 29 | 30 | def stop(self) -> None: 31 | raise NotImplementedError 32 | 33 | def join_multicast(self, multicast_addr: str) -> None: 34 | raise NotImplementedError 35 | 36 | def leave_multicast(self, multicast_addr: str) -> None: 37 | raise NotImplementedError 38 | -------------------------------------------------------------------------------- /sacn/receiving/receiver_socket_base_test.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import pytest 4 | from sacn.receiving.receiver_socket_base import ReceiverSocketBase, ReceiverSocketListener 5 | 6 | 7 | def test_abstract_receiver_socket_listener(): 8 | listener = ReceiverSocketListener() 9 | with pytest.raises(NotImplementedError): 10 | listener.on_data([], 0) 11 | with pytest.raises(NotImplementedError): 12 | listener.on_periodic_callback(0) 13 | 14 | 15 | def test_abstract_receiver_socket_base(): 16 | socket = ReceiverSocketBase(None) 17 | with pytest.raises(NotImplementedError): 18 | socket.start() 19 | with pytest.raises(NotImplementedError): 20 | socket.stop() 21 | with pytest.raises(NotImplementedError): 22 | socket.join_multicast('test') 23 | with pytest.raises(NotImplementedError): 24 | socket.leave_multicast('test') 25 | -------------------------------------------------------------------------------- /sacn/receiving/receiver_socket_test.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | 4 | from sacn.receiving.receiver_socket_base import ReceiverSocketBase 5 | 6 | 7 | class ReceiverSocketTest(ReceiverSocketBase): 8 | def __init__(self, listener=None): 9 | super().__init__(listener) 10 | self.start_called: bool = False 11 | self.stop_called: bool = False 12 | self.join_multicast_called: str = None 13 | self.leave_multicast_called: str = None 14 | 15 | def start(self) -> None: 16 | self.start_called = True 17 | 18 | def stop(self) -> None: 19 | self.stop_called = True 20 | 21 | def join_multicast(self, multicast_addr: str) -> None: 22 | self.join_multicast_called = multicast_addr 23 | 24 | def leave_multicast(self, multicast_addr: str) -> None: 25 | self.leave_multicast_called = multicast_addr 26 | 27 | def call_on_data(self, data: bytes, current_time: float) -> None: 28 | self._listener.on_data(data, current_time) 29 | 30 | def call_on_periodic_callback(self, current_time: float) -> None: 31 | self._listener.on_periodic_callback(current_time) 32 | -------------------------------------------------------------------------------- /sacn/receiving/receiver_socket_udp.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import socket 4 | import threading 5 | import time 6 | import platform 7 | from sacn.receiving.receiver_socket_base import ReceiverSocketBase, ReceiverSocketListener 8 | 9 | THREAD_NAME = 'sACN input/receiver thread' 10 | 11 | 12 | class ReceiverSocketUDP(ReceiverSocketBase): 13 | """ 14 | Implements a receiver socket with a UDP socket of the OS. 15 | """ 16 | 17 | def __init__(self, listener: ReceiverSocketListener, bind_address: str, bind_port: int): 18 | super().__init__(listener=listener) 19 | 20 | self._bind_address: str = bind_address 21 | self._bind_port: int = bind_port 22 | self._enabled_flag: bool = True 23 | 24 | # initialize the UDP socket 25 | self._socket: socket.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 26 | try: 27 | self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 28 | except socket.error: # Not all systems support multiple sockets on the same port and interface 29 | pass 30 | os_name = platform.system() 31 | if os_name == "Linux": 32 | self._socket.bind(("", self._bind_port)) 33 | else: 34 | self._socket.bind((self._bind_address, self._bind_port)) 35 | self._logger.info(f'Bind receiver socket to IP: {self._bind_address} port: {self._bind_port}') 36 | 37 | def start(self): 38 | # initialize thread infos 39 | self._thread = threading.Thread( 40 | target=self.receive_loop, 41 | name=THREAD_NAME 42 | ) 43 | # self._thread.setDaemon(True) # TODO: might be beneficial to use a daemon thread 44 | self._thread.start() 45 | 46 | def receive_loop(self) -> None: 47 | """ 48 | Implements the run method inherited by threading.Thread 49 | """ 50 | self._logger.info(f'Started {THREAD_NAME}') 51 | self._socket.settimeout(0.1) # timeout as 100ms 52 | self._enabled_flag = True 53 | while self._enabled_flag: 54 | # before receiving: invoke periodic callback 55 | self._listener.on_periodic_callback(time.time()) 56 | # receive the data 57 | try: 58 | raw_data = list(self._socket.recv(2048)) # greater than 1144 because the longest possible packet 59 | # in the sACN standard is the universe discovery packet with a max length of 1144 60 | except socket.timeout: 61 | continue # if a timeout happens just go through while from the beginning 62 | self._listener.on_data(raw_data, time.time()) 63 | 64 | self._logger.info(f'Stopped {THREAD_NAME}') 65 | 66 | def stop(self) -> None: 67 | """ 68 | Stops a running thread and closes the underlying socket. If no thread was started, nothing happens. 69 | Do not reuse the socket after calling stop once. 70 | """ 71 | self._enabled_flag = False 72 | try: 73 | self._thread.join() 74 | # stop the socket, after the loop terminated 75 | self._socket.close() 76 | except AttributeError: 77 | pass 78 | 79 | def join_multicast(self, multicast_addr: str) -> None: 80 | """ 81 | Join a specific multicast address by string. Only IPv4. 82 | """ 83 | # Windows: https://learn.microsoft.com/en-us/windows/win32/winsock/ipproto-ip-socket-options 84 | # Linux: https://man7.org/linux/man-pages/man7/ip.7.html 85 | self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, 86 | socket.inet_aton(multicast_addr) + 87 | socket.inet_aton(self._bind_address)) 88 | 89 | def leave_multicast(self, multicast_addr: str) -> None: 90 | """ 91 | Leave a specific multicast address by string. Only IPv4. 92 | """ 93 | # Windows: https://learn.microsoft.com/en-us/windows/win32/winsock/ipproto-ip-socket-options 94 | # Linux: https://man7.org/linux/man-pages/man7/ip.7.html 95 | try: 96 | self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP, 97 | socket.inet_aton(multicast_addr) + 98 | socket.inet_aton(self._bind_address)) 99 | except socket.error: # try to leave the multicast group for the universe 100 | pass 101 | -------------------------------------------------------------------------------- /sacn/sender.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | """ 4 | This is a server for sending out sACN and receiving sACN data. 5 | http://tsp.esta.org/tsp/documents/docs/E1-31-2016.pdf 6 | """ 7 | 8 | import random 9 | import time 10 | from typing import Dict, List, Optional 11 | 12 | from sacn.messages.data_packet import DataPacket 13 | from sacn.sending.output import Output 14 | from sacn.sending.sender_socket_base import SenderSocketBase, DEFAULT_PORT 15 | from sacn.sending.sender_handler import SenderHandler 16 | 17 | 18 | class sACNsender: 19 | def __init__(self, bind_address: str = '0.0.0.0', bind_port: int = DEFAULT_PORT, 20 | source_name: str = 'default source name', cid: tuple = (), 21 | fps: int = 30, universeDiscovery: bool = True, 22 | sync_universe: int = 63999, socket: SenderSocketBase = None): 23 | """ 24 | Creates a sender object. A sender is used to manage multiple sACN universes and handles their sending. 25 | DMX data is send out every second, when no data changes. Some changes may be not send out, because the fps 26 | setting defines how often packets are send out to prevent network overuse. So if you change the DMX values too 27 | often in a second they may not all been send. Vary the fps parameter to your needs (Default=30). 28 | Note that a bind address is needed on Windows for sending out multicast packets. 29 | :param bind_address: the IP-Address to bind to. 30 | For multicast on a Windows machine this must be set to a proper value otherwise omit. 31 | :param bind_port: optionally bind to a specific port. Default=5568. It is not recommended to change the port. 32 | Change the port number if you have trouble with another program or the sACNreceiver blocking the port 33 | :param source_name: the source name used in the sACN packets. 34 | :param cid: the cid. If not given, a random CID will be generated. 35 | :param fps: the frames per second. See above explanation. Has to be >0 36 | :param sync_universe: universe to send sync packets on. 37 | :param socket: Provide a special socket implementation if necessary. Must be derived from SenderSocketBase, 38 | only use if the default socket implementation of this library is not sufficient. 39 | """ 40 | if len(cid) != 16: 41 | cid = tuple(int(random.random() * 255) for _ in range(0, 16)) 42 | self._outputs: Dict[int, Output] = {} 43 | self._sender_handler = SenderHandler(cid, source_name, self._outputs, bind_address, bind_port, fps, socket) 44 | self.universeDiscovery = universeDiscovery 45 | self._sync_universe: int = sync_universe 46 | 47 | @property 48 | def universeDiscovery(self) -> bool: 49 | return self._sender_handler.universe_discovery 50 | 51 | @universeDiscovery.setter 52 | def universeDiscovery(self, universeDiscovery: bool) -> None: 53 | self._sender_handler.universe_discovery = universeDiscovery 54 | 55 | @property 56 | def manual_flush(self) -> bool: 57 | return self._sender_handler.manual_flush 58 | 59 | @manual_flush.setter 60 | def manual_flush(self, manual_flush: bool) -> None: 61 | self._sender_handler.manual_flush = manual_flush 62 | 63 | def flush(self, universes: List[int] = []): 64 | """ 65 | Sends out all universes in one go. This is done on the caller's thread! 66 | This uses the E1.31 sync mechanism to try to sync all universes. 67 | Note that not all receivers support this feature. 68 | :param universes: a list of universes to send. If not given, all will be sent. 69 | :raises ValueError: when attempting to flush a universe that is not activated. 70 | """ 71 | for uni in universes: 72 | if uni not in self._outputs: 73 | raise ValueError(f'Cannot flush universe {uni}, it is not active!') 74 | self._sender_handler.send_out_all_universes( 75 | self._sync_universe, 76 | self._outputs if not universes else {uni: self._outputs[uni] for uni in universes}, 77 | time.time() 78 | ) 79 | 80 | def activate_output(self, universe: int) -> None: 81 | """ 82 | Activates a universe that's then starting to sending every second. 83 | See http://tsp.esta.org/tsp/documents/docs/E1-31-2016.pdf for more information 84 | :param universe: the universe to activate 85 | """ 86 | check_universe(universe) 87 | # check, if the universe already exists in the list: 88 | if universe in self._outputs: 89 | return 90 | # add new sending: 91 | new_output = Output(DataPacket(cid=self._sender_handler._CID, sourceName=self._sender_handler._source_name, universe=universe)) 92 | self._outputs[universe] = new_output 93 | 94 | def deactivate_output(self, universe: int) -> None: 95 | """ 96 | Deactivates an existing sending. Every data from the existing sending output will be lost. 97 | (TTL, Multicast, DMX data, ..) 98 | :param universe: the universe to deactivate. If the universe was not activated before, no error is raised 99 | """ 100 | check_universe(universe) 101 | try: # try to send out three messages with stream_termination bit set to 1 102 | self._outputs[universe]._packet.option_StreamTerminated = True 103 | for _ in range(0, 3): 104 | self._sender_handler.send_out(self._outputs[universe], time.time()) 105 | except KeyError: 106 | pass 107 | try: 108 | del self._outputs[universe] 109 | except KeyError: 110 | pass 111 | 112 | def get_active_outputs(self) -> tuple: 113 | """ 114 | Returns a list with all active outputs. Useful when iterating over all sender indexes. 115 | :return: list: a list with int (every int is a activated universe. May be not sorted) 116 | """ 117 | return tuple(self._outputs.keys()) 118 | 119 | def move_universe(self, universe_from: int, universe_to: int) -> None: 120 | """ 121 | Moves an sending from one universe to another. All settings are being restored and only the universe changes 122 | :param universe_from: the universe that should be moved 123 | :param universe_to: the target universe. An existing universe will be overwritten 124 | """ 125 | check_universe(universe_from) 126 | check_universe(universe_to) 127 | # store the sending object and change the universe in the packet of the sending 128 | tmp_output = self._outputs[universe_from] 129 | # deactivate sending 130 | self.deactivate_output(universe_from) 131 | # activate new sending with the new universe 132 | tmp_output._packet.universe = universe_to 133 | tmp_output._packet.option_StreamTerminated = False 134 | self._outputs[universe_to] = tmp_output 135 | 136 | def __getitem__(self, item: int) -> Optional[Output]: 137 | try: 138 | return self._outputs[item] 139 | except KeyError: 140 | return None 141 | 142 | def start(self) -> None: 143 | """ 144 | Starts or restarts a new Thread with the parameters given in the constructor. 145 | """ 146 | self.stop() 147 | self._sender_handler.start() 148 | 149 | def stop(self) -> None: 150 | """ 151 | Stops a running thread and closes the underlying socket. If no thread was started, nothing happens. 152 | Do not reuse the socket after calling stop once. 153 | """ 154 | self._sender_handler.stop() 155 | 156 | def __del__(self): 157 | # stop a potential running thread 158 | self.stop() 159 | 160 | 161 | def check_universe(universe: int): 162 | if universe not in range(1, 64000): 163 | raise ValueError(f'Universe must be between [1-63999]! Universe was {universe}') 164 | -------------------------------------------------------------------------------- /sacn/sender_test.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import pytest 4 | import sacn 5 | from sacn.sender import check_universe 6 | from sacn.messages.data_packet import DataPacket 7 | from sacn.sending.sender_socket_test import SenderSocketTest 8 | 9 | 10 | def test_constructor(): 11 | cid = tuple(range(0, 16)) 12 | source_name = 'test' 13 | socket = SenderSocketTest() 14 | 15 | # test default values for constructor 16 | sender = sacn.sACNsender(socket=socket) 17 | assert sender._sender_handler._source_name == 'default source name' 18 | assert len(sender._sender_handler._CID) == 16 19 | assert sender.universeDiscovery is True 20 | assert sender._sync_universe == 63999 21 | assert sender.manual_flush is False 22 | 23 | # test explicit values for constructor 24 | bind_address = 'test_address' 25 | bind_port = 1234 26 | fps = 40 27 | universe_discovery = False 28 | sync_universe = 4567 29 | sender = sacn.sACNsender(bind_address, bind_port, source_name, cid, fps, universe_discovery, sync_universe, socket) 30 | assert sender._sender_handler._source_name == source_name 31 | assert sender._sender_handler._CID == cid 32 | assert sender.universeDiscovery == universe_discovery 33 | assert sender._sync_universe == sync_universe 34 | assert sender.manual_flush is False 35 | 36 | 37 | def test_universe_discovery_setting(): 38 | socket = SenderSocketTest() 39 | sender = sacn.sACNsender(socket=socket) 40 | assert sender.universeDiscovery is True 41 | assert sender._sender_handler.universe_discovery is True 42 | sender.universeDiscovery = False 43 | assert sender.universeDiscovery is False 44 | assert sender._sender_handler.universe_discovery is False 45 | 46 | 47 | def test_manual_flush_setting(): 48 | socket = SenderSocketTest() 49 | sender = sacn.sACNsender(socket=socket) 50 | assert sender.manual_flush is False 51 | assert sender._sender_handler.manual_flush is False 52 | sender.manual_flush = True 53 | assert sender.manual_flush is True 54 | assert sender._sender_handler.manual_flush is True 55 | 56 | 57 | def test_flush(): 58 | socket = SenderSocketTest() 59 | sync_universe = 1234 60 | sender = sacn.sACNsender(sync_universe=sync_universe, socket=socket) 61 | 62 | assert socket.send_unicast_called == [] 63 | # test that non-active universes throw exception 64 | with pytest.raises(ValueError): 65 | sender.flush([1, 2]) 66 | 67 | assert socket.send_unicast_called == [] 68 | # test that no active universes triggers nothing 69 | sender.flush() 70 | assert socket.send_unicast_called == [] 71 | 72 | # activate universe 1 73 | sender.activate_output(1) 74 | assert socket.send_unicast_called == [] 75 | # test that no parameters triggers flushing of all universes 76 | sender.flush() 77 | assert socket.send_unicast_called[0][0].__dict__ == DataPacket( 78 | sender._sender_handler._CID, sender._sender_handler._source_name, 1, sync_universe=sync_universe).__dict__ 79 | 80 | # activate universe 2 81 | sender.activate_output(2) 82 | # test that a list with only universe 1 triggers flushing of only this universe 83 | sender.flush([1]) 84 | assert socket.send_unicast_called[1][0].__dict__ == DataPacket( 85 | sender._sender_handler._CID, sender._sender_handler._source_name, 1, sequence=1, sync_universe=sync_universe).__dict__ 86 | 87 | 88 | def test_activate_output(): 89 | socket = SenderSocketTest() 90 | sender = sacn.sACNsender(socket=socket) 91 | 92 | # start with no universes active 93 | assert list(sender._outputs.keys()) == [] 94 | 95 | # activate one universe 96 | sender.activate_output(1) 97 | assert list(sender._outputs.keys()) == [1] 98 | 99 | # activate another universe 100 | sender.activate_output(63999) 101 | assert list(sender._outputs.keys()) == [1, 63999] 102 | 103 | # check that a universe can not be enabled twice 104 | sender.activate_output(1) 105 | assert list(sender._outputs.keys()) == [1, 63999] 106 | 107 | 108 | def test_deactivate_output(): 109 | socket = SenderSocketTest() 110 | sender = sacn.sACNsender(socket=socket) 111 | 112 | # check that three packets with stream-termination bit set are send out on deactivation 113 | sender.activate_output(100) 114 | assert socket.send_unicast_called == [] 115 | sender.deactivate_output(100) 116 | assert socket.send_unicast_called[0][0].__dict__ == DataPacket( 117 | sender._sender_handler._CID, sender._sender_handler._source_name, 100, sequence=0, streamTerminated=True).__dict__ 118 | assert socket.send_unicast_called[1][0].__dict__ == DataPacket( 119 | sender._sender_handler._CID, sender._sender_handler._source_name, 100, sequence=1, streamTerminated=True).__dict__ 120 | assert socket.send_unicast_called[2][0].__dict__ == DataPacket( 121 | sender._sender_handler._CID, sender._sender_handler._source_name, 100, sequence=2, streamTerminated=True).__dict__ 122 | 123 | # start with no universes active 124 | assert list(sender._outputs.keys()) == [] 125 | sender.deactivate_output(1) 126 | assert list(sender._outputs.keys()) == [] 127 | 128 | # one universe active 129 | sender.activate_output(1) 130 | assert list(sender._outputs.keys()) == [1] 131 | sender.deactivate_output(1) 132 | assert list(sender._outputs.keys()) == [] 133 | 134 | # two universes active 135 | sender.activate_output(10) 136 | sender.activate_output(11) 137 | assert list(sender._outputs.keys()) == [10, 11] 138 | sender.deactivate_output(10) 139 | assert list(sender._outputs.keys()) == [11] 140 | 141 | # deactivate no active universe 142 | assert list(sender._outputs.keys()) == [11] 143 | sender.deactivate_output(99) 144 | assert list(sender._outputs.keys()) == [11] 145 | 146 | 147 | def test_get_active_outputs(): 148 | socket = SenderSocketTest() 149 | sender = sacn.sACNsender(socket=socket) 150 | 151 | # none active 152 | assert sender.get_active_outputs() == tuple([]) 153 | 154 | # one active 155 | sender.activate_output(1) 156 | assert sender.get_active_outputs() == tuple([1]) 157 | 158 | # two active 159 | sender.activate_output(2) 160 | assert sender.get_active_outputs() == tuple([1, 2]) 161 | 162 | 163 | def test_move_universe(): 164 | socket = SenderSocketTest() 165 | sender = sacn.sACNsender(socket=socket) 166 | 167 | sender.activate_output(1) 168 | output = sender._outputs[1] 169 | assert list(sender._outputs.keys()) == [1] 170 | assert sender._outputs[1] == output 171 | sender.move_universe(1, 2) 172 | assert list(sender._outputs.keys()) == [2] 173 | assert sender._outputs[2] == output 174 | 175 | 176 | def test_getitem(): 177 | socket = SenderSocketTest() 178 | sender = sacn.sACNsender(socket=socket) 179 | 180 | assert sender[1] is None 181 | sender.activate_output(1) 182 | assert sender[1] == sender._outputs[1] 183 | 184 | 185 | def test_start(): 186 | socket = SenderSocketTest() 187 | sender = sacn.sACNsender(socket=socket) 188 | 189 | assert socket.start_called is False 190 | sender.start() 191 | assert socket.start_called is True 192 | 193 | # a second time is not allowed to throw an exception 194 | assert socket.start_called is True 195 | sender.start() 196 | assert socket.start_called is True 197 | 198 | 199 | def test_stop(): 200 | socket = SenderSocketTest() 201 | sender = sacn.sACNsender(socket=socket) 202 | 203 | assert socket.stop_called is False 204 | sender.stop() 205 | assert socket.stop_called is True 206 | 207 | # a second time is not allowed to throw an exception 208 | assert socket.stop_called is True 209 | sender.stop() 210 | assert socket.stop_called is True 211 | 212 | 213 | def test_output_destination(): 214 | socket = SenderSocketTest() 215 | sender = sacn.sACNsender(socket=socket) 216 | sender.activate_output(1) 217 | 218 | # test default 219 | assert sender[1].destination == '127.0.0.1' 220 | # test setting and retriving the value 221 | test = 'test' 222 | sender[1].destination = test 223 | assert sender[1].destination == test 224 | 225 | 226 | def test_output_multicast(): 227 | socket = SenderSocketTest() 228 | sender = sacn.sACNsender(socket=socket) 229 | sender.activate_output(1) 230 | 231 | # test default 232 | assert sender[1].multicast is False 233 | # test setting and retriving the value 234 | test = True 235 | sender[1].multicast = test 236 | assert sender[1].multicast == test 237 | 238 | 239 | def test_output_ttl(): 240 | socket = SenderSocketTest() 241 | sender = sacn.sACNsender(socket=socket) 242 | sender.activate_output(1) 243 | 244 | # test default 245 | assert sender[1].ttl == 8 246 | # test setting and retriving the value 247 | test = 16 248 | sender[1].ttl = test 249 | assert sender[1].ttl == test 250 | 251 | 252 | def test_output_priority(): 253 | socket = SenderSocketTest() 254 | sender = sacn.sACNsender(socket=socket) 255 | sender.activate_output(1) 256 | 257 | # test default 258 | assert sender[1].priority == 100 259 | # test setting and retriving the value 260 | test = 200 261 | sender[1].priority = test 262 | assert sender[1].priority == test 263 | 264 | 265 | def test_output_preview_data(): 266 | socket = SenderSocketTest() 267 | sender = sacn.sACNsender(socket=socket) 268 | sender.activate_output(1) 269 | 270 | # test default 271 | assert sender[1].preview_data is False 272 | # test setting and retriving the value 273 | test = True 274 | sender[1].preview_data = test 275 | assert sender[1].preview_data == test 276 | 277 | 278 | def test_output_dmx_data(): 279 | socket = SenderSocketTest() 280 | sender = sacn.sACNsender(socket=socket) 281 | sender.activate_output(1) 282 | 283 | # test default 284 | assert sender[1].dmx_data == tuple([0]*512) 285 | # test setting and retriving the value 286 | test = tuple([x % 256 for x in range(0, 512)]) 287 | sender[1].dmx_data = test 288 | assert sender[1].dmx_data == test 289 | 290 | 291 | def test_check_universe(): 292 | with pytest.raises(ValueError): 293 | check_universe(0) 294 | with pytest.raises(ValueError): 295 | check_universe(64000) 296 | check_universe(1) 297 | check_universe(63999) 298 | -------------------------------------------------------------------------------- /sacn/sending/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hundemeier/sacn/d5fd886c2bbd57c945f9c8ffa0588c327859dd3a/sacn/sending/__init__.py -------------------------------------------------------------------------------- /sacn/sending/output.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | from copy import deepcopy 4 | from sacn.messages.data_packet import DataPacket 5 | 6 | 7 | class Output: 8 | """ 9 | This class is a compact representation of an sending with all relevant information 10 | """ 11 | 12 | def __init__(self, packet: DataPacket, last_time_send: int = 0, destination: str = '127.0.0.1', 13 | multicast: bool = False, ttl: int = 8): 14 | self._packet: DataPacket = packet 15 | self._last_time_send: int = last_time_send 16 | self.destination: str = destination 17 | self.multicast: bool = multicast 18 | self.ttl: int = ttl 19 | self._per_address_priority: tuple = () 20 | self._changed: bool = False 21 | 22 | @property 23 | def dmx_data(self) -> tuple: 24 | return self._packet.dmxData 25 | 26 | @dmx_data.setter 27 | def dmx_data(self, dmx_data: tuple): 28 | self._packet.dmxData = dmx_data 29 | self._changed = True 30 | 31 | @property 32 | def priority(self) -> int: 33 | return self._packet.priority 34 | 35 | @priority.setter 36 | def priority(self, priority: int): 37 | self._packet.priority = priority 38 | 39 | @property 40 | def per_address_priority(self) -> tuple: 41 | return self._per_address_priority 42 | 43 | @per_address_priority.setter 44 | def per_address_priority(self, per_address_priority: tuple): 45 | if len(per_address_priority) != 512 and len(per_address_priority) != 0 or \ 46 | not all((isinstance(x, int) and (0 <= x <= 255)) for x in per_address_priority): 47 | raise ValueError(f'per_address_priority is a tuple with a length of 512 or 0! ' 48 | f'Data in the tuple has to be valid bytes (i.e. values in range [0; 255])! ' 49 | f'Length was {len(per_address_priority)}') 50 | self._per_address_priority = per_address_priority 51 | 52 | @property 53 | def _per_address_priority_packet(self) -> DataPacket: 54 | per_address_priority_packet = deepcopy(self._packet) 55 | per_address_priority_packet.dmxStartCode = 0xdd 56 | per_address_priority_packet.dmxData = self.per_address_priority 57 | return per_address_priority_packet 58 | 59 | @property 60 | def preview_data(self) -> bool: 61 | return self._packet.option_PreviewData 62 | 63 | @preview_data.setter 64 | def preview_data(self, preview_data: bool): 65 | self._packet.option_PreviewData = preview_data 66 | -------------------------------------------------------------------------------- /sacn/sending/sender_handler.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | from typing import Dict 4 | from sacn.messages.universe_discovery import UniverseDiscoveryPacket 5 | from sacn.messages.sync_packet import SyncPacket 6 | from sacn.messages.data_packet import calculate_multicast_addr 7 | from sacn.sending.output import Output 8 | from sacn.sending.sender_socket_base import SenderSocketBase, SenderSocketListener 9 | from sacn.sending.sender_socket_udp import SenderSocketUDP 10 | 11 | SEND_OUT_INTERVAL = 1 12 | E131_E131_UNIVERSE_DISCOVERY_INTERVAL = 10 13 | 14 | 15 | class SenderHandler(SenderSocketListener): 16 | # TODO: start using type CID instead of tuple 17 | def __init__(self, cid: tuple, source_name: str, outputs: Dict[int, Output], bind_address: str, bind_port: int, fps: int, socket: SenderSocketBase = None): 18 | """ 19 | This is a private class and should not be used elsewhere. It handles the sender state with sACN specific values. 20 | Uses a UDP sender socket with the given bind-address and -port, if the socket was not provided (i.e. None). 21 | """ 22 | if socket is None: 23 | self.socket: SenderSocketBase = SenderSocketUDP(self, bind_address, bind_port, fps) 24 | else: 25 | self.socket: SenderSocketBase = socket 26 | 27 | self._CID = cid 28 | self._source_name = source_name 29 | self.universe_discovery: bool = True 30 | self._last_time_universe_discover: float = 0 31 | self._outputs: Dict[int, Output] = outputs 32 | self.manual_flush: bool = False 33 | self._sync_sequence = 0 34 | 35 | def on_periodic_callback(self, current_time: float) -> None: 36 | # send out universe discovery packets if necessary 37 | if self.universe_discovery and \ 38 | abs(current_time - self._last_time_universe_discover) >= E131_E131_UNIVERSE_DISCOVERY_INTERVAL: 39 | self.send_universe_discovery_packets() 40 | self._last_time_universe_discover = current_time 41 | 42 | # go through the list of outputs and send everything out that has to be send out 43 | # Note: dict may changes size during iteration (multithreading) 44 | [self.send_out(output, current_time) for output in list(self._outputs.values()) 45 | # only send if the manual flush feature is disabled 46 | # send out when the 1 second interval is over 47 | if not self.manual_flush and 48 | (output._changed or abs(current_time - output._last_time_send) >= SEND_OUT_INTERVAL)] 49 | 50 | def send_out(self, output: Output, current_time: float): 51 | # 1st: Destination (check if multicast) 52 | if output.multicast: 53 | udp_ip = output._packet.calculate_multicast_addr() 54 | # 2nd: check if a per-address-priority packet needs to be send first 55 | if len(output.per_address_priority) == 512: 56 | self.socket.send_multicast(output._per_address_priority_packet, udp_ip, output.ttl) 57 | # note: the following data packet should have an increased sequence number 58 | output._packet.sequence_increase() 59 | self.socket.send_multicast(output._packet, udp_ip, output.ttl) 60 | else: 61 | udp_ip = output.destination 62 | # 2nd: check if a per-address-priority packet needs to be send first 63 | if len(output.per_address_priority) == 512: 64 | self.socket.send_unicast(output._per_address_priority_packet, udp_ip) 65 | # note: the following data packet should have an increased sequence number 66 | output._packet.sequence_increase() 67 | self.socket.send_unicast(output._packet, udp_ip) 68 | 69 | output._last_time_send = current_time 70 | # increase the sequence counter 71 | output._packet.sequence_increase() 72 | # the changed flag is not necessary any more 73 | output._changed = False 74 | 75 | def send_universe_discovery_packets(self): 76 | packets = UniverseDiscoveryPacket.make_multiple_uni_disc_packets( 77 | cid=self._CID, sourceName=self._source_name, universes=list(self._outputs.keys())) 78 | for packet in packets: 79 | self.socket.send_broadcast(packet) 80 | 81 | def send_out_all_universes(self, sync_universe: int, universes: dict, current_time: float): 82 | """ 83 | Sends out all universes in one go. This is not done by this thread! This is done by the caller's thread. 84 | This uses the E1.31 sync mechanism to try to sync all universes. 85 | Note that not all receivers support this feature. 86 | """ 87 | # go through the list of outputs and send everything out 88 | # Note: dict may changes size during iteration (multithreading) 89 | for output in list(universes.values()): 90 | output._packet.syncAddr = sync_universe # temporarily set the sync universe 91 | self.send_out(output, current_time) 92 | output._packet.syncAddr = 0 93 | 94 | sync_packet = SyncPacket(cid=self._CID, syncAddr=sync_universe, sequence=self._sync_sequence) 95 | # Increment sequence number for next time. 96 | self._sync_sequence += 1 97 | if self._sync_sequence > 255: 98 | self._sync_sequence = 0 99 | self.socket.send_multicast(sync_packet, calculate_multicast_addr(sync_universe), 255) 100 | 101 | def start(self): 102 | self.socket.start() 103 | 104 | def stop(self): 105 | self.socket.stop() 106 | -------------------------------------------------------------------------------- /sacn/sending/sender_handler_test.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | from typing import Dict 4 | from sacn.messages.data_packet import DataPacket, calculate_multicast_addr 5 | from sacn.messages.sync_packet import SyncPacket 6 | from sacn.messages.universe_discovery import UniverseDiscoveryPacket 7 | from sacn.sending.output import Output 8 | from sacn.sending.sender_handler import SenderHandler 9 | from sacn.sending.sender_socket_test import SenderSocketTest 10 | 11 | 12 | def get_handler(): 13 | cid = tuple(range(0, 16)) 14 | source_name = 'test' 15 | outputs: Dict[int, Output] = { 16 | 1: Output( 17 | packet=DataPacket( 18 | cid=cid, 19 | sourceName=source_name, 20 | universe=1, 21 | ) 22 | ) 23 | } 24 | socket = SenderSocketTest() 25 | handler = SenderHandler( 26 | cid=cid, 27 | source_name=source_name, 28 | outputs=outputs, 29 | bind_address='0.0.0.0', 30 | bind_port=5568, 31 | fps=30, 32 | socket=socket, 33 | ) 34 | handler.manual_flush = True 35 | # wire up listener for tests 36 | socket._listener = handler 37 | return handler, socket, cid, source_name, outputs 38 | 39 | 40 | def test_universe_discovery_packets(): 41 | handler, socket, cid, source_name, outputs = get_handler() 42 | handler.manual_flush = True 43 | current_time = 100.0 44 | 45 | assert handler.universe_discovery is True 46 | assert socket.send_broadcast_called is None 47 | 48 | # test that the universe discovery can be disabled 49 | handler.universe_discovery = False 50 | socket.call_on_periodic_callback(current_time) 51 | assert socket.send_broadcast_called is None 52 | handler.universe_discovery = True 53 | 54 | # if no outputs are specified, there is an empty universe packet send 55 | socket.call_on_periodic_callback(current_time) 56 | assert socket.send_broadcast_called == UniverseDiscoveryPacket(cid, source_name, (1,)) 57 | 58 | 59 | def test_send_out_interval(): 60 | handler, socket, cid, source_name, outputs = get_handler() 61 | handler.manual_flush = False 62 | current_time = 100.0 63 | 64 | assert handler.manual_flush is False 65 | assert socket.send_unicast_called == [] 66 | 67 | # first send packet due to interval 68 | socket.call_on_periodic_callback(current_time) 69 | assert socket.send_unicast_called[0][0].__dict__ == DataPacket(cid, source_name, 1, sequence=0).__dict__ 70 | assert socket.send_unicast_called[0][1] == '127.0.0.1' 71 | 72 | # interval must be 1 seconds 73 | socket.call_on_periodic_callback(current_time+0.99) 74 | assert len(socket.send_unicast_called) == 1 75 | socket.call_on_periodic_callback(current_time+1.01) 76 | assert socket.send_unicast_called[1][0].__dict__ == DataPacket(cid, source_name, 1, sequence=1).__dict__ 77 | 78 | 79 | def test_multicast(): 80 | handler, socket, cid, source_name, outputs = get_handler() 81 | handler.manual_flush = False 82 | current_time = 100.0 83 | outputs[1].multicast = True 84 | outputs[1].ttl = 123 85 | 86 | assert handler.manual_flush is False 87 | assert socket.send_multicast_called is None 88 | assert outputs[1].multicast is True 89 | assert outputs[1].ttl == 123 90 | 91 | # first send packet due to interval 92 | socket.call_on_periodic_callback(current_time) 93 | assert socket.send_multicast_called[0].__dict__ == DataPacket(cid, source_name, 1, sequence=0).__dict__ 94 | assert socket.send_multicast_called[1] == calculate_multicast_addr(1) 95 | 96 | # only send out on dmx change 97 | # test same data as before 98 | # TODO: currently there is no "are the values different" check. 99 | # If it is implemented, enable the following line: 100 | # outputs[1].dmx_data = (0, 0) 101 | socket.call_on_periodic_callback(current_time) 102 | assert socket.send_multicast_called[0].__dict__ == DataPacket(cid, source_name, 1, sequence=0).__dict__ 103 | assert socket.send_multicast_called[1] == calculate_multicast_addr(1) 104 | 105 | # test change in data as before 106 | outputs[1].dmx_data = (1, 2) 107 | socket.call_on_periodic_callback(current_time) 108 | assert socket.send_multicast_called[0].__dict__ == DataPacket(cid, source_name, 1, sequence=1, dmxData=(1, 2)).__dict__ 109 | assert socket.send_multicast_called[1] == calculate_multicast_addr(1) 110 | 111 | # assert that no unicast was send 112 | assert socket.send_unicast_called == [] 113 | 114 | 115 | def test_unicast(): 116 | handler, socket, cid, source_name, outputs = get_handler() 117 | handler.manual_flush = False 118 | current_time = 100.0 119 | outputs[1].multicast = False 120 | destination = "1.2.3.4" 121 | outputs[1].destination = destination 122 | 123 | assert handler.manual_flush is False 124 | assert socket.send_unicast_called == [] 125 | assert outputs[1].multicast is False 126 | 127 | # first send packet due to interval 128 | socket.call_on_periodic_callback(current_time) 129 | assert socket.send_unicast_called[0][0].__dict__ == DataPacket(cid, source_name, 1, sequence=0).__dict__ 130 | assert socket.send_unicast_called[0][1] == destination 131 | 132 | # only send out on dmx change 133 | # test same data as before 134 | # TODO: currently there is no "are the values different" check. 135 | # If it is implemented, enable the following line: 136 | # outputs[1].dmx_data = (0, 0) 137 | socket.call_on_periodic_callback(current_time) 138 | print(socket.send_unicast_called) 139 | assert socket.send_unicast_called[0][0].__dict__ == DataPacket(cid, source_name, 1, sequence=0).__dict__ 140 | assert socket.send_unicast_called[0][1] == destination 141 | 142 | # test change in data as before 143 | outputs[1].dmx_data = (1, 2) 144 | socket.call_on_periodic_callback(current_time) 145 | assert socket.send_unicast_called[1][0].__dict__ == DataPacket(cid, source_name, 1, sequence=1, dmxData=(1, 2)).__dict__ 146 | assert socket.send_unicast_called[1][1] == destination 147 | 148 | # assert that no multicast was send 149 | assert socket.send_multicast_called is None 150 | 151 | 152 | def test_send_out_all_universes(): 153 | handler, socket, cid, source_name, outputs = get_handler() 154 | handler.manual_flush = True 155 | current_time = 100.0 156 | outputs[1].multicast = False 157 | destination = "1.2.3.4" 158 | outputs[1].destination = destination 159 | 160 | assert handler.manual_flush is True 161 | assert socket.send_unicast_called == [] 162 | assert socket.send_multicast_called is None 163 | assert outputs[1].multicast is False 164 | 165 | # check that send packets due to interval are suppressed 166 | socket.call_on_periodic_callback(current_time) 167 | assert socket.send_unicast_called == [] 168 | assert socket.send_multicast_called is None 169 | 170 | # after calling send_out_all_universes, the DataPackets need to send, as well as one SyncPacket 171 | sync_universe = 63999 172 | handler.send_out_all_universes(sync_universe, outputs, current_time) 173 | assert socket.send_unicast_called[0][0].__dict__ == DataPacket(cid, source_name, 1, sequence=0, sync_universe=sync_universe).__dict__ 174 | assert socket.send_unicast_called[0][1] == destination 175 | assert socket.send_multicast_called[0].__dict__ == SyncPacket(cid, sync_universe, 0).__dict__ 176 | assert socket.send_multicast_called[1] == calculate_multicast_addr(sync_universe) 177 | 178 | 179 | def test_send_out_all_universes_sequence_increment(): 180 | handler, socket, cid, source_name, outputs = get_handler() 181 | handler.manual_flush = True 182 | current_time = 100.0 183 | sync_universe = 63999 184 | 185 | # check that the sequence number never exceeds the range [0-255] 186 | for i in range(0, 300): 187 | handler.send_out_all_universes(sync_universe, outputs, current_time) 188 | assert socket.send_multicast_called[0].__dict__ == SyncPacket(cid, sync_universe, (i % 256)).__dict__ 189 | 190 | 191 | def test_per_address_priority(): 192 | handler, socket, cid, source_name, outputs = get_handler() 193 | handler.manual_flush = False 194 | current_time = 100.0 195 | outputs[1].multicast = False 196 | destination = "1.2.3.4" 197 | tuple_per_address_priority = tuple([123]*512) 198 | outputs[1].destination = destination 199 | outputs[1].per_address_priority = tuple_per_address_priority 200 | 201 | # send packet due to interval 202 | # note: special case with per-address-priority: the sequence of the second data packet (with line values) 203 | # is one higher than the priority data 204 | socket.call_on_periodic_callback(current_time) 205 | assert socket.send_unicast_called[0][0].__dict__ == DataPacket(cid, source_name, 1, sequence=0, dmxStartCode=0xdd, 206 | dmxData=tuple_per_address_priority).__dict__ 207 | assert socket.send_unicast_called[0][1] == destination 208 | assert socket.send_unicast_called[1][0].__dict__ == DataPacket(cid, source_name, 1, sequence=1, dmxStartCode=0x00).__dict__ 209 | assert socket.send_unicast_called[1][1] == destination 210 | -------------------------------------------------------------------------------- /sacn/sending/sender_socket_base.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import logging 4 | from sacn.messages.root_layer import RootLayer 5 | 6 | DEFAULT_PORT = 5568 7 | 8 | 9 | class SenderSocketListener: 10 | """ 11 | Base class for listener of a SenderSocketListener. 12 | """ 13 | 14 | def on_periodic_callback(self, time: float) -> None: 15 | raise NotImplementedError 16 | 17 | 18 | class SenderSocketBase: 19 | """ 20 | Base class for abstracting a UDP sending socket. 21 | """ 22 | 23 | def __init__(self, listener: SenderSocketListener): 24 | self._logger: logging.Logger = logging.getLogger('sacn') 25 | self._listener: SenderSocketListener = listener 26 | 27 | def start(self) -> None: 28 | raise NotImplementedError 29 | 30 | def stop(self) -> None: 31 | raise NotImplementedError 32 | 33 | def send_unicast(self, data: RootLayer, destination: str) -> None: 34 | raise NotImplementedError 35 | 36 | def send_multicast(self, data: RootLayer, destination: str, ttl: int) -> None: 37 | raise NotImplementedError 38 | 39 | def send_broadcast(self, data: RootLayer) -> None: 40 | raise NotImplementedError 41 | -------------------------------------------------------------------------------- /sacn/sending/sender_socket_base_test.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import pytest 4 | from sacn.messages.root_layer import RootLayer 5 | from sacn.sending.sender_socket_base import SenderSocketBase, SenderSocketListener 6 | 7 | 8 | def test_abstract_sender_socket_listener(): 9 | listener = SenderSocketListener() 10 | with pytest.raises(NotImplementedError): 11 | listener.on_periodic_callback(1.0) 12 | 13 | 14 | def test_abstract_sender_socket_base(): 15 | socket = SenderSocketBase(None) 16 | with pytest.raises(NotImplementedError): 17 | socket.start() 18 | with pytest.raises(NotImplementedError): 19 | socket.stop() 20 | with pytest.raises(NotImplementedError): 21 | socket.send_unicast(RootLayer(1, tuple(range(0, 16)), (0, 0, 0, 0)), 'test') 22 | with pytest.raises(NotImplementedError): 23 | socket.send_multicast(RootLayer(1, tuple(range(0, 16)), (0, 0, 0, 0)), 'test', 12) 24 | with pytest.raises(NotImplementedError): 25 | socket.send_broadcast(RootLayer(1, tuple(range(0, 16)), (0, 0, 0, 0))) 26 | -------------------------------------------------------------------------------- /sacn/sending/sender_socket_test.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import copy 4 | from sacn.messages.root_layer import RootLayer 5 | from sacn.sending.sender_socket_base import SenderSocketBase 6 | 7 | 8 | class SenderSocketTest(SenderSocketBase): 9 | def __init__(self, listener=None): 10 | super().__init__(listener) 11 | self.start_called: bool = False 12 | self.stop_called: bool = False 13 | self.send_unicast_called: list[tuple[RootLayer, str]] = [] 14 | self.send_multicast_called: tuple[RootLayer, str, int] = None 15 | self.send_broadcast_called: RootLayer = None 16 | 17 | def start(self) -> None: 18 | self.start_called = True 19 | 20 | def stop(self) -> None: 21 | self.stop_called = True 22 | 23 | def send_unicast(self, data: RootLayer, destination: str) -> None: 24 | self.send_unicast_called.append((copy.deepcopy(data), copy.deepcopy(destination))) 25 | 26 | def send_multicast(self, data: RootLayer, destination: str, ttl: int) -> None: 27 | self.send_multicast_called = (copy.deepcopy(data), copy.deepcopy(destination), ttl) 28 | 29 | def send_broadcast(self, data: RootLayer) -> None: 30 | self.send_broadcast_called = copy.deepcopy(data) 31 | 32 | def call_on_periodic_callback(self, time: float) -> None: 33 | self._listener.on_periodic_callback(time) 34 | -------------------------------------------------------------------------------- /sacn/sending/sender_socket_udp.py: -------------------------------------------------------------------------------- 1 | # This file is under MIT license. The license file can be obtained in the root directory of this module. 2 | 3 | import socket 4 | import time 5 | import threading 6 | 7 | from sacn.messages.root_layer import RootLayer 8 | from sacn.sending.sender_socket_base import SenderSocketBase, SenderSocketListener, DEFAULT_PORT 9 | 10 | THREAD_NAME = 'sACN sending/sender thread' 11 | 12 | 13 | class SenderSocketUDP(SenderSocketBase): 14 | """ 15 | Implements a sender socket with a UDP socket of the OS. 16 | """ 17 | 18 | def __init__(self, listener: SenderSocketListener, bind_address: str, bind_port: int, fps: int): 19 | super().__init__(listener=listener) 20 | 21 | self._bind_address: str = bind_address 22 | self._bind_port: int = bind_port 23 | self._enabled_flag: bool = True 24 | self.fps: int = fps 25 | 26 | # initialize the UDP socket 27 | self._socket: socket.socket = socket.socket(socket.AF_INET, # Internet 28 | socket.SOCK_DGRAM) # UDP 29 | try: 30 | self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 31 | except socket.error: # Not all systems support multiple sockets on the same port and interface 32 | pass 33 | 34 | try: 35 | self._socket.bind((self._bind_address, self._bind_port)) 36 | self._logger.info(f'Bind sender thread to IP:{self._bind_address} Port:{self._bind_port}') 37 | except socket.error: 38 | self._logger.exception(f'Could not bind to IP:{self._bind_address} Port:{self._bind_port}') 39 | raise 40 | 41 | def start(self): 42 | # initialize thread infos 43 | self._thread = threading.Thread( 44 | target=self.send_loop, 45 | name=THREAD_NAME 46 | ) 47 | # self._thread.setDaemon(True) # TODO: might be beneficial to use a daemon thread 48 | self._thread.start() 49 | 50 | def send_loop(self) -> None: 51 | self._logger.info(f'Started {THREAD_NAME}') 52 | self._enabled_flag = True 53 | while self._enabled_flag: 54 | time_stamp = time.time() 55 | self._listener.on_periodic_callback(time_stamp) 56 | time_to_sleep = (1 / self.fps) - (time.time() - time_stamp) 57 | if time_to_sleep < 0: # if time_to_sleep is negative (because the loop has too much work to do) set it to 0 58 | time_to_sleep = 0 59 | time.sleep(time_to_sleep) 60 | # this sleeps nearly exactly so long that the loop is called every 1/fps seconds 61 | 62 | self._logger.info(f'Stopped {THREAD_NAME}') 63 | 64 | def stop(self) -> None: 65 | """ 66 | Stops a running thread and closes the underlying socket. If no thread was started, nothing happens. 67 | Do not reuse the socket after calling stop once. 68 | """ 69 | self._enabled_flag = False 70 | # wait for the thread to finish 71 | try: 72 | self._thread.join() 73 | # stop the socket, after the loop terminated 74 | self._socket.close() 75 | except AttributeError: 76 | pass 77 | 78 | def send_unicast(self, data: RootLayer, destination: str) -> None: 79 | self.send_packet(data.getBytes(), destination) 80 | 81 | def send_multicast(self, data: RootLayer, destination: str, ttl: int) -> None: 82 | # make socket multicast-aware: (set TTL) 83 | self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) 84 | self.send_packet(data.getBytes(), destination) 85 | 86 | def send_broadcast(self, data: RootLayer) -> None: 87 | # hint: on windows a bind address must be set, to use broadcast 88 | self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 89 | self.send_packet(data.getBytes(), destination='') 90 | 91 | def send_packet(self, data: bytearray, destination: str) -> None: 92 | data_raw = bytearray(data) 93 | try: 94 | self._socket.sendto(data_raw, (destination, DEFAULT_PORT)) 95 | except OSError as e: 96 | self._logger.exception('Failed to send packet', exc_info=e) 97 | raise 98 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name='sacn', 8 | version='1.11.0', 9 | description='sACN / E1.31 module for easy handling of DMX data over ethernet', 10 | long_description=long_description, 11 | long_description_content_type="text/markdown", 12 | url='https://www.github.com/Hundemeier/sacn', 13 | author='Hundemeier', 14 | author_email='hundemeier99@gmail.com', 15 | license='MIT License', 16 | packages=find_packages(), 17 | keywords=['sacn e131 e1.31 dmx'], 18 | classifiers=[ 19 | "Programming Language :: Python :: 3 :: Only", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent", 22 | ], 23 | python_requires='>=3.6', 24 | zip_safe=False 25 | ) 26 | --------------------------------------------------------------------------------