├── .coveragerc ├── .github └── workflows │ ├── python-package.yml │ └── wheel-test.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── README.rst ├── doc ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── _static │ └── css │ │ └── custom.css │ ├── conf.py │ ├── conftest.py │ ├── ext │ ├── __init__.py │ ├── builtinproperty.py │ ├── eventobj.py │ └── propertyobj.py │ ├── index.rst │ ├── overview.rst │ ├── protocol.rst │ ├── receiver.rst │ ├── reference │ ├── common.rst │ ├── index.rst │ ├── messages.rst │ ├── receiver.rst │ ├── sender.rst │ └── tallyobj.rst │ └── sender.rst ├── examples └── animated_sender.py ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── src └── tslumd │ ├── __init__.py │ ├── common.py │ ├── messages.py │ ├── receiver.py │ ├── sender.py │ ├── tallyobj.py │ └── utils.py └── tests ├── conftest.py ├── data ├── uhs500-message.umd └── uhs500-tally.json ├── test_messages.py ├── test_receiver.py ├── test_sender.py └── test_tallyobj.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = tslumd 3 | 4 | [report] 5 | exclude_lines = 6 | pragma: no cover 7 | if TYPE_CHECKING: 8 | def __repr__ 9 | raise AssertionError 10 | raise NotImplementedError 11 | return NotImplemented 12 | if __name__ == .__main__.: 13 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | workflow_dispatch: 12 | 13 | jobs: 14 | test: 15 | 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip setuptools wheel 31 | pip install -r requirements-dev.txt 32 | pip install coveralls 33 | pip install -e . 34 | - name: Test with pytest 35 | run: | 36 | py.test --cov=src tests src 37 | py.test --cov=src --cov-append doc 38 | 39 | - name: Upload to Coveralls 40 | run: coveralls --service=github 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | COVERALLS_FLAG_NAME: run-${{ matrix.python-version }} 44 | COVERALLS_PARALLEL: true 45 | 46 | coveralls: 47 | name: Indicate completion to coveralls.io 48 | needs: test 49 | runs-on: ubuntu-latest 50 | container: python:3-slim 51 | steps: 52 | - name: Finished 53 | run: | 54 | pip3 install --upgrade coveralls 55 | coveralls --finish 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | -------------------------------------------------------------------------------- /.github/workflows/wheel-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Test from sdist and wheels 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | workflow_dispatch: 12 | 13 | release: 14 | types: [created] 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: 3.8 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip setuptools wheel 28 | - name: Build dists 29 | run: python setup.py sdist bdist_wheel 30 | - name: Upload artifacts 31 | uses: actions/upload-artifact@v2 32 | with: 33 | name: 'dists' 34 | path: 'dist/*' 35 | 36 | test: 37 | needs: build 38 | runs-on: ubuntu-latest 39 | strategy: 40 | fail-fast: false 41 | matrix: 42 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 43 | 44 | steps: 45 | - uses: actions/checkout@v2 46 | - name: Set up Python ${{ matrix.python-version }} 47 | uses: actions/setup-python@v2 48 | with: 49 | python-version: ${{ matrix.python-version }} 50 | - name: Install dependencies 51 | run: | 52 | python -m pip install --upgrade pip setuptools wheel 53 | pip install -r requirements-dev.txt 54 | - name: Download artifacts 55 | uses: actions/download-artifact@v2 56 | with: 57 | name: 'dists' 58 | path: dist 59 | - name: Install wheel 60 | run: pip install dist/*.whl 61 | - name: Test built wheel 62 | run: py.test -o testpaths=tests 63 | - name: Install sdist 64 | run: | 65 | pip uninstall -y tslumd 66 | pip install dist/*.tar.gz 67 | - name: Test built sdist 68 | run: py.test -o testpaths=tests 69 | 70 | deploy: 71 | needs: test 72 | if: ${{ success() && github.event_name == 'release' }} 73 | runs-on: ubuntu-latest 74 | steps: 75 | - uses: actions/checkout@v2 76 | - name: Set up Python ${{ matrix.python-version }} 77 | uses: actions/setup-python@v2 78 | with: 79 | python-version: 3.8 80 | - name: Install dependencies 81 | run: | 82 | python -m pip install --upgrade pip setuptools wheel twine 83 | - name: Download artifacts 84 | uses: actions/download-artifact@v2 85 | with: 86 | name: 'dists' 87 | path: dist 88 | - name: Publish to PyPI 89 | env: 90 | TWINE_REPOSITORY_URL: ${{ secrets.TWINE_REPOSITORY_URL }} 91 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 92 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 93 | run: twine upload dist/* 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | venv* 92 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | python: 9 | install: 10 | - requirements: requirements-dev.txt 11 | - method: pip 12 | path: . 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Matthew Reid 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 4 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 5 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 6 | permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 11 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 13 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | tslumd 2 | ====== 3 | 4 | .. image:: https://badge.fury.io/py/tslumd.svg 5 | :target: https://badge.fury.io/py/tslumd 6 | .. image:: https://img.shields.io/github/workflow/status/nocarryr/tslumd/Python%20package 7 | :alt: GitHub Workflow Status 8 | .. image:: https://img.shields.io/coveralls/github/nocarryr/tslumd 9 | :alt: Coveralls 10 | 11 | Client and Server for TSLUMD Tally Protocols 12 | 13 | Description 14 | ----------- 15 | 16 | This project is intended to serve only as a library for other applications 17 | wishing to send or receive tally information using the 18 | `UMDv5.0 Protocol`_ by `TSL Products`_. It is written in pure Python and 19 | utilizes `asyncio `_ for 20 | communication. 21 | 22 | Links 23 | ----- 24 | 25 | .. list-table:: 26 | 27 | * - Project Home 28 | - https://github.com/nocarryr/tslumd 29 | * - Documentation 30 | - https://tslumd.readthedocs.io 31 | * - PyPI 32 | - https://pypi.org/project/tslumd 33 | 34 | 35 | License 36 | ------- 37 | 38 | Copyright (c) 2021 Matthew Reid 39 | 40 | tslumd is licensed under the MIT license, please see LICENSE file for details. 41 | 42 | 43 | .. _UMDv5.0 Protocol: https://tslproducts.com/media/1959/tsl-umd-protocol.pdf 44 | .. _TSL Products: https://tslproducts.com 45 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /doc/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx>=3.5 2 | sphinx_rtd_theme 3 | -------------------------------------------------------------------------------- /doc/source/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | .rst-content code.docutils.literal.notranslate:not(.xref) { 2 | color: #404040; 3 | font-weight: normal; 4 | } 5 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | from pathlib import Path 16 | HERE = Path(__file__).resolve().parent 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | sys.path.append(str(HERE / 'ext')) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = 'tslumd' 24 | copyright = '2021, Matthew Reid' 25 | author = 'Matthew Reid' 26 | 27 | # The full version, including alpha/beta/rc tags 28 | try: 29 | import importlib.metadata 30 | release = importlib.metadata.version(project) 31 | except ImportError: 32 | release = '0.0.0' 33 | version = release 34 | 35 | # -------------------------------------------------- 36 | # Hacked on Sphinx v2.2.2 37 | # https://github.com/sphinx-doc/sphinx/tree/0c48a28ad7216ee064b0db564745d749c049bfd5 38 | 39 | from sphinx.ext.napoleon.docstring import GoogleDocstring 40 | from propertyobj import _parse_propertyobj_section 41 | 42 | def _parse_attributes_section_monkeyed(self, section): 43 | return self._format_fields(section.title(), self._consume_fields()) 44 | 45 | GoogleDocstring._parse_attributes_section_monkeyed = _parse_attributes_section_monkeyed 46 | GoogleDocstring._parse_propertyobj_section = _parse_propertyobj_section 47 | 48 | def _load_custom_sections(self): 49 | for key in ['attributes', 'class attributes']: 50 | self._sections[key] = self._parse_attributes_section_monkeyed 51 | self._sections['properties'] = self._parse_propertyobj_section 52 | 53 | GoogleDocstring._load_custom_sections = _load_custom_sections 54 | 55 | # ------------------------------------------------- 56 | 57 | # -- General configuration --------------------------------------------------- 58 | 59 | # Add any Sphinx extension module names here, as strings. They can be 60 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 61 | # ones. 62 | extensions = [ 63 | 'sphinx.ext.autodoc', 64 | 'sphinx.ext.napoleon', 65 | # 'sphinx_autodoc_typehints', 66 | 'sphinx.ext.doctest', 67 | 'sphinx.ext.viewcode', 68 | 'sphinx.ext.intersphinx', 69 | 'sphinx.ext.todo', 70 | 'pytest_doctestplus.sphinx.doctestplus', 71 | 'propertyobj', 72 | 'builtinproperty', 73 | 'eventobj', 74 | ] 75 | 76 | autodoc_member_order = 'bysource' 77 | autodoc_default_options = { 78 | 'show-inheritance':True, 79 | } 80 | 81 | 82 | # Add any paths that contain templates here, relative to this directory. 83 | templates_path = ['_templates'] 84 | 85 | # List of patterns, relative to source directory, that match files and 86 | # directories to ignore when looking for source files. 87 | # This pattern also affects html_static_path and html_extra_path. 88 | exclude_patterns = [] 89 | 90 | 91 | # -- Options for HTML output ------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | # 96 | html_theme = 'sphinx_rtd_theme' 97 | 98 | # Add any paths that contain custom static files (such as style sheets) here, 99 | # relative to this directory. They are copied after the builtin static files, 100 | # so a file named "default.css" will overwrite the builtin "default.css". 101 | html_static_path = ['_static'] 102 | 103 | html_css_files = [ 104 | 'css/custom.css', 105 | ] 106 | 107 | 108 | intersphinx_mapping = { 109 | 'python':('https://docs.python.org/', None), 110 | 'pydispatch': ('https://python-dispatch.readthedocs.io/en/latest/', None), 111 | } 112 | -------------------------------------------------------------------------------- /doc/source/conftest.py: -------------------------------------------------------------------------------- 1 | # from loguru import logger 2 | import pytest 3 | import asyncio 4 | import socket 5 | 6 | @pytest.fixture(scope='session') 7 | def non_loopback_hostaddr(): 8 | hostname, aliases, addrs = socket.gethostbyname_ex(socket.gethostname()) 9 | addrs = [addr for addr in addrs if addr != '127.0.0.1'] 10 | assert len(addrs) 11 | return addrs[0] 12 | 13 | 14 | @pytest.fixture(scope='function') 15 | def new_loop(request, doctest_namespace): 16 | policy = asyncio.get_event_loop_policy() 17 | loop = policy.new_event_loop() 18 | policy.set_event_loop(loop) 19 | doctest_namespace['loop'] = loop 20 | yield loop 21 | loop.close() 22 | policy.set_event_loop(None) 23 | 24 | def receiver_setup(request, loop, hostaddr): 25 | cleanup_coro = None 26 | from tslumd import UmdSender, TallyType, TallyColor 27 | 28 | sender = UmdSender(clients=[(hostaddr, 65000)], all_off_on_close=False) 29 | screen_index = 1 30 | 31 | async def open_sender(): 32 | await sender.open() 33 | sender_task = asyncio.create_task(run_sender()) 34 | return sender_task 35 | 36 | async def run_sender(): 37 | await sender.connected_evt.wait() 38 | for i in range(1, 5): 39 | tally_key = (screen_index, i) 40 | sender.set_tally_text(tally_key, f'Camera {i}') 41 | for i, color in ((1, TallyColor.RED), (2, TallyColor.GREEN)): 42 | await asyncio.sleep(.5) 43 | sender.set_tally_color((screen_index, i), TallyType.rh_tally, color) 44 | return sender 45 | 46 | sender_task = loop.run_until_complete(open_sender()) 47 | 48 | async def cleanup(): 49 | await sender_task 50 | await sender.close() 51 | 52 | yield 53 | loop.run_until_complete(cleanup()) 54 | if not loop.is_closed(): 55 | loop.close() 56 | asyncio.set_event_loop_policy(None) 57 | 58 | @pytest.fixture(scope="function", autouse=True) 59 | def doctest_stuff(request, new_loop, non_loopback_hostaddr): 60 | node_name = request.node.name 61 | loop = asyncio.get_event_loop() 62 | assert loop is new_loop 63 | if node_name == 'receiver.rst': 64 | yield from receiver_setup(request, new_loop, non_loopback_hostaddr) 65 | else: 66 | yield 67 | -------------------------------------------------------------------------------- /doc/source/ext/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nocarryr/tslumd/f3df164dae57ca8a9babe83cbe0390be6856a5da/doc/source/ext/__init__.py -------------------------------------------------------------------------------- /doc/source/ext/builtinproperty.py: -------------------------------------------------------------------------------- 1 | from sphinx import addnodes 2 | from docutils import nodes 3 | from docutils.parsers.rst import directives 4 | from sphinx.domains.python import PyXRefRole, PyMethod 5 | from sphinx.util import logging 6 | from sphinx.ext import autodoc 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | ANNO_CLS = nodes.emphasis 11 | 12 | def build_xref(title, target, innernode=ANNO_CLS, contnode=None, env=None): 13 | refnode = addnodes.pending_xref('', refdomain='py', refexplicit=False, reftype='class', reftarget=target) 14 | 15 | refnode += contnode or innernode(title, title) 16 | if env: 17 | env.get_domain('py').process_field_xref(refnode) 18 | return refnode 19 | 20 | class BuiltinPropertyDocumenter(autodoc.PropertyDocumenter): 21 | """An autodoc Documenter for built-in :func:`property` objects (descriptors 22 | defined with the `@property` decorator) 23 | 24 | This overrides the existing :class:`autodoc.PropertyDocumenter` and uses a 25 | `.. builtinproperty::` directive to provide type information and `readonly` 26 | status (see :class:`BuiltinPropertyDirective`). 27 | 28 | :pep:`484` annotations on the `getter` method will be passed to the 29 | :attr:`~BuiltinPropertyDirective.proptype` option of the directive. 30 | 31 | If no `setter` method is present, the property is assumed to be read-only 32 | and will be passed to :attr:`BuiltinPropertyDirective.readonly` 33 | 34 | """ 35 | priority = autodoc.PropertyDocumenter.priority + 1 36 | directivetype = 'builtinproperty' 37 | 38 | def add_directive_header(self, sig: str) -> None: 39 | sourcename = self.get_sourcename() 40 | anno = self.object.fget.__annotations__.get('return') 41 | readonly = self.object.fset is None 42 | autodoc.Documenter.add_directive_header(self, sig) 43 | self.add_line(' :property:', sourcename) 44 | if readonly: 45 | self.add_line(f' :readonly:', sourcename) 46 | if anno is not None: 47 | anno_type = getattr(anno, '__qualname__', None) 48 | if anno_type is None: 49 | anno_type = str(anno) 50 | self.add_line(f' :proptype: {anno_type}', sourcename) 51 | 52 | class BuiltinPropertyDirective(PyMethod): 53 | """A directive for more informative :func:`property` documentation 54 | 55 | Options 56 | ------- 57 | 58 | :param str proptype: The property type 59 | :param bool readonly: Indicates if the property is read-only (no `setter`) 60 | """ 61 | option_spec = PyMethod.option_spec.copy() 62 | option_spec.update({ 63 | 'proptype':directives.unchanged, 64 | 'readonly':directives.flag, 65 | }) 66 | 67 | def handle_signature(self, sig, signode): 68 | rtype = self.retann = self.options.get('proptype') 69 | r = super().handle_signature(sig, signode) 70 | if rtype is not None: 71 | anno = addnodes.desc_returns('', '') 72 | anno += build_xref(rtype, rtype) 73 | signode += anno 74 | if 'readonly' in self.options: 75 | # signode['classes'].append('readonly') 76 | # t = ' [{}]'.format(l_('READ ONLY')) 77 | t = ' [read-only]' 78 | signode += nodes.emphasis(t, t)#, classes=['readonly-label'])#, 'align-center']) 79 | return r 80 | 81 | def setup(app): 82 | app.setup_extension('sphinx.ext.autodoc') 83 | 84 | app.add_directive_to_domain('py', 'builtinproperty', BuiltinPropertyDirective) 85 | builtinproperty_role = PyXRefRole() 86 | app.add_role_to_domain('py', 'builtinproperty', builtinproperty_role) 87 | 88 | app.add_autodocumenter(BuiltinPropertyDocumenter, override=True) 89 | return { 90 | 'version': '0.1', 91 | 'parallel_read_safe': True, 92 | 'parallel_write_safe': True, 93 | } 94 | -------------------------------------------------------------------------------- /doc/source/ext/eventobj.py: -------------------------------------------------------------------------------- 1 | from docutils import nodes 2 | from docutils.parsers.rst import directives 3 | from sphinx.domains.python import PyFunction, PyXRefRole 4 | 5 | class EventDirective(PyFunction): 6 | pass 7 | 8 | def setup(app): 9 | app.add_directive_to_domain('py', 'event', EventDirective) 10 | event_role = PyXRefRole() 11 | app.add_role_to_domain('py', 'event', event_role) 12 | return { 13 | 'version': '0.1', 14 | 'parallel_read_safe': True, 15 | 'parallel_write_safe': True, 16 | } 17 | -------------------------------------------------------------------------------- /doc/source/ext/propertyobj.py: -------------------------------------------------------------------------------- 1 | from sphinx import addnodes 2 | from docutils import nodes 3 | from docutils.parsers.rst import directives 4 | from sphinx.domains.python import PyXRefRole, PyAttribute 5 | 6 | def _parse_propertyobj_section(self, section): 7 | """Injected into :class:`sphinx.ext.napoleon.docstring.GoogleDocstring` 8 | to transform a `Properties` section and add `.. propertyobj` directives 9 | 10 | (monkeypatching is done in conf.py) 11 | """ 12 | lines = [] 13 | field_type = ':Properties:' 14 | padding = ' ' * len(field_type) 15 | fields = self._consume_fields() 16 | multi = len(fields) > 1 17 | lines.append(field_type) 18 | for _name, _type, _desc in fields: 19 | field_block = [] 20 | field_block.append(f'.. propertyobj:: {_name}') 21 | if _type: 22 | field_block.extend(self._indent([f':type: {_type}'], 3)) 23 | prop_cls = 'Property' 24 | if _type == 'dict': 25 | prop_cls = 'DictProperty' 26 | elif _type == 'list': 27 | prop_cls = 'ListProperty' 28 | field_block.extend(self._indent([f':propcls: {prop_cls}'], 3)) 29 | # field_block.append(f'.. propertyobj:: {_name} -> :class:`~pydispatch.properties.{prop_cls}`(:class:`{_type}`)') 30 | field_block.append('') 31 | field = self._format_field('', '', _desc) 32 | field_block.extend(self._indent(field, 3)) 33 | field_block.append('') 34 | lines.extend(self._indent(field_block, 3)) 35 | return lines 36 | 37 | ANNO_CLS = nodes.emphasis 38 | 39 | def build_xref(title, target, innernode=ANNO_CLS, contnode=None, env=None): 40 | refnode = addnodes.pending_xref('', refdomain='py', refexplicit=False, reftype='class', reftarget=target) 41 | 42 | refnode += contnode or innernode(title, title) 43 | if env: 44 | env.get_domain('py').process_field_xref(refnode) 45 | return refnode 46 | 47 | class PropertyObjDirective(PyAttribute): 48 | """A directive for documenting :class:`pydispatch.properties.Property` objects 49 | 50 | Options 51 | ------- 52 | 53 | :param str propcls: The name of the :class:`pydispatch.properties.Property` 54 | class or subclass (`'Property'`, `'ListProperty'`, `'DictProperty'`). 55 | :param str type: The data type for the property. Only useful for non-container 56 | Property types (for `DictProperty` and `ListProperty`, 57 | this would be `'list'` or `'dict'`) 58 | 59 | .. note:: 60 | 61 | The values for :attr:`propcls` and :attr:`type` should be detected by 62 | :func:`_parse_propertyobj_section` if using napoleon 63 | """ 64 | option_spec = PyAttribute.option_spec.copy() 65 | option_spec.update({ 66 | 'type':directives.unchanged, 67 | 'propcls':directives.unchanged, 68 | }) 69 | def run(self): 70 | self._build_prop_anno() 71 | return super().run() 72 | # def add_target_and_index(self, name, sig, signode): 73 | # return super().add_target_and_index(name, sig, signode) 74 | def handle_signature(self, sig, signode): 75 | r = super().handle_signature(sig, signode) 76 | signode += self._annotation_node 77 | return r 78 | def _build_prop_anno(self): 79 | self.options.setdefault('type', 'None') 80 | prop_type = self.options['type'] 81 | prop_cls = self.options.get('propcls') 82 | if prop_cls is None: 83 | prop_cls = 'Property' 84 | if prop_type == 'dict': 85 | prop_cls = 'DictProperty' 86 | elif prop_type == 'list': 87 | prop_cls = 'ListProperty' 88 | prop_cls_xr = f':class:`~pydispatch.properties.{prop_cls}`' 89 | prop_type_xr = f':class:`{prop_type}`' 90 | 91 | # self.options['annotation'] = f'{prop_cls_xr}({prop_cls})' 92 | anno = addnodes.desc_returns('', '') 93 | # anno += ANNO_CLS(' -> ', ' -> ') 94 | anno += build_xref(prop_cls, f'pydispatch.properties.{prop_cls}', env=self.env) 95 | anno += ANNO_CLS('(', '(') 96 | anno += build_xref(prop_type, prop_type, env=self.env) 97 | anno += ANNO_CLS(')', ')') 98 | self._annotation_node = anno 99 | 100 | def setup(app): 101 | app.add_directive_to_domain('py', 'propertyobj', PropertyObjDirective) 102 | propertyobj_role = PyXRefRole() 103 | app.add_role_to_domain('py', 'propertyobj', propertyobj_role) 104 | return { 105 | 'version': '0.1', 106 | 'parallel_read_safe': True, 107 | 'parallel_write_safe': True, 108 | } 109 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. tslumd documentation master file, created by 2 | sphinx-quickstart on Sun Mar 28 13:37:30 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | 7 | .. include:: ../../README.rst 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | overview 14 | receiver 15 | sender 16 | protocol 17 | reference/index.rst 18 | 19 | Indices and tables 20 | ================== 21 | 22 | * :ref:`genindex` 23 | * :ref:`modindex` 24 | * :ref:`search` 25 | -------------------------------------------------------------------------------- /doc/source/overview.rst: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | 4 | This project uses the `Observer Pattern`_ as its primary means of integration 5 | with other code. This is handled by the `python-dispatch`_ library which provides 6 | methods of `subscribing to events`_ and `property changes`_. 7 | 8 | .. currentmodule:: tslumd.tallyobj 9 | .. _tally-object: 10 | 11 | Tally Object 12 | ------------ 13 | 14 | The primary object used for sending or receiving tally information is the 15 | :class:`Tally` object. 16 | 17 | Indicator Properties 18 | ^^^^^^^^^^^^^^^^^^^^ 19 | 20 | It has properties which hold the 21 | :class:`color ` for the three :ref:`indicator ` 22 | values (:attr:`Tally.lh_tally`, :attr:`Tally.txt_tally` and :attr:`Tally.rh_tally`) 23 | among others. 24 | 25 | These are :class:`~pydispatch.properties.Property` objects which act as observable 26 | :term:`descriptors `, meaning callbacks can be invoked when their 27 | values change. 28 | 29 | The :meth:`Tally.set_color` and :meth:`Tally.get_color` methods can also be used 30 | to get and set the values. 31 | 32 | 33 | Container Support 34 | ^^^^^^^^^^^^^^^^^ 35 | 36 | The indicator properties can be retrieved and assigned using :ref:`subscription ` 37 | notation (``color = tally[key]``, ``tally[key] = color``). In this form, the 38 | expected key and value types match that of :meth:`Tally.set_color` and the return 39 | values match the description in :meth:`Tally.get_color`. 40 | 41 | The example shown in :meth:`Tally.get_color` could be rewritten as: 42 | 43 | .. doctest:: 44 | 45 | >>> from tslumd import Tally 46 | >>> tally = Tally(0) 47 | >>> tally['rh_tally'] 48 | 49 | >>> tally['rh_tally'] = 'red' 50 | >>> tally['rh_tally'] 51 | 52 | >>> tally['txt_tally'] = 'red' 53 | >>> tally['rh_tally|txt_tally'] 54 | 55 | >>> tally['all'] 56 | 57 | >>> tally['lh_tally'] = 'green' 58 | >>> tally['lh_tally'] 59 | 60 | >>> tally['all'] 61 | 62 | 63 | 64 | Events 65 | ^^^^^^ 66 | 67 | When receiving, the Tally object will emit an 68 | :event:`Tally.on_update` event on any change of state with the 69 | Tally instance as the first argument and the property names as the second: 70 | 71 | .. doctest:: 72 | 73 | >>> from tslumd import Tally, TallyColor 74 | >>> def my_callback(tally: Tally, props_changed, **kwargs): 75 | ... for name in props_changed: 76 | ... value = getattr(tally, name) 77 | ... print(f'my_callback: {tally!r}.{name} = {value}') 78 | >>> tally = Tally(0) 79 | >>> # bind `my_callback` to the `on_update` event 80 | >>> tally.bind(on_update=my_callback) 81 | >>> # rh_tally is initialized to "OFF" 82 | >>> tally.rh_tally 83 | 84 | >>> tally.rh_tally = TallyColor.RED 85 | my_callback: .rh_tally = RED 86 | 87 | 88 | One can also subscribe to any of the properties individually: 89 | 90 | .. doctest:: 91 | 92 | >>> from tslumd import Tally, TallyColor 93 | >>> def my_callback(tally: Tally, value, **kwargs): 94 | ... prop = kwargs['property'] 95 | ... print(f'my_callback: {tally!r}.{prop.name} = {value}') 96 | >>> tally = Tally(0, text='foo') 97 | >>> tally 98 | 99 | >>> # bind `my_callback` to the `text` property 100 | >>> tally.bind(text=my_callback) 101 | >>> # does not reach the callback 102 | >>> tally.rh_tally = TallyColor.RED 103 | >>> # but this does 104 | >>> tally.text = 'bar' 105 | my_callback: .text = bar 106 | 107 | 108 | .. _screen-object: 109 | 110 | Screen Object 111 | ------------- 112 | 113 | :class:`Tally` objects should never be created directly (as in 114 | the examples above). They are instead created by the :class:`Screen` object 115 | and stored in its :attr:`Screen.tallies` dictionary, using the Tally's 116 | :attr:`index ` as the key. 117 | 118 | When receiving, they are created automatically when necessary (when data is 119 | received). The :event:`Screen.on_tally_added` event can be used 120 | to listen for new Tally objects. 121 | 122 | Screens also propagate the "on_update" event for all of their Tally objects and 123 | emit their own :event:`Screen.on_tally_update` event. 124 | This can reduce the amount of code and callbacks when handling multiple tallies. 125 | 126 | When sending, Tally objects are created by using either the 127 | :meth:`Screen.add_tally` and :meth:`Screen.get_or_create_tally` methods. 128 | 129 | 130 | Glossary 131 | -------- 132 | 133 | .. glossary:: 134 | 135 | TallyKey 136 | Combination of :attr:`Screen.index` and :attr:`Tally.index` used to 137 | uniquely identify a single tally (or :term:`Display`) within a single 138 | :term:`Screen`. 139 | 140 | :data:`~tslumd.common.TallyKey` is a 2-tuple of integers and is available 141 | as the :attr:`Tally.id`. 142 | 143 | TallyType 144 | :class:`~tslumd.common.TallyType` is an enum used to aid in mapping 145 | the three Tally :ref:`Tally Indicators ` to the 146 | :attr:`Tally.lh_tally`, :attr:`Tally.txt_tally` and 147 | :attr:`Tally.rh_tally` attributes 148 | 149 | TallyColor 150 | :class:`~tslumd.common.TallyColor` is an enum defining the four 151 | allowable colors for an :ref:`indicator ` (including "off") 152 | 153 | 154 | .. _Observer Pattern: https://en.wikipedia.org/wiki/Observer_pattern 155 | .. _python-dispatch: https://pypi.org/project/python-dispatch/ 156 | .. _subscribing to events: https://python-dispatch.readthedocs.io/en/latest/dispatcher.html#usage 157 | .. _property changes: https://python-dispatch.readthedocs.io/en/latest/properties.html 158 | -------------------------------------------------------------------------------- /doc/source/protocol.rst: -------------------------------------------------------------------------------- 1 | Protocol Information 2 | ==================== 3 | 4 | Overview 5 | -------- 6 | 7 | The UMD protocols, developed by `TSL Products`_ are used throughout the 8 | broadcast video industry to control tally indicators in Multiviewer products and 9 | Under-Monitor Displays. The documentation for all versions can be found here: 10 | https://tslproducts.com/media/1959/tsl-umd-protocol.pdf 11 | 12 | 13 | This library focuses on the most recent version: UMDv5 14 | 15 | 16 | Physical Layer 17 | -------------- 18 | 19 | Packets are sent via UDP with a maximum length of 2048 bytes. There is an 20 | option to frame packets using TCP, but this is not currently implemented. 21 | 22 | 23 | Structure 24 | --------- 25 | 26 | Screen 27 | ^^^^^^ 28 | 29 | The primary entity within a packet is a "Screen", which can be thought of as 30 | addressing a specific device (such as a large Multiviewer). Screens are 31 | addressed by their index, which can be from 0 to 65534. 32 | 33 | Display 34 | ^^^^^^^ 35 | 36 | Within each Screen are "Displays" which can be thought of as a single monitor 37 | window of a Multiviewer. Displays are also addressed by index from 0 65534. 38 | 39 | A message packet for a single Screen can contain information for multiple 40 | Displays with the only limitation being the size of the packet itself (2048 bytes). 41 | 42 | 43 | .. _indicators: 44 | 45 | Indicators 46 | ^^^^^^^^^^ 47 | 48 | For each Display there are two main tally indicators either above or below the 49 | monitor window; one for the "left-hand" side (called :term:`lh_tally`) and one 50 | for the "right-hand" side (called :term:`rh_tally`). 51 | There is also typically a label to identify the source of the monitor. 52 | The label's text can be set as well as its color (:term:`txt_tally`). 53 | 54 | These three items can be set individually to specific colors to indicate status 55 | choosing from: 56 | 57 | * Off 58 | * Red 59 | * Green 60 | * Amber (yellow) 61 | 62 | 63 | 64 | Glossary 65 | -------- 66 | 67 | .. glossary:: 68 | 69 | Packet 70 | A single message of up to 2048 bytes containing tally information for 71 | a single :term:`Screen` 72 | 73 | Screen 74 | Conceptually, a collection of :term:`Displays `. Physically, 75 | a screen is typically a Multiviewer (a large monitor showing many 76 | smaller, windowed displays). 77 | 78 | Addressed by an index from 0 to 65534 79 | 80 | Display 81 | A single tally display within a :term:`Screen`. A display can show text 82 | information and can have up to three 83 | tally indicators: :term:`lh_tally`, :term:`rh_tally` and :term:`txt_tally`. 84 | 85 | Addressed by an index from 0 to 65534 86 | 87 | lh_tally 88 | A tally indicator on the "left-hand side" of a :term:`Display` which can 89 | be illuminated in red, green or amber (yellow) 90 | 91 | rh_tally 92 | A tally indicator on the "right-hand side" of a :term:`Display` which can 93 | be illuminated in red, green or amber (yellow) 94 | 95 | txt_tally 96 | Typically used to control the text color for a :term:`Display` which can 97 | be one of red, green or amber (yellow) 98 | 99 | Broadcast Screen 100 | A reserved :term:`Screen` index of 65535 (``0xffff``) that is meant to 101 | apply to all screens, regardless of their index 102 | 103 | Broadcast Display 104 | A reserved :term:`Display` index of 65535 (``0xffff``) that is meant to 105 | apply to all displays within a screen, regardless of their index 106 | 107 | 108 | 109 | .. _TSL Products: https://tslproducts.com 110 | -------------------------------------------------------------------------------- /doc/source/receiver.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: tslumd.receiver 2 | 3 | Receiver 4 | ======== 5 | 6 | The :class:`~UmdReceiver` class is used to listen for and process 7 | UMD :term:`packets ` from the network (typically from devices such as 8 | video switchers). 9 | 10 | 11 | Starting and Stopping 12 | --------------------- 13 | 14 | The receiver does not begin communication when first created. 15 | 16 | Starting and stopping can be done by calling the :meth:`UmdReceiver.open` and 17 | :meth:`UmdReceiver.close` methods manually 18 | 19 | .. doctest:: UmdReceiver-open-close 20 | 21 | >>> import asyncio 22 | >>> from tslumd import UmdReceiver 23 | >>> async def run(): 24 | ... receiver = UmdReceiver() 25 | ... await receiver.open() 26 | ... ... 27 | ... await receiver.close() 28 | >>> asyncio.run(run()) 29 | 30 | or it can be used as an :term:`asynchronous context manager` 31 | in an :keyword:`async with` block 32 | 33 | .. doctest:: UmdReceiver-async-with 34 | 35 | >>> import asyncio 36 | >>> from tslumd import UmdReceiver 37 | >>> async def run(): 38 | ... receiver = UmdReceiver() 39 | ... async with receiver: 40 | ... ... 41 | >>> asyncio.run(run()) 42 | 43 | 44 | Object Access 45 | ------------- 46 | 47 | While running, it will create :ref:`Screens ` and 48 | :ref:`Tallies ` as information for them arrives. Screens are stored 49 | in the :attr:`~UmdReceiver.screens` dictionary using their 50 | :attr:`~tslumd.tallyobj.Screen.index` as keys. 51 | 52 | While each Screen object contains its own Tally instances, the Receiver stores 53 | all Tally objects from all Screens in its own :attr:`~UmdReceiver.tallies` 54 | dictionary by their :attr:`Tally.id ` (:term:`TallyKey`) 55 | 56 | 57 | Events 58 | ------ 59 | 60 | The :event:`UmdReceiver.on_screen_added` event is used to listen for new 61 | Screen objects and the :event:`UmdReceiver.on_tally_added` is used to listen 62 | for new Tally objects (which will be fired for all new Tallies across all Screens). 63 | 64 | Like Screens, the Receiver will propagate the 65 | :event:`~tslumd.tallyobj.Screen.on_tally_update` event of each Screen and emit 66 | its own :event:`UmdReceiver.on_tally_updated` event. 67 | Because of this, one may only need to subscribe to a single event to handle 68 | all Tally changes across all Screens 69 | 70 | 71 | Example 72 | ------- 73 | 74 | In following example, assume a device is sending tally information for four 75 | tallies labeled ``"Camera 1", "Camera 2", "Camera 3", "Camera 4"``. 76 | They are indexed 1 through 4 and their screen index is 1. 77 | 78 | .. doctest:: UmdReceiver-events 79 | 80 | >>> import asyncio 81 | >>> from tslumd import UmdReceiver 82 | >>> def screen_added(screen, **kwargs): 83 | ... print(f'screen_added: {screen!r}') 84 | >>> def tally_added(tally, **kwargs): 85 | ... print(f'tally_added: {tally!r}') 86 | >>> def tally_updated(tally, props_changed, **kwargs): 87 | ... for name in props_changed: 88 | ... value = getattr(tally, name) 89 | ... print(f'tally_updated: {tally!r}.{name} = {value}') 90 | >>> receiver = UmdReceiver() 91 | >>> receiver.bind( 92 | ... on_screen_added=screen_added, 93 | ... on_tally_added=tally_added, 94 | ... on_tally_updated=tally_updated, 95 | ... ) 96 | >>> async def run(): 97 | ... async with receiver: 98 | ... await asyncio.sleep(2) 99 | >>> loop.run_until_complete(run()) 100 | screen_added: 101 | tally_added: 102 | tally_added: 103 | tally_added: 104 | tally_added: 105 | tally_updated: .rh_tally = RED 106 | tally_updated: .rh_tally = GREEN 107 | 108 | When the receiver first opens, the :event:`~UmdReceiver.on_screen_added` and 109 | :event:`~UmdReceiver.on_tally_added` events are triggered once they are detected. 110 | 111 | After a second or so, the tally for "Camera 1" is set to red and "Camera 2" is 112 | set to green. Both of these trigger the 113 | :event:`~UmdReceiver.on_tally_updated` event as shown above. 114 | 115 | 116 | .. todo:: 117 | The UmdReceiver.on_tally_updated and Screen.on_tally_update event names 118 | are inconsistent. One of the two needs to be decided on. 119 | -------------------------------------------------------------------------------- /doc/source/reference/common.rst: -------------------------------------------------------------------------------- 1 | :mod:`tslumd.common` 2 | ==================== 3 | 4 | .. automodule:: tslumd.common 5 | :members: 6 | -------------------------------------------------------------------------------- /doc/source/reference/index.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | :mod:`tslumd` 5 | ------------- 6 | 7 | .. automodule:: tslumd 8 | :members: 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | 13 | common 14 | tallyobj 15 | messages 16 | receiver 17 | sender 18 | -------------------------------------------------------------------------------- /doc/source/reference/messages.rst: -------------------------------------------------------------------------------- 1 | :mod:`tslumd.messages` 2 | ====================== 3 | 4 | .. automodule:: tslumd.messages 5 | :members: 6 | -------------------------------------------------------------------------------- /doc/source/reference/receiver.rst: -------------------------------------------------------------------------------- 1 | :mod:`tslumd.receiver` 2 | ====================== 3 | 4 | .. automodule:: tslumd.receiver 5 | :members: 6 | -------------------------------------------------------------------------------- /doc/source/reference/sender.rst: -------------------------------------------------------------------------------- 1 | :mod:`tslumd.sender` 2 | ==================== 3 | 4 | .. automodule:: tslumd.sender 5 | :members: 6 | -------------------------------------------------------------------------------- /doc/source/reference/tallyobj.rst: -------------------------------------------------------------------------------- 1 | :mod:`tslumd.tallyobj` 2 | ====================== 3 | 4 | .. automodule:: tslumd.tallyobj 5 | :members: 6 | -------------------------------------------------------------------------------- /doc/source/sender.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: tslumd.sender 2 | 3 | Sender 4 | ====== 5 | 6 | The :class:`UmdSender` class is used to send tally information as UMD packets 7 | to clients on the network. 8 | 9 | The remote addresses can be specified on initialization by giving a Sequence of 10 | tuples containing the address and port (:data:`Client`). The 11 | :attr:`UmdSender.clients` container may also be modified on the instance if 12 | clients need to be added or removed while running. 13 | 14 | .. note:: 15 | Modifying :attr:`UmdSender.clients` is not a thread-safe operation and should 16 | only be done from within the same thread the instance belongs to. 17 | 18 | 19 | Starting and Stopping 20 | --------------------- 21 | 22 | UmdSender does not begin communication when first created. 23 | 24 | Starting and stopping can be done by calling the :meth:`UmdSender.open` and 25 | :meth:`UmdSender.close` methods manually 26 | 27 | .. doctest:: UmdSender-open-close 28 | 29 | >>> import asyncio 30 | >>> from tslumd import UmdSender 31 | >>> async def run(): 32 | ... sender = UmdSender(clients=[('127.0.0.1', 65000)]) 33 | ... await sender.open() 34 | ... ... 35 | ... await sender.close() 36 | >>> asyncio.run(run()) 37 | 38 | or it can be used as an :term:`asynchronous context manager` 39 | in an :keyword:`async with` block 40 | 41 | .. doctest:: UmdSender-async-with 42 | 43 | >>> import asyncio 44 | >>> from tslumd import UmdSender 45 | >>> async def run(): 46 | ... sender = UmdSender(clients=[('127.0.0.1', 65000)]) 47 | ... async with sender: 48 | ... ... 49 | >>> asyncio.run(run()) 50 | 51 | 52 | Object Access 53 | ------------- 54 | 55 | UmdSender creates :ref:`Screens ` and 56 | :ref:`Tallies ` using one of the following methods: 57 | 58 | * :meth:`UmdSender.add_tally` 59 | * :meth:`UmdSender.get_or_create_tally` 60 | * :meth:`UmdSender.get_or_create_screen` 61 | 62 | Additionally, it will create objects as needed when one of the 63 | `Shortcut Methods`_ are used. 64 | 65 | Screens are stored in the :attr:`UmdSender.screens` dictionary using their 66 | :attr:`~tslumd.tallyobj.Screen.index` as keys. 67 | 68 | While each Screen object contains its own Tally instances, UmdSender stores 69 | all Tally objects from all Screens in its own :attr:`~UmdSender.tallies` 70 | dictionary by their :attr:`Tally.id ` (:term:`TallyKey`) 71 | 72 | 73 | Sending Tally 74 | ------------- 75 | 76 | Shortcut Methods 77 | ^^^^^^^^^^^^^^^^ 78 | 79 | In :class:`UmdSender`, there are several shortcut methods defined to create 80 | and update tallies without needing to operate on :class:`~tslumd.tallyobj.Tally` 81 | objects directly. 82 | 83 | All of these methods operate using a :term:`TallyKey` to specify the 84 | :ref:`Screen ` and :ref:`Tally `. 85 | 86 | For :meth:`UmdSender.set_tally_text`, the :term:`TallyKey` and the text are the 87 | only two arguments. 88 | 89 | For :meth:`UmdSender.set_tally_color`, the :term:`TallyKey`, :term:`TallyType` 90 | and :term:`TallyColor` arguments are used. 91 | 92 | .. doctest:: UmdSender-shortcuts 93 | 94 | >>> from pprint import pprint 95 | >>> from tslumd import UmdSender, TallyType, TallyColor 96 | >>> sender = UmdSender(clients=[('127.0.0.1', 65000)]) 97 | >>> loop.run_until_complete(sender.open()) 98 | >>> for cam_num in range(1, 5): 99 | ... sender.set_tally_text((1, cam_num), f'Camera {cam_num}') # Creates a new Tally 100 | >>> pprint(sender.tallies) 101 | {(1, 1): , 102 | (1, 2): , 103 | (1, 3): , 104 | (1, 4): } 105 | >>> sender.set_tally_color((1, 1), TallyType.rh_tally, TallyColor.RED) 106 | >>> cam1_tally = sender.tallies[(1, 1)] 107 | >>> pprint(cam1_tally.rh_tally) 108 | 109 | >>> # Rename "Camera 4" so you remember not to take their shot for too long 110 | >>> sender.set_tally_text((1, 4), 'Handheld') 111 | >>> pprint(sender.tallies) 112 | {(1, 1): , 113 | (1, 2): , 114 | (1, 3): , 115 | (1, 4): } 116 | >>> loop.run_until_complete(sender.close()) 117 | 118 | 119 | Direct Tally Changes 120 | ^^^^^^^^^^^^^^^^^^^^ 121 | 122 | In the example above, all of the changes would be sent automatically if the 123 | UmdSender were open (and the event loop running). 124 | To accomplish this, it listens for property changes on each 125 | :class:`~tslumd.tallyobj.Tally` and :class:`~tslumd.tallyobj.Screen` it contains. 126 | This also means that one can operate on a :class:`~tslumd.tallyobj.Tally` 127 | object directly. 128 | 129 | .. doctest:: UmdSender-tally-props 130 | 131 | >>> cam2_tally = sender.tallies[(1, 2)] 132 | >>> cam2_tally.text = 'Jim' 133 | >>> pprint(sender.tallies) 134 | {(1, 1): , 135 | (1, 2): , 136 | (1, 3): , 137 | (1, 4): } 138 | >>> cam2_tally.txt_tally = TallyColor.GREEN 139 | >>> pprint(sender.tallies[cam2_tally.id].txt_tally) 140 | 141 | 142 | 143 | Tally States on Shutdown 144 | ------------------------ 145 | 146 | In some cases, it may be desirable for all tally lights to be remain in their 147 | last state when UmdSender closes. It could also be preferable to ensure all 148 | of them are "off". 149 | 150 | This behavior can be set for either case by setting :attr:`UmdSender.all_off_on_close` 151 | either upon creation (as an init argument), or by setting the instance attribute 152 | (must be done before :meth:`~UmdSender.close` is called). 153 | 154 | The default behavior is to leave all tallies in their last state. If 155 | :attr:`~UmdSender.all_off_on_close` is set True however, messages will be 156 | sent for all tallies across all screens to be "OFF" right before 157 | shutdown. 158 | -------------------------------------------------------------------------------- /examples/animated_sender.py: -------------------------------------------------------------------------------- 1 | try: 2 | from loguru import logger 3 | except ImportError: # pragma: no cover 4 | import logging 5 | logger = logging.getLogger(__name__) 6 | import asyncio 7 | import socket 8 | import string 9 | import argparse 10 | import enum 11 | from typing import List, Dict, Tuple, Sequence, Iterable 12 | 13 | from pydispatch import Dispatcher, Property, DictProperty, ListProperty 14 | 15 | from tslumd import TallyColor, TallyType, Tally, UmdSender 16 | from tslumd.utils import logger_catch 17 | from tslumd.sender import ClientArgAction 18 | 19 | 20 | class AnimateMode(enum.Enum): 21 | vertical = 1 22 | horizontal = 2 23 | 24 | class TallyTypeGroup: 25 | tally_type: TallyType 26 | num_tallies: int 27 | tally_colors: List[TallyColor] 28 | def __init__(self, tally_type: TallyType, num_tallies: int): 29 | if tally_type == TallyType.no_tally: 30 | raise ValueError(f'TallyType cannot be {TallyType.no_tally}') 31 | self.tally_type = tally_type 32 | self.num_tallies = num_tallies 33 | self.tally_colors = [TallyColor.OFF for _ in range(num_tallies)] 34 | 35 | def reset_all(self, color: TallyColor = TallyColor.OFF): 36 | self.tally_colors[:] = [color for _ in range(self.num_tallies)] 37 | 38 | def update_tallies(self, tallies: Iterable[Tally]) -> List[int]: 39 | attr = self.tally_type.name 40 | changed = [] 41 | for tally in tallies: 42 | color = self.tally_colors[tally.index] 43 | cur_value = getattr(tally, attr) 44 | if cur_value == color: 45 | continue 46 | setattr(tally, attr, color) 47 | changed.append(tally.index) 48 | return changed 49 | 50 | class AnimatedSender(UmdSender): 51 | tally_groups: Dict[TallyType, TallyTypeGroup] 52 | def __init__(self, clients=None, num_tallies=8, update_interval=.5, screen=1, all_off_on_close=False): 53 | self.num_tallies = num_tallies 54 | self.update_interval = update_interval 55 | super().__init__(clients, all_off_on_close) 56 | self.screen = self.get_or_create_screen(screen) 57 | for i in range(self.num_tallies): 58 | self.screen.add_tally(i, text=string.ascii_uppercase[i]) 59 | 60 | self.tally_groups = {} 61 | for tally_type in TallyType.all(): 62 | tg = TallyTypeGroup(tally_type, self.num_tallies) 63 | self.tally_groups[tally_type] = tg 64 | 65 | async def open(self): 66 | if self.running: 67 | return 68 | await super().open() 69 | self.update_task = asyncio.create_task(self.update_loop()) 70 | 71 | async def close(self): 72 | if not self.running: 73 | return 74 | # self.running = False 75 | self.update_task.cancel() 76 | try: 77 | await self.update_task 78 | except asyncio.CancelledError: 79 | pass 80 | self.update_task = None 81 | await super().close() 82 | 83 | def set_animate_mode(self, mode: AnimateMode): 84 | self.animate_mode = mode 85 | if mode == AnimateMode.vertical: 86 | self.cur_group = TallyType.rh_tally 87 | self.cur_index = -2 88 | elif mode == AnimateMode.horizontal: 89 | self.cur_index = 0 90 | self.cur_group = TallyType.no_tally 91 | for tg in self.tally_groups.values(): 92 | tg.reset_all() 93 | 94 | def animate_tallies(self): 95 | if self.animate_mode == AnimateMode.vertical: 96 | self.animate_vertical() 97 | elif self.animate_mode == AnimateMode.horizontal: 98 | self.animate_horizontal() 99 | 100 | def animate_vertical(self): 101 | colors = [c for c in TallyColor if c != TallyColor.OFF] 102 | 103 | tg = self.tally_groups[self.cur_group] 104 | start_ix = self.cur_index 105 | tg.reset_all() 106 | 107 | for color in colors: 108 | ix = start_ix + color.value-1 109 | if 0 <= ix < self.num_tallies: 110 | tg.tally_colors[ix] = color 111 | start_ix += 1 112 | 113 | if start_ix > self.num_tallies: 114 | self.cur_index = -2 115 | if self.cur_group == TallyType.rh_tally: 116 | self.cur_group = TallyType.txt_tally 117 | elif self.cur_group == TallyType.txt_tally: 118 | self.cur_group = TallyType.lh_tally 119 | else: 120 | self.set_animate_mode(AnimateMode.horizontal) 121 | else: 122 | self.cur_index = start_ix 123 | 124 | def animate_horizontal(self): 125 | tally_types = [t for t in TallyType if t != TallyType.all_tally] 126 | while tally_types[0] != self.cur_group: 127 | t = tally_types.pop(0) 128 | tally_types.append(t) 129 | for i, t in enumerate(tally_types): 130 | if t == TallyType.no_tally: 131 | continue 132 | tg = self.tally_groups[t] 133 | tg.reset_all() 134 | try: 135 | color = TallyColor(i+1) 136 | except ValueError: 137 | color = TallyColor.OFF 138 | tg.tally_colors[self.cur_index] = color 139 | try: 140 | if self.cur_group.value == 0: 141 | v = 1 142 | else: 143 | v = self.cur_group.value * 2 144 | if v > TallyType.all_tally.value: 145 | raise ValueError() 146 | t = TallyType(v) 147 | self.cur_group = t 148 | except ValueError: 149 | self.cur_index += 1 150 | self.cur_group = TallyType.no_tally 151 | if self.cur_index >= self.num_tallies: 152 | self.set_animate_mode(AnimateMode.vertical) 153 | 154 | @logger_catch 155 | async def update_loop(self): 156 | self.set_animate_mode(AnimateMode.vertical) 157 | 158 | def update_tallies(): 159 | changed = set() 160 | for tg in self.tally_groups.values(): 161 | _changed = tg.update_tallies(self.screen.tallies.values()) 162 | changed |= set(_changed) 163 | return changed 164 | 165 | await self.connected_evt.wait() 166 | 167 | while self.running: 168 | await asyncio.sleep(self.update_interval) 169 | if not self.running: 170 | break 171 | self.animate_tallies() 172 | changed_ix = update_tallies() 173 | # changed_tallies = [self.tallies[i] for i in changed_ix] 174 | # await self.update_queue.put(changed_tallies) 175 | 176 | 177 | def main(): 178 | p = argparse.ArgumentParser() 179 | p.add_argument( 180 | '-c', '--client', dest='clients', action=ClientArgAction, 181 | ) 182 | p.add_argument( 183 | '-n', '--num-tallies', dest='num_tallies', type=int, default=8, 184 | ) 185 | p.add_argument( 186 | '-a', '--all-of-on-close', dest='all_off_on_close', action='store_true', 187 | ) 188 | p.add_argument( 189 | '-i', '--interval', dest='update_interval', type=float, default=.5, 190 | ) 191 | p.add_argument( 192 | '-s', '--screen', dest='screen', type=int, default=1, 193 | ) 194 | args = p.parse_args() 195 | 196 | logger.info(f'Sending to clients: {args.clients!r}') 197 | 198 | loop = asyncio.get_event_loop() 199 | sender = AnimatedSender(**vars(args)) 200 | 201 | # async def run(): 202 | # await sender.open() 203 | # await asyncio.sleep(10) 204 | # await sender.close() 205 | # try: 206 | # loop.run_until_complete(run()) 207 | # finally: 208 | # loop.close() 209 | 210 | loop.run_until_complete(sender.open()) 211 | try: 212 | loop.run_forever() 213 | except KeyboardInterrupt: 214 | loop.run_until_complete(sender.close()) 215 | finally: 216 | loop.close() 217 | 218 | if __name__ == '__main__': 219 | main() 220 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-asyncio 3 | pytest-cov 4 | pytest-doctestplus 5 | pytest-timeout 6 | Faker 7 | -r doc/requirements.txt 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = False 3 | 4 | [metadata] 5 | name = tslumd 6 | version = 0.0.6 7 | author = Matthew Reid 8 | author_email = matt@nomadic-recording.com 9 | url = https://github.com/nocarryr/tslumd 10 | description = Client and Server for TSLUMD Tally Protocols 11 | long_description = file: README.rst 12 | long_description_content_type = text/x-rst 13 | license = MIT 14 | license_file = LICENSE 15 | platforms = any 16 | python_requires = >=3.7 17 | classifiers = 18 | Development Status :: 2 - Pre-Alpha 19 | Natural Language :: English 20 | Operating System :: OS Independent 21 | Programming Language :: Python :: 3 22 | Programming Language :: Python :: 3.7 23 | Programming Language :: Python :: 3.8 24 | Programming Language :: Python :: 3.9 25 | Programming Language :: Python :: 3.10 26 | Programming Language :: Python :: 3.11 27 | Intended Audience :: Developers 28 | Intended Audience :: Information Technology 29 | Framework :: AsyncIO 30 | Topic :: Multimedia :: Video 31 | Topic :: Software Development :: Libraries 32 | Topic :: Software Development :: Libraries :: Python Modules 33 | 34 | 35 | [options] 36 | package_dir= 37 | =src 38 | packages = find: 39 | install_requires = 40 | python-dispatch 41 | 42 | 43 | [options.packages.find] 44 | where = src 45 | exclude = tests 46 | 47 | 48 | [options.package_data] 49 | * = LICENSE, README* 50 | 51 | [tool:pytest] 52 | testpaths = tests src doc 53 | addopts = --doctest-modules --doctest-glob="*.rst" 54 | doctest_plus = enabled 55 | timeout = 300 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /src/tslumd/__init__.py: -------------------------------------------------------------------------------- 1 | """Implementation of the `UMDv5.0 Protocol`_ by `TSL Products`_ for tally 2 | and other production display/control purposes. 3 | 4 | .. _UMDv5.0 Protocol: https://tslproducts.com/media/1959/tsl-umd-protocol.pdf 5 | .. _TSL Products: https://tslproducts.com 6 | """ 7 | import pkg_resources 8 | 9 | try: 10 | __version__ = pkg_resources.require('tslumd')[0].version 11 | except: # pragma: no cover 12 | __version__ = 'unknown' 13 | 14 | try: 15 | from loguru import logger 16 | except ImportError: # pragma: no cover 17 | import logging 18 | logging.basicConfig(format='%(asctime)s\t%(levelname)s\t%(message)s', level=logging.DEBUG) 19 | logger = logging.getLogger(__name__) 20 | from .common import * 21 | from .tallyobj import * 22 | from .messages import * 23 | from .receiver import * 24 | from .sender import * 25 | -------------------------------------------------------------------------------- /src/tslumd/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import enum 3 | from typing import Tuple, Iterator 4 | 5 | __all__ = ('TallyColor', 'TallyType', 'TallyState', 'MessageType', 'TallyKey') 6 | 7 | class TallyColor(enum.IntFlag): 8 | """Color enum for tally indicators 9 | 10 | Since this is an :class:`~enum.IntFlag`, its members can be combined using 11 | bitwise operators, making :attr:`AMBER` a combination of 12 | :attr:`RED` and :attr:`GREEN` 13 | 14 | This allows merging one color with another 15 | 16 | >>> from tslumd import TallyColor 17 | >>> TallyColor.RED 18 | 19 | >>> TallyColor.GREEN 20 | 21 | >>> TallyColor.AMBER 22 | 23 | >>> TallyColor.RED | TallyColor.GREEN 24 | 25 | 26 | 27 | .. versionchanged:: 0.0.4 28 | Bitwise operators 29 | """ 30 | OFF = 0 #: Off 31 | RED = 1 #: Red 32 | GREEN = 2 #: Green 33 | AMBER = RED | GREEN #: Amber 34 | 35 | @staticmethod 36 | def from_str(s: str) -> TallyColor: 37 | """Return the member matching the given name (case-insensitive) 38 | 39 | >>> TallyColor.from_str('RED') 40 | 41 | >>> TallyColor.from_str('green') 42 | 43 | >>> TallyColor.from_str('Amber') 44 | 45 | 46 | .. versionadded:: 0.0.5 47 | """ 48 | return getattr(TallyColor, s.upper()) 49 | 50 | def to_str(self) -> str: 51 | """The member name as a string 52 | 53 | >>> TallyColor.RED.to_str() 54 | 'RED' 55 | >>> TallyColor.GREEN.to_str() 56 | 'GREEN' 57 | >>> TallyColor.AMBER.to_str() 58 | 'AMBER' 59 | >>> (TallyColor.RED | TallyColor.GREEN).to_str() 60 | 'AMBER' 61 | 62 | .. versionadded:: 0.0.5 63 | """ 64 | return self.name 65 | 66 | def __str__(self): 67 | return self.name 68 | 69 | @classmethod 70 | def all(cls): 71 | """Iterate over all members 72 | 73 | .. versionadded:: 0.0.6 74 | """ 75 | yield from cls.__members__.values() 76 | 77 | def __format__(self, format_spec): 78 | if format_spec == '': 79 | return str(self) 80 | return super().__format__(format_spec) 81 | 82 | class TallyType(enum.IntFlag): 83 | """Enum for the three tally display types in the UMD protocol 84 | 85 | Since this is an :class:`~enum.IntFlag`, its members can be combined using 86 | bitwise operators. The members can then be iterated over to retrieve the 87 | individual "concrete" values of :attr:`rh_tally`, :attr:`txt_tally` 88 | and :attr:`lh_tally` 89 | 90 | >>> from tslumd import TallyType 91 | >>> list(TallyType.rh_tally) 92 | [] 93 | >>> list(TallyType.rh_tally | TallyType.txt_tally) 94 | [, ] 95 | >>> list(TallyType.all_tally) 96 | [, , ] 97 | 98 | .. versionchanged:: 0.0.4 99 | Added support for bitwise operators and member iteration 100 | """ 101 | no_tally = 0 #: No-op 102 | rh_tally = 1 #: :term:`Right-hand tally ` 103 | txt_tally = 2 #: :term:`Text tally ` 104 | lh_tally = 4 #: :term:`Left-hand tally ` 105 | all_tally = rh_tally | txt_tally | lh_tally 106 | """Combination of all tally types 107 | 108 | .. versionadded:: 0.0.4 109 | """ 110 | 111 | @property 112 | def is_iterable(self) -> bool: 113 | """Returns ``True`` if this is a combination of multiple members 114 | 115 | (meaning it must be iterated over) 116 | 117 | .. versionadded:: 0.0.5 118 | """ 119 | if self == TallyType.all_tally: 120 | return True 121 | mask = 1 << (self.bit_length() - 1) 122 | return self ^ mask != 0 123 | 124 | 125 | @classmethod 126 | def all(cls) -> Iterator[TallyType]: 127 | """Iterate over all members, excluding :attr:`no_tally` and :attr:`all_tally` 128 | 129 | .. versionadded:: 0.0.4 130 | """ 131 | for ttype in cls: 132 | if ttype != TallyType.no_tally and ttype != TallyType.all_tally: 133 | yield ttype 134 | 135 | @staticmethod 136 | def from_str(s: str) -> TallyType: 137 | """Create an instance from a string of member name(s) 138 | 139 | The string can be a single member or multiple member names separated by 140 | a "|". For convenience, the names may be shortened by omitting the 141 | ``"_tally"`` portion from the end ("rh" == "rh_tally", etc) 142 | 143 | >>> TallyType.from_str('rh_tally') 144 | 145 | >>> TallyType.from_str('rh|txt_tally') 146 | 147 | >>> TallyType.from_str('rh|txt|lh') 148 | 149 | >>> TallyType.from_str('all') 150 | 151 | 152 | .. versionadded:: 0.0.5 153 | """ 154 | if '|' in s: 155 | result = TallyType.no_tally 156 | for name in s.split('|'): 157 | result |= TallyType.from_str(name) 158 | return result 159 | s = s.lower() 160 | if not s.endswith('_tally'): 161 | s = f'{s}_tally' 162 | return getattr(TallyType, s) 163 | 164 | def to_str(self) -> str: 165 | """Create a string representation suitable for use in :meth:`from_str` 166 | 167 | >>> tt = TallyType.rh_tally 168 | >>> tt.to_str() 169 | 'rh_tally' 170 | >>> tt |= TallyType.txt_tally 171 | >>> tt.to_str() 172 | 'rh_tally|txt_tally' 173 | >>> tt |= TallyType.lh_tally 174 | >>> tt.to_str() 175 | 'all_tally' 176 | 177 | .. versionadded:: 0.0.5 178 | """ 179 | if self == TallyType.all_tally: 180 | return self.name 181 | if self.is_iterable: 182 | return '|'.join((obj.name for obj in self)) 183 | return self.name 184 | 185 | def __iter__(self) -> Iterator[TallyType]: 186 | for ttype in self.all(): 187 | if ttype in self: 188 | yield ttype 189 | 190 | def __repr__(self): 191 | return f'<{self.__class__.__name__}.{self.to_str()}: {self.value}>' 192 | 193 | 194 | class TallyState(enum.IntFlag): 195 | OFF = 0 #: Off 196 | PREVIEW = 1 #: Preview 197 | PROGRAM = 2 #: Program 198 | 199 | class MessageType(enum.Enum): 200 | """Message type 201 | 202 | .. versionadded:: 0.0.2 203 | """ 204 | _unset = 0 205 | display = 1 #: A message containing tally display information 206 | control = 2 #: A message containing control data 207 | 208 | TallyKey = Tuple[int, int] 209 | """A tuple of (:attr:`screen_index <.Screen.index>`, 210 | :attr:`tally_index <.Tally.index>`) to uniquely identify a single :class:`.Tally` 211 | within its :class:`.Screen` 212 | """ 213 | -------------------------------------------------------------------------------- /src/tslumd/messages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import asyncio 3 | import dataclasses 4 | from dataclasses import dataclass, field 5 | import enum 6 | import struct 7 | from typing import Tuple, Iterator, Any, cast 8 | 9 | from tslumd import MessageType, TallyColor, Tally 10 | 11 | __all__ = ( 12 | 'Display', 'Message', 'ParseError', 'MessageParseError', 13 | 'DmsgParseError', 'DmsgControlParseError', 'MessageLengthError', 14 | ) 15 | 16 | 17 | class ParseError(Exception): 18 | """Raised on errors during message parsing 19 | 20 | .. versionadded:: 0.0.2 21 | """ 22 | msg: str #: Error message 23 | value: bytes #: The relevant message bytes containing the error 24 | def __init__(self, msg: str, value: bytes): 25 | self.msg = msg 26 | self.value = value 27 | def __str__(self): 28 | return f'{self.msg}: "{self.value!r}"' 29 | 30 | class MessageParseError(ParseError): 31 | """Raised on errors while parsing :class:`Message` objects 32 | 33 | .. versionadded:: 0.0.2 34 | """ 35 | pass 36 | 37 | class DmsgParseError(ParseError): 38 | """Raised on errors while parsing :class:`Display` objects 39 | 40 | .. versionadded:: 0.0.2 41 | """ 42 | pass 43 | 44 | class DmsgControlParseError(ParseError): 45 | """Raised on errors when parsing :attr:`Display.control` data 46 | 47 | .. versionadded:: 0.0.2 48 | """ 49 | pass 50 | 51 | class MessageLengthError(ValueError): 52 | """Raised when message length is larger than 2048 bytes 53 | 54 | .. versionadded:: 0.0.4 55 | """ 56 | 57 | 58 | class Flags(enum.IntFlag): 59 | """Message flags 60 | """ 61 | NO_FLAGS = 0 #: No flags set 62 | UTF16 = 1 63 | """Indicates text formatted as ``UTF-16LE`` if set, otherwise ``UTF-8``""" 64 | 65 | SCONTROL = 2 66 | """Indicates the message contains ``SCONTROL`` data if set, otherwise ``DMESG`` 67 | """ 68 | 69 | 70 | @dataclass 71 | class Display: 72 | """A single tally "display" 73 | """ 74 | index: int #: The display index from 0 to 65534 (``0xFFFE``) 75 | rh_tally: TallyColor = TallyColor.OFF #: Right hand tally indicator 76 | txt_tally: TallyColor = TallyColor.OFF #: Text tally indicator 77 | lh_tally: TallyColor = TallyColor.OFF #: Left hand tally indicator 78 | brightness: int = 3 #: Display brightness (from 0 to 3) 79 | text: str = '' #: Text to display 80 | control: bytes = b'' 81 | """Control data (if :attr:`type` is :attr:`~.MessageType.control`) 82 | 83 | .. versionadded:: 0.0.2 84 | """ 85 | 86 | type: MessageType = MessageType.display 87 | """The message type. One of :attr:`~.MessageType.display` or 88 | :attr:`~.MessageType.control`. 89 | 90 | * For :attr:`~.MessageType.display` (the default), the message contains 91 | :attr:`text` information and the :attr:`control` field must be empty. 92 | * For :attr:`~.MessageType.control`, the message contains :attr:`control` 93 | data and the :attr:`text` field must be empty 94 | 95 | .. versionadded:: 0.0.2 96 | """ 97 | 98 | is_broadcast: bool = field(init=False) 99 | """``True`` if the display is to a "broadcast", meaning sent to all display 100 | indices. 101 | 102 | (if the :attr:`index` is ``0xffff``) 103 | 104 | .. versionadded:: 0.0.2 105 | """ 106 | 107 | def __post_init__(self): 108 | self.is_broadcast = self.index == 0xffff 109 | if len(self.control): 110 | self.type = MessageType.control 111 | if self.type == MessageType.control and len(self.text): 112 | raise ValueError('Control message cannot contain text') 113 | 114 | @classmethod 115 | def broadcast(cls, **kwargs) -> Display: 116 | """Create a :attr:`broadcast ` display 117 | 118 | (with :attr:`index` set to ``0xffff``) 119 | 120 | .. versionadded:: 0.0.2 121 | """ 122 | kwargs = kwargs.copy() 123 | kwargs['index'] = 0xffff 124 | return cls(**kwargs) 125 | 126 | @classmethod 127 | def from_dmsg(cls, flags: Flags, dmsg: bytes) -> Tuple[Display, bytes]: 128 | """Construct an instance from a ``DMSG`` portion of received message. 129 | 130 | Any remaining message data after the relevant ``DMSG`` is returned along 131 | with the instance. 132 | """ 133 | if len(dmsg) < 4: 134 | raise DmsgParseError('Invalid dmsg length', dmsg) 135 | hdr = struct.unpack('<2H', dmsg[:4]) 136 | hdr = cast(Tuple[int, int], hdr) 137 | dmsg = dmsg[4:] 138 | ctrl = hdr[1] 139 | kw: dict[str, Any] = dict( 140 | index=hdr[0], 141 | rh_tally=TallyColor(ctrl & 0b11), 142 | txt_tally=TallyColor(ctrl >> 2 & 0b11), 143 | lh_tally=TallyColor(ctrl >> 4 & 0b11), 144 | brightness=ctrl >> 6 & 0b11, 145 | ) 146 | is_control_data = ctrl & 0x8000 == 0x8000 147 | if is_control_data: 148 | ctrl, dmsg = cls._unpack_control_data(dmsg) 149 | kw['control'] = ctrl 150 | kw['type'] = MessageType.control 151 | else: 152 | if len(dmsg) < 2: 153 | raise DmsgParseError('Invalid text length field', dmsg) 154 | txt_byte_len = struct.unpack(' Tuple[bytes, bytes]: 174 | """Unpack control data (if control bit 15 is set) 175 | 176 | Arguments: 177 | data: The portion of the ``dmsg`` at the start of the 178 | "Control Data" field 179 | 180 | Returns: 181 | bytes: remaining 182 | The remaining message data after the control data field 183 | 184 | Note: 185 | This is undefined as of UMDv5.0 and its implementation is 186 | the author's "best guess" based off of other areas of the protocol 187 | 188 | .. versionadded:: 0.0.2 189 | 190 | :meta public: 191 | """ 192 | if len(data) < 2: 193 | raise DmsgControlParseError('Unknown control data format', data) 194 | length = struct.unpack(' bytes: 202 | """Pack control data (if control bit 15 is set) 203 | 204 | Arguments: 205 | data: The control data to pack 206 | 207 | Returns: 208 | bytes: packed 209 | The packed control data 210 | 211 | Note: 212 | This is undefined as of UMDv5.0 and its implementation is 213 | the author's "best guess" based off of other areas of the protocol 214 | 215 | .. versionadded:: 0.0.2 216 | 217 | :meta public: 218 | """ 219 | length = len(data) 220 | return struct.pack(f' bytes: 223 | """Build ``dmsg`` bytes to be included in a message 224 | (called from :meth:`Message.build_message`) 225 | """ 226 | ctrl = self.rh_tally & 0b11 227 | ctrl += (self.txt_tally & 0b11) << 2 228 | ctrl += (self.lh_tally & 0b11) << 4 229 | ctrl += (self.brightness & 0b11) << 6 230 | if self.type == MessageType.control: 231 | ctrl |= 0x8000 232 | data = bytearray(struct.pack('<2H', self.index, ctrl)) 233 | data.extend(self._pack_control_data(self.control)) 234 | else: 235 | if Flags.UTF16 in flags: 236 | txt_bytes = bytes(self.text, 'UTF-16le') 237 | else: 238 | txt_bytes = bytes(self.text, 'UTF-8') 239 | txt_byte_len = len(txt_bytes) 240 | data = bytearray(struct.pack('<3H', self.index, ctrl, txt_byte_len)) 241 | data.extend(txt_bytes) 242 | return data 243 | 244 | def to_dict(self) -> dict: 245 | d = dataclasses.asdict(self) 246 | del d['is_broadcast'] 247 | return d 248 | 249 | @classmethod 250 | def from_tally(cls, tally: Tally, msg_type: MessageType = MessageType.display) -> Display: 251 | """Create a :class:`Display` from the given :class:`~.Tally` 252 | 253 | .. versionadded:: 0.0.2 254 | The msg_type argument 255 | """ 256 | kw = tally.to_dict() 257 | del kw['id'] 258 | if msg_type == MessageType.control: 259 | del kw['text'] 260 | elif msg_type == MessageType.display: 261 | del kw['control'] 262 | kw['type'] = msg_type 263 | return cls(**kw) 264 | 265 | def __eq__(self, other): 266 | if not isinstance(other, (Display, Tally)): 267 | return NotImplemented 268 | self_dict = self.to_dict() 269 | oth_dict = other.to_dict() 270 | if isinstance(other, Display): 271 | return self_dict == oth_dict 272 | else: 273 | del oth_dict['id'] 274 | 275 | del self_dict['type'] 276 | if self.type == MessageType.control: 277 | del self_dict['text'] 278 | del oth_dict['text'] 279 | else: 280 | del self_dict['control'] 281 | del oth_dict['control'] 282 | 283 | return self_dict == oth_dict 284 | 285 | def __ne__(self, other): 286 | if not isinstance(other, (Display, Tally)): 287 | return NotImplemented 288 | return not self.__eq__(other) 289 | 290 | @dataclass 291 | class Message: 292 | """A single UMDv5 message packet 293 | """ 294 | version: int = 0 #: Protocol minor version 295 | flags: Flags = Flags.NO_FLAGS #: The message :class:`Flags` field 296 | screen: int = 0 #: Screen index from 0 to 65534 (``0xFFFE``) 297 | displays: list[Display] = field(default_factory=list) 298 | """A list of :class:`Display` instances""" 299 | 300 | scontrol: bytes = b'' 301 | """SCONTROL data (if :attr:`type` is :attr:`~.MessageType.control`)""" 302 | 303 | type: MessageType = MessageType.display 304 | """The message type. One of :attr:`~.MessageType.display` or 305 | :attr:`~.MessageType.control`. 306 | 307 | * For :attr:`~.MessageType.display` (the default), the contents of 308 | :attr:`displays` are used and the :attr:`scontrol` field must be empty. 309 | * For :attr:`~.MessageType.control`, the :attr:`scontrol` field is used and 310 | :attr:`displays` must be empty. 311 | 312 | .. versionadded:: 0.0.2 313 | """ 314 | 315 | is_broadcast: bool = field(init=False) 316 | """``True`` if the message is to be "broadcast" to all screens. 317 | 318 | (if :attr:`screen` is ``0xffff``) 319 | 320 | .. versionadded:: 0.0.2 321 | """ 322 | 323 | def __post_init__(self): 324 | self.is_broadcast = self.screen == 0xffff 325 | if not isinstance(self.flags, Flags): 326 | self.flags = Flags(self.flags) 327 | 328 | if len(self.scontrol) and len(self.displays): 329 | raise ValueError('SCONTROL message cannot contain displays') 330 | 331 | if len(self.scontrol): 332 | self.type = MessageType.control 333 | 334 | if self.type == MessageType.control: 335 | self.flags |= Flags.SCONTROL 336 | elif self.type == MessageType._unset: 337 | if Flags.SCONTROL in self.flags: 338 | self.type = MessageType.control 339 | else: 340 | self.type = MessageType.display 341 | 342 | @classmethod 343 | def broadcast(cls, **kwargs) -> Message: 344 | """Create a :attr:`broadcast ` message 345 | 346 | (with :attr:`screen` set to ``0xffff``) 347 | 348 | .. versionadded:: 0.0.2 349 | """ 350 | kwargs = kwargs.copy() 351 | kwargs['screen'] = 0xffff 352 | return cls(**kwargs) 353 | 354 | @classmethod 355 | def parse(cls, msg: bytes) -> Tuple[Message, bytes]: 356 | """Parse incoming message data to create a :class:`Message` instance. 357 | 358 | Any remaining message data after parsing is returned along with the instance. 359 | """ 360 | if len(msg) < 6: 361 | raise MessageParseError('Invalid header length', msg) 362 | data = struct.unpack(' bytes: 388 | """Build a message packet from data in this instance 389 | 390 | Arguments: 391 | ignore_packet_length (bool, optional): If ``False``, the message limit 392 | of 2048 bytes is respected, and if exceeded, an exception is raised. 393 | Otherwise, the limit is ignored. (default is False) 394 | 395 | Raises: 396 | MessageLengthError: If the message packet is larger than 2048 bytes 397 | (and ``ignore_packet_length`` is False) 398 | 399 | Note: 400 | This method is retained for backwards compatability. To properly 401 | handle the message limit, use :meth:`build_messages` 402 | 403 | .. versionchanged:: 0.0.4 404 | 405 | * The ``ignore_packet_length`` parameter was added 406 | * Message length is limited to 2048 bytes 407 | """ 408 | it = self.build_messages(ignore_packet_length=ignore_packet_length) 409 | data = next(it) 410 | try: 411 | next_data = next(it) 412 | except StopIteration: 413 | pass 414 | else: 415 | if not ignore_packet_length: 416 | raise MessageLengthError() 417 | return data 418 | 419 | def build_messages(self, ignore_packet_length: bool = False) -> Iterator[bytes]: 420 | """Build message packet(s) from data in this instance as an iterator 421 | 422 | The specified maximum packet length of 2048 is respected and if 423 | necessary, the data will be split into separate messages. 424 | 425 | This method will always function as a :term:`generator`, regardless of 426 | the number of message packets produced. 427 | 428 | .. versionadded:: 0.0.4 429 | """ 430 | msg_len_exceeded = False 431 | next_disp_index = None 432 | if self.type == MessageType.control: 433 | payload = bytearray(self.scontrol) 434 | byte_count = len(payload) 435 | if byte_count + 6 > 2048: 436 | raise MessageLengthError() 437 | else: 438 | byte_count = 0 439 | payload = bytearray() 440 | for disp_index, display in enumerate(self.displays): 441 | disp_payload = display.to_dmsg(self.flags) 442 | disp_len = len(disp_payload) 443 | if not ignore_packet_length: 444 | if byte_count + disp_len + 6 >= 2048: 445 | if disp_index == 0: 446 | raise MessageLengthError() 447 | msg_len_exceeded = True 448 | next_disp_index = disp_index 449 | break 450 | byte_count += disp_len 451 | payload.extend(disp_payload) 452 | fmt = f' asyncio.AbstractEventLoop: 117 | """The :class:`asyncio.BaseEventLoop` associated with the instance""" 118 | loop = self.__loop 119 | if loop is None: 120 | loop = self.__loop = asyncio.get_running_loop() 121 | return loop 122 | 123 | @property 124 | def connected_evt(self) -> asyncio.Event: 125 | e = self.__connected_evt 126 | if e is None: 127 | e = self.__connected_evt = asyncio.Event() 128 | return e 129 | 130 | @property 131 | def _connect_lock(self) -> asyncio.Lock: 132 | l = self.__connect_lock 133 | if l is None: 134 | l = self.__connect_lock = asyncio.Lock() 135 | return l 136 | 137 | @property 138 | def hostaddr(self) -> str: 139 | """The local host address to bind the server to 140 | """ 141 | return self.__hostaddr 142 | 143 | @property 144 | def hostport(self) -> int: 145 | """The port to listen on 146 | """ 147 | return self.__hostport 148 | 149 | async def open(self): 150 | """Open the server 151 | """ 152 | async with self._connect_lock: 153 | if self.running: 154 | return 155 | logger.debug('UmdReceiver.open()') 156 | self.running = True 157 | self.connected_evt.clear() 158 | self.transport, self.protocol = await self.loop.create_datagram_endpoint( 159 | lambda: UmdProtocol(self), 160 | local_addr=(self.hostaddr, self.hostport), 161 | reuse_port=True, 162 | ) 163 | await self.connected_evt.wait() 164 | logger.info('UmdReceiver running') 165 | 166 | async def close(self): 167 | """Close the server 168 | """ 169 | async with self._connect_lock: 170 | if not self.running: 171 | return 172 | logger.debug('UmdReceiver.close()') 173 | self.running = False 174 | self.transport.close() 175 | self.connected_evt.clear() 176 | logger.info('UmdReceiver closed') 177 | 178 | async def set_bind_address(self, hostaddr: str, hostport: int): 179 | """Set the :attr:`hostaddr` and :attr:`hostport` and restart the server 180 | """ 181 | if hostaddr == self.hostaddr and hostport == self.hostport: 182 | return 183 | running = self.running 184 | if running: 185 | await self.close() 186 | self.__hostaddr = hostaddr 187 | self.__hostport = hostport 188 | if running: 189 | await self.open() 190 | 191 | async def set_hostaddr(self, hostaddr: str): 192 | """Set the :attr:`hostaddr` and restart the server 193 | """ 194 | await self.set_bind_address(hostaddr, self.hostport) 195 | 196 | async def set_hostport(self, hostport: int): 197 | """Set the :attr:`hostport` and restart the server 198 | """ 199 | await self.set_bind_address(self.hostaddr, hostport) 200 | 201 | def parse_incoming(self, data: bytes, addr: Tuple[str, int]): 202 | """Parse data received by the server 203 | """ 204 | while True: 205 | message, remaining = Message.parse(data) 206 | if message.screen not in self.screens: 207 | screen = Screen(message.screen) 208 | self.screens[screen.index] = screen 209 | self._bind_screen(screen) 210 | self.emit('on_screen_added', screen) 211 | logger.debug(f'new screen: {screen.index}') 212 | else: 213 | screen = self.screens[message.screen] 214 | 215 | if message.is_broadcast: 216 | for screen in self.screens.values(): 217 | screen.update_from_message(message) 218 | else: 219 | screen.update_from_message(message) 220 | if not len(remaining): 221 | break 222 | 223 | def _bind_screen(self, screen: Screen): 224 | screen.bind( 225 | on_tally_added=self._on_screen_tally_added, 226 | on_tally_update=self._on_screen_tally_update, 227 | on_tally_control=self._on_screen_tally_control, 228 | on_control=self._on_screen_control, 229 | ) 230 | 231 | def _on_screen_tally_added(self, tally: Tally, **kwargs): 232 | if tally.id not in self.tallies: 233 | self.tallies[tally.id] = tally 234 | self.emit('on_tally_added', tally, **kwargs) 235 | 236 | def _on_screen_tally_update(self, *args, **kwargs): 237 | self.emit('on_tally_updated', *args, **kwargs) 238 | 239 | def _on_screen_tally_control(self, *args, **kwargs): 240 | self.emit('on_tally_control', *args, **kwargs) 241 | 242 | def _on_screen_control(self, *args, **kwargs): 243 | self.emit('on_scontrol', *args, **kwargs) 244 | 245 | async def __aenter__(self): 246 | await self.open() 247 | return self 248 | 249 | async def __aexit__(self, exc_type, exc_value, traceback): 250 | await self.close() 251 | 252 | 253 | if __name__ == '__main__': 254 | loop = asyncio.get_event_loop() 255 | umd = UmdReceiver() 256 | 257 | loop.run_until_complete(umd.open()) 258 | try: 259 | loop.run_forever() 260 | except KeyboardInterrupt: 261 | loop.run_until_complete(umd.close()) 262 | finally: 263 | loop.close() 264 | -------------------------------------------------------------------------------- /src/tslumd/sender.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | try: 3 | from loguru import logger 4 | except ImportError: # pragma: no cover 5 | import logging 6 | logger = logging.getLogger(__name__) 7 | import asyncio 8 | import socket 9 | import argparse 10 | from typing import Tuple, Iterable 11 | 12 | from pydispatch import Dispatcher, Property, DictProperty, ListProperty 13 | 14 | from tslumd import ( 15 | MessageType, Message, Display, TallyColor, TallyType, TallyKey, 16 | Tally, Screen, 17 | ) 18 | from tslumd.tallyobj import StrOrTallyType, StrOrTallyColor 19 | from tslumd.utils import logger_catch 20 | 21 | Client = Tuple[str, int] #: A network client as a tuple of ``(address, port)`` 22 | 23 | __all__ = ('Client', 'UmdSender') 24 | 25 | class UmdProtocol(asyncio.DatagramProtocol): 26 | def __init__(self, sender: 'UmdSender'): 27 | self.sender = sender 28 | def connection_made(self, transport): 29 | logger.debug(f'transport={transport}') 30 | self.transport = transport 31 | self.sender.connected_evt.set() 32 | def datagram_received(self, data, addr): # pragma: no cover 33 | pass 34 | 35 | class UmdSender(Dispatcher): 36 | """Send UMD Messages 37 | 38 | Messages are sent immediately when a change is made to any of the 39 | :class:`~.Tally` objects in :attr:`tallies`. These can be added by using 40 | the :meth:`add_tally` method. 41 | 42 | Alternatively, the :meth:`set_tally_color` and :meth:`set_tally_text` methods 43 | may be used. 44 | 45 | Arguments: 46 | clients: Intitial value for :attr:`clients` 47 | all_off_on_close: Initial value for :attr:`all_off_on_close` 48 | 49 | .. versionchanged:: 0.0.4 50 | The ``all_off_on_close`` parameter was added 51 | """ 52 | 53 | screens: dict[int, Screen] 54 | """Mapping of :class:`~.Screen` objects by :attr:`~.Screen.index` 55 | 56 | .. versionadded:: 0.0.3 57 | """ 58 | 59 | tallies: dict[TallyKey, Tally] 60 | """Mapping of :class:`~.Tally` objects by their :attr:`~.Tally.id` 61 | 62 | Note: 63 | This should not be altered directly. Use :meth:`add_tally` instead 64 | 65 | .. versionchanged:: 0.0.3 66 | The keys are now a combination of the :class:`~.Screen` and 67 | :class:`.Tally` indices 68 | """ 69 | 70 | running: bool 71 | """``True`` if the client / server are running 72 | """ 73 | 74 | tx_interval: float = .3 75 | """Interval to send tally messages, regardless of state changes 76 | """ 77 | 78 | clients: set[Client] 79 | """Set of :data:`clients ` to send messages to 80 | """ 81 | 82 | all_off_on_close: bool 83 | """If ``True``, a broadcast message will be sent before shutdown to turn 84 | off all tally lights in the system. (default is ``False``) 85 | 86 | .. versionadded:: 0.0.4 87 | """ 88 | 89 | def __init__(self, 90 | clients: Iterable[Client]|None = None, 91 | all_off_on_close: bool = False): 92 | self.clients = set() 93 | if clients is not None: 94 | for client in clients: 95 | self.clients.add(client) 96 | self.all_off_on_close = all_off_on_close 97 | self.screens = {} 98 | self.tallies = {} 99 | self.running = False 100 | self.__loop: asyncio.AbstractEventLoop|None = None 101 | self.__broadcast_screen: Screen|None = None 102 | self.__update_queue: asyncio.PriorityQueue[TallyKey|tuple[int, bool]]|None = None 103 | self.update_task = None 104 | self.tx_task: asyncio.Task|None = None 105 | self.__connected_evt: asyncio.Event| None = None 106 | self.__tx_lock: asyncio.Lock|None = None 107 | 108 | @property 109 | def loop(self) -> asyncio.AbstractEventLoop: 110 | """The :class:`asyncio.BaseEventLoop` associated with the instance""" 111 | loop = self.__loop 112 | if loop is None: 113 | loop = self.__loop = asyncio.get_running_loop() 114 | return loop 115 | 116 | @property 117 | def connected_evt(self) -> asyncio.Event: 118 | e = self.__connected_evt 119 | if e is None: 120 | e = self.__connected_evt = asyncio.Event() 121 | return e 122 | 123 | @property 124 | def _tx_lock(self) -> asyncio.Lock: 125 | l = self.__tx_lock 126 | if l is None: 127 | l = self.__tx_lock = asyncio.Lock() 128 | return l 129 | 130 | @property 131 | def update_queue(self) -> asyncio.PriorityQueue[TallyKey|tuple[int, bool]]: 132 | q = self.__update_queue 133 | if q is None: 134 | q = self.__update_queue = asyncio.PriorityQueue() 135 | return q 136 | 137 | @property 138 | def broadcast_screen(self) -> Screen: 139 | """A :class:`~.Screen` instance created using :meth:`.Screen.broadcast` 140 | 141 | .. versionadded:: 0.0.3 142 | """ 143 | return self._build_broadcast_screen() 144 | 145 | def _build_broadcast_screen(self) -> Screen: 146 | if self.__broadcast_screen is not None: 147 | return self.__broadcast_screen 148 | screen = self.__broadcast_screen = Screen.broadcast() 149 | assert screen.is_broadcast 150 | self.screens[screen.index] = screen 151 | self._bind_screen(screen) 152 | return screen 153 | 154 | async def open(self): 155 | """Open connections and begin data transmission 156 | """ 157 | if self.running: 158 | return 159 | self._build_broadcast_screen() 160 | self.connected_evt.clear() 161 | assert self.tx_task is None 162 | logger.debug('UmdSender.open()') 163 | self.running = True 164 | self.transport, self.protocol = await self.loop.create_datagram_endpoint( 165 | lambda: UmdProtocol(self), 166 | family=socket.AF_INET, 167 | ) 168 | self.tx_task = asyncio.create_task(self.tx_loop()) 169 | logger.info('UmdSender running') 170 | 171 | async def close(self): 172 | """Stop sending to clients and close connections 173 | """ 174 | if not self.running: 175 | return 176 | logger.debug('UmdSender.close()') 177 | self.running = False 178 | await self.update_queue.put((0, False)) 179 | t = self.tx_task 180 | self.tx_task = None 181 | if t is not None: 182 | await t 183 | if self.all_off_on_close: 184 | logger.debug('sending all off broadcast message') 185 | await self.send_broadcast_tally(0xffff) 186 | self.transport.close() 187 | logger.info('UmdSender closed') 188 | 189 | async def send_scontrol(self, screen_index: int, data: bytes): 190 | """Send an :attr:`SCONTROL <.Message.scontrol>` message 191 | 192 | Arguments: 193 | screen_index: The :attr:`~.Message.screen` index for the message 194 | data: The data to send in the :attr:`~.Message.scontrol` field 195 | 196 | .. versionadded:: 0.0.2 197 | """ 198 | screen = self.get_or_create_screen(screen_index) 199 | screen.scontrol = data 200 | 201 | async def send_broadcast_scontrol(self, data: bytes): 202 | """Send a :attr:`broadcast <.Message.is_broadcast>` 203 | :attr:`SCONTROL <.Message.scontrol>` message 204 | 205 | Arguments: 206 | data: The data to send in the :attr:`~.Message.scontrol` field 207 | 208 | .. versionadded:: 0.0.2 209 | """ 210 | self.broadcast_screen.scontrol = data 211 | 212 | def add_tally(self, tally_id: TallyKey, **kwargs) -> Tally: 213 | """Create a :class:`~.Tally` object and add it to :attr:`tallies` if 214 | one does not exist 215 | 216 | If necessary, creates a :class:`~.Screen` using :meth:`get_or_create_screen` 217 | 218 | Arguments: 219 | tally_id: A tuple of (:attr:`screen_index <.Screen.index>`, 220 | :attr:`tally_index <.Tally.index>`) 221 | **kwargs: Keyword arguments passed to create the tally instance 222 | 223 | Raises: 224 | KeyError: If the given ``tally_id`` already exists 225 | 226 | .. versionchanged:: 0.0.3 227 | Chaned the ``tally_index`` parameter to ``tally_id`` 228 | """ 229 | if tally_id in self.tallies: 230 | raise KeyError(f'Tally exists for id {tally_id}') 231 | screen_index, tally_index = tally_id 232 | screen = self.get_or_create_screen(screen_index) 233 | tally = screen.add_tally(tally_index, **kwargs) 234 | return tally 235 | 236 | def get_or_create_tally(self, tally_id: TallyKey) -> Tally: 237 | """If a :class:`~.Tally` object matching the given tally id exists, 238 | return it. Otherwise, create it using :meth:`.Screen.get_or_create_tally` 239 | 240 | This method is similar to :meth:`add_tally` and it can be used to avoid 241 | exception handling. It does not however take keyword arguments and 242 | is only intended for object creation. 243 | 244 | .. versionadded:: 0.0.3 245 | """ 246 | tally = self.tallies.get(tally_id) 247 | if tally is not None: 248 | return tally 249 | screen_index, tally_index = tally_id 250 | screen = self.get_or_create_screen(screen_index) 251 | tally = screen.get_or_create_tally(tally_index) 252 | return tally 253 | 254 | def get_or_create_screen(self, index_: int) -> Screen: 255 | """Create a :class:`~.Screen` object and add it to :attr:`screens` 256 | 257 | Arguments: 258 | index_: The screen :attr:`~.Screen.index` 259 | 260 | Raises: 261 | KeyError: If the given ``index_`` already exists 262 | 263 | .. versionadded:: 0.0.3 264 | """ 265 | if index_ in self.screens: 266 | return self.screens[index_] 267 | screen = Screen(index_) 268 | self.screens[screen.index] = screen 269 | self._bind_screen(screen) 270 | return screen 271 | 272 | def _bind_screen(self, screen: Screen): 273 | screen.bind(on_tally_added=self._on_screen_tally_added) 274 | screen.bind_async(self.loop, 275 | on_tally_update=self.on_tally_updated, 276 | on_tally_control=self.on_tally_control, 277 | on_control=self.on_screen_control, 278 | ) 279 | 280 | def set_tally_color(self, tally_id: TallyKey, tally_type: StrOrTallyType, color: StrOrTallyColor): 281 | """Set the tally color for the given index and tally type 282 | 283 | Uses :meth:`.Tally.set_color`. See the method documentation for details 284 | 285 | Arguments: 286 | tally_id (TallyKey): A tuple of (:attr:`screen_index <.Screen.index>`, 287 | :attr:`tally_index <.Tally.index>`) 288 | tally_type (TallyType or str): :class:`~.common.TallyType` or member 289 | name as described in :meth:`.Tally.set_color` 290 | color (TallyColor or str): :class:`~.common.TallyColor` or color 291 | name as described in :meth:`.Tally.set_color` 292 | 293 | .. versionchanged:: 0.0.3 294 | Chaned the ``tally_index`` parameter to ``tally_id`` 295 | 296 | .. versionchanged:: 0.0.5 297 | Accept string arguments and match behavior of :meth:`.Tally.set_color` 298 | """ 299 | if tally_type == TallyType.no_tally: 300 | raise ValueError() 301 | tally = self.get_or_create_tally(tally_id) 302 | tally[tally_type] = color 303 | 304 | def set_tally_text(self, tally_id: TallyKey, text: str): 305 | """Set the tally text for the given id 306 | 307 | Arguments: 308 | tally_id: A tuple of (:attr:`screen_index <.Screen.index>`, 309 | :attr:`tally_index <.Tally.index>`) 310 | text: The :attr:`~.Tally.text` to set 311 | 312 | .. versionchanged:: 0.0.3 313 | Chaned the ``tally_index`` parameter to ``tally_id`` 314 | """ 315 | tally = self.get_or_create_tally(tally_id) 316 | tally.text = text 317 | 318 | async def send_tally_control(self, tally_id: TallyKey, data: bytes): 319 | """Send :attr:`~.Display.control` data for the given screen and tally index 320 | 321 | Arguments: 322 | tally_id: A tuple of (:attr:`screen_index <.Screen.index>`, 323 | :attr:`tally_index <.Tally.index>`) 324 | control: The control data to send 325 | 326 | .. versionadded:: 0.0.2 327 | 328 | .. versionchanged:: 0.0.3 329 | Chaned the ``tally_index`` parameter to ``tally_id`` 330 | """ 331 | tally = self.get_or_create_tally(tally_id) 332 | tally.control = data 333 | 334 | async def send_broadcast_tally_control(self, screen_index: int, data: bytes, **kwargs): 335 | """Send :attr:`~.Display.control` data as 336 | :attr:`broadcast <.Display.is_broadcast>` to all listening displays 337 | 338 | Arguments: 339 | screen_index: The screen :attr:`~.Screen.index` 340 | **kwargs: Additional keyword arguments to pass to the :class:`~.Tally` 341 | constructor 342 | 343 | .. versionadded:: 0.0.2 344 | 345 | .. versionchanged:: 0.0.3 346 | Added the screen_index parameter 347 | """ 348 | await self.send_broadcast_tally(screen_index, control=data, **kwargs) 349 | 350 | async def send_broadcast_tally(self, screen_index: int, **kwargs): 351 | """Send a :attr:`broadcast <.Display.is_broadcast>` update 352 | to all listening displays 353 | 354 | Arguments: 355 | screen_index: The screen :attr:`~.Screen.index` 356 | **kwargs: The keyword arguments to pass to the :class:`~.Tally` 357 | constructor 358 | 359 | .. versionadded:: 0.0.2 360 | 361 | .. versionchanged:: 0.0.3 362 | Added the screen_index parameter 363 | """ 364 | screen = self.get_or_create_screen(screen_index) 365 | tally = screen.broadcast_tally(**kwargs) 366 | if tally.text == '' or tally.control != b'': 367 | msg_type = MessageType.control 368 | else: 369 | msg_type = MessageType.display 370 | msg = self._build_message(screen=screen_index) 371 | disp = Display.from_tally(tally, msg_type=msg_type) 372 | msg.displays.append(disp) 373 | async with self._tx_lock: 374 | await self.send_message(msg) 375 | screen.unbind(self) 376 | for oth_tally in screen: 377 | oth_tally.update_from_display(disp) 378 | self._bind_screen(screen) 379 | 380 | async def on_tally_updated(self, tally: Tally, props_changed: set[str], **kwargs): 381 | if self.running: 382 | if set(props_changed) == set(['control']): 383 | return 384 | logger.debug(f'tally update: {tally}') 385 | await self.update_queue.put(tally.id) 386 | 387 | async def on_tally_control(self, tally: Tally, data: bytes, **kwargs): 388 | if self.running: 389 | async with self._tx_lock: 390 | disp = Display.from_tally(tally, msg_type=MessageType.control) 391 | assert tally.screen is not None 392 | msg = self._build_message( 393 | screen=tally.screen.index, 394 | displays=[disp], 395 | ) 396 | await self.send_message(msg) 397 | 398 | 399 | async def on_screen_control(self, screen: Screen, data: bytes, **kwargs): 400 | if self.running: 401 | async with self._tx_lock: 402 | msg = self._build_message( 403 | screen=screen.index, 404 | type=MessageType.control, 405 | scontrol=data, 406 | ) 407 | await self.send_message(msg) 408 | 409 | 410 | def _on_screen_tally_added(self, tally: Tally, **kwargs): 411 | self.tallies[tally.id] = tally 412 | logger.debug(f'new tally: {tally}') 413 | 414 | @logger_catch 415 | async def tx_loop(self): 416 | async def get_queue_item(timeout): 417 | try: 418 | item = await asyncio.wait_for(self.update_queue.get(), timeout) 419 | if item[1] is False: 420 | return False 421 | except asyncio.TimeoutError: 422 | item = None 423 | return item 424 | 425 | await self.connected_evt.wait() 426 | 427 | while self.running: 428 | item = await get_queue_item(self.tx_interval) 429 | if item is False: 430 | self.update_queue.task_done() 431 | break 432 | elif item is None: 433 | if not self._tx_lock.locked(): 434 | await self.send_full_update() 435 | else: 436 | screen_index, _ = item 437 | ids = set([item]) 438 | self.update_queue.task_done() 439 | while not self.update_queue.empty(): 440 | try: 441 | item = self.update_queue.get_nowait() 442 | except asyncio.QueueEmpty: 443 | break 444 | if item is False: 445 | self.update_queue.task_done() 446 | return 447 | _screen_index, _ = item 448 | if _screen_index == screen_index: 449 | ids.add(item) 450 | self.update_queue.task_done() 451 | else: 452 | await self.update_queue.put(item) 453 | break 454 | 455 | msg = self._build_message(screen=screen_index) 456 | tallies = {i:self.tallies[i] for i in ids} 457 | async with self._tx_lock: 458 | for key in sorted(tallies.keys()): 459 | tally = tallies[key] 460 | msg.displays.append(Display.from_tally(tally)) 461 | await self.send_message(msg) 462 | 463 | async def send_message(self, msg: Message): 464 | assert self._tx_lock.locked() 465 | for data in msg.build_messages(): 466 | for client in self.clients: 467 | self.transport.sendto(data, client) 468 | 469 | async def send_full_update(self): 470 | coros = set() 471 | for screen in self.screens.values(): 472 | coros.add(self.send_screen_update(screen)) 473 | if not len(coros): # pragma: no cover 474 | return 475 | async with self._tx_lock: 476 | await asyncio.gather(*coros) 477 | 478 | async def send_screen_update(self, screen: Screen): 479 | if screen.is_broadcast: 480 | return 481 | msg = self._build_message(screen=screen.index) 482 | for tally in screen: 483 | disp = Display.from_tally(tally) 484 | msg.displays.append(disp) 485 | await self.send_message(msg) 486 | 487 | def _build_message(self, **kwargs) -> Message: 488 | return Message(**kwargs) 489 | 490 | async def __aenter__(self): 491 | await self.open() 492 | return self 493 | 494 | async def __aexit__(self, exc_type, exc_value, traceback): 495 | await self.close() 496 | 497 | 498 | class ClientArgAction(argparse._AppendAction): 499 | _default_help = ' '.join([ 500 | 'Client(s) to send UMD messages to formatted as ":".', 501 | 'Multiple arguments may be given.', 502 | 'If nothing is provided, defaults to "127.0.0.1:65000"', 503 | ]) 504 | def __init__(self, 505 | option_strings, 506 | dest, 507 | nargs=None, 508 | const=None, 509 | default=[('127.0.0.1', 65000)], 510 | type_=str, 511 | choices=None, 512 | required=False, 513 | help=_default_help, 514 | metavar=None): 515 | super().__init__( 516 | option_strings, dest, nargs, const, default, 517 | type_, choices, required, help, metavar, 518 | ) 519 | 520 | def __call__(self, parser, namespace, values, option_string=None): 521 | addr, port = values.split(':') # type: ignore 522 | values = (addr, int(port)) 523 | items = getattr(namespace, self.dest, None) 524 | if items == [('127.0.0.1', 65000)]: 525 | items = [] 526 | else: 527 | items = argparse._copy_items(items) # type: ignore 528 | items.append(values) 529 | setattr(namespace, self.dest, items) 530 | 531 | def main(): 532 | p = argparse.ArgumentParser() 533 | p.add_argument( 534 | '-c', '--client', dest='clients', action=ClientArgAction#, type=str, 535 | ) 536 | args = p.parse_args() 537 | 538 | logger.info(f'Sending to clients: {args.clients!r}') 539 | 540 | loop = asyncio.get_event_loop() 541 | sender = UmdSender(clients=args.clients) 542 | loop.run_until_complete(sender.open()) 543 | try: 544 | loop.run_forever() 545 | except KeyboardInterrupt: 546 | loop.run_until_complete(sender.close()) 547 | finally: 548 | loop.close() 549 | 550 | if __name__ == '__main__': 551 | main() 552 | -------------------------------------------------------------------------------- /src/tslumd/tallyobj.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | try: 3 | from loguru import logger 4 | except ImportError: # pragma: no cover 5 | import logging 6 | logger = logging.getLogger(__name__) 7 | from typing import Union, Tuple, Iterator, cast, TYPE_CHECKING 8 | 9 | from pydispatch import Dispatcher, Property 10 | 11 | from tslumd import MessageType, TallyType, TallyColor, TallyKey 12 | if TYPE_CHECKING: 13 | from .messages import Display, Message 14 | 15 | StrOrTallyType = Union[str, TallyType] 16 | StrOrTallyColor = Union[str, TallyColor] 17 | 18 | __all__ = ('Tally', 'Screen') 19 | 20 | class Tally(Dispatcher): 21 | """A single tally object 22 | 23 | Properties: 24 | rh_tally (TallyColor): State of the :term:`right-hand tally ` indicator 25 | txt_tally (TallyColor): State of the :term:`text tally ` indicator 26 | lh_tally (TallyColor): State of the :term:`left-hand tally ` indicator 27 | brightness (int): Tally indicator brightness from 0 to 3 28 | text (str): Text to display 29 | control (bytes): Any control data received for the tally indicator 30 | normalized_brightness (float): The :attr:`brightness` value normalized 31 | as a float from ``0.0`` to ``1.0`` 32 | 33 | :Events: 34 | .. event:: on_update(instance: Tally, props_changed: set[str]) 35 | 36 | Fired when any property changes 37 | 38 | .. event:: on_control(instance: Tally, data: bytes) 39 | 40 | Fired when control data is received for the tally indicator 41 | 42 | .. versionadded:: 0.0.2 43 | The :event:`on_control` event 44 | 45 | .. versionchanged:: 0.0.5 46 | Added container emulation 47 | """ 48 | screen: Screen|None 49 | """The parent :class:`Screen` this tally belongs to 50 | 51 | .. versionadded:: 0.0.3 52 | """ 53 | rh_tally = Property(TallyColor.OFF) 54 | txt_tally = Property(TallyColor.OFF) 55 | lh_tally = Property(TallyColor.OFF) 56 | brightness = Property(3) 57 | normalized_brightness = Property(1.) 58 | text = Property('') 59 | control = Property(b'') 60 | _events_ = ['on_update', 'on_control'] 61 | _prop_attrs = ('rh_tally', 'txt_tally', 'lh_tally', 'brightness', 'text', 'control') 62 | def __init__(self, index_, **kwargs): 63 | self.screen = kwargs.get('screen') 64 | self.__index = index_ 65 | if self.screen is not None: 66 | self.__id = (self.screen.index, self.__index) 67 | else: 68 | self.__id = None 69 | self._updating_props = False 70 | self.update(**kwargs) 71 | self.bind(**{prop:self._on_prop_changed for prop in self._prop_attrs}) 72 | 73 | @property 74 | def index(self) -> int: 75 | """Index of the tally object from 0 to 65534 (``0xfffe``) 76 | """ 77 | return self.__index 78 | 79 | @property 80 | def id(self) -> TallyKey: 81 | """A key to uniquely identify a :class:`Tally` / :class:`Screen` 82 | combination. 83 | 84 | Tuple of (:attr:`Screen.index`, :attr:`Tally.index`) 85 | 86 | Raises: 87 | ValueError: If the :attr:`Tally.screen` is ``None`` 88 | 89 | .. versionadded:: 0.0.3 90 | """ 91 | if self.__id is None: 92 | raise ValueError(f'Cannot create id for Tally without a screen ({self!r})') 93 | return self.__id 94 | 95 | @property 96 | def is_broadcast(self) -> bool: 97 | """``True`` if the tally is to be "broadcast", meaning sent to all 98 | :attr:`display indices<.messages.Display.index>`. 99 | 100 | (if the :attr:`index` is ``0xffff``) 101 | 102 | .. versionadded:: 0.0.2 103 | """ 104 | return self.index == 0xffff 105 | 106 | @classmethod 107 | def broadcast(cls, **kwargs) -> Tally: 108 | """Create a :attr:`broadcast ` tally 109 | 110 | (with :attr:`index` set to ``0xffff``) 111 | 112 | .. versionadded:: 0.0.2 113 | """ 114 | return cls(0xffff, **kwargs) 115 | 116 | @classmethod 117 | def from_display(cls, display: Display, **kwargs) -> Tally: 118 | """Create an instance from the given :class:`~.messages.Display` object 119 | """ 120 | attrs = set(cls._prop_attrs) 121 | if display.type.name == 'control': 122 | attrs.discard('text') 123 | else: 124 | attrs.discard('control') 125 | kw = kwargs.copy() 126 | kw.update({attr:getattr(display, attr) for attr in cls._prop_attrs}) 127 | return cls(display.index, **kw) 128 | 129 | def set_color(self, tally_type: StrOrTallyType, color: StrOrTallyColor): 130 | """Set the color property (or properties) for the given TallyType 131 | 132 | Sets the :attr:`rh_tally`, :attr:`txt_tally` or :attr:`lh_tally` 133 | properties matching the :class:`~.common.TallyType` value(s). 134 | 135 | If the given tally_type is a combination of tally types, all of the 136 | matched attributes will be set to the given color. 137 | 138 | Arguments: 139 | tally_type (TallyType or str): The :class:`~.common.TallyType` member(s) 140 | to set. Multiple types can be specified using 141 | bitwise ``|`` operators. 142 | 143 | If the argument is a string, it should be formatted as shown in 144 | :meth:`.TallyType.from_str` 145 | color (TallyColor or str): The :class:`~.common.TallyColor` to set, or the 146 | name as a string 147 | 148 | 149 | >>> from tslumd import Tally, TallyType, TallyColor 150 | >>> tally = Tally(0) 151 | >>> tally.set_color(TallyType.rh_tally, TallyColor.RED) 152 | >>> tally.rh_tally 153 | 154 | >>> tally.set_color('lh_tally', 'green') 155 | >>> tally.lh_tally 156 | 157 | >>> tally.set_color('rh_tally|txt_tally', 'green') 158 | >>> tally.rh_tally 159 | 160 | >>> tally.txt_tally 161 | 162 | >>> tally.set_color('all', 'off') 163 | >>> tally.rh_tally 164 | 165 | >>> tally.txt_tally 166 | 167 | >>> tally.lh_tally 168 | 169 | 170 | .. versionadded:: 0.0.4 171 | 172 | .. versionchanged:: 0.0.5 173 | Allow string arguments and multiple tally_type members 174 | """ 175 | self[tally_type] = color 176 | 177 | def get_color(self, tally_type: StrOrTallyType) -> TallyColor: 178 | """Get the color of the given tally_type 179 | 180 | If tally_type is a combination of tally types, the color returned will 181 | be a combination all of the matched color properties. 182 | 183 | Arguments: 184 | tally_type (TallyType or str): :class:`~.common.TallyType` member(s) 185 | to get the color values from. 186 | 187 | If the argument is a string, it should be formatted as shown in 188 | :meth:`.TallyType.from_str` 189 | 190 | 191 | >>> tally = Tally(0) 192 | >>> tally.get_color('rh_tally') 193 | 194 | >>> tally.set_color('rh_tally', 'red') 195 | >>> tally.get_color('rh_tally') 196 | 197 | >>> tally.set_color('txt_tally', 'red') 198 | >>> tally.get_color('rh_tally|txt_tally') 199 | 200 | >>> tally.get_color('all') 201 | 202 | >>> tally.set_color('lh_tally', 'green') 203 | >>> tally.get_color('lh_tally') 204 | 205 | >>> tally.get_color('all') 206 | 207 | 208 | .. versionadded:: 0.0.5 209 | """ 210 | return self[tally_type] 211 | 212 | def merge_color(self, tally_type: TallyType, color: TallyColor): 213 | """Merge the color property (or properties) for the given TallyType 214 | using the :meth:`set_color` method 215 | 216 | Combines the existing color value with the one provided using a bitwise 217 | ``|`` (or) operation 218 | 219 | Arguments: 220 | tally_type (TallyType): The :class:`~.common.TallyType` member(s) 221 | to merge. Multiple types can be specified using 222 | bitwise ``|`` operators. 223 | color (TallyColor): The :class:`~.common.TallyColor` to merge 224 | 225 | .. versionadded:: 0.0.4 226 | """ 227 | for ttype in tally_type: 228 | cur_color = self[ttype] 229 | new_color = cur_color | color 230 | if new_color == cur_color: 231 | continue 232 | self[ttype] = new_color 233 | 234 | def merge(self, other: Tally, tally_type: TallyType = TallyType.all_tally): 235 | """Merge the color(s) from another Tally instance into this one using 236 | the :meth:`merge_color` method 237 | 238 | Arguments: 239 | other (Tally): The Tally instance to merge with 240 | tally_type (TallyType, optional): The :class:`~.common.TallyType` 241 | member(s) to merge. Multiple types can be specified using 242 | bitwise ``|`` operators. 243 | Default is :attr:`~.common.TallyType.all_tally` (all three types) 244 | 245 | .. versionadded:: 0.0.4 246 | """ 247 | for ttype in tally_type: 248 | color = other[ttype] 249 | self.merge_color(ttype, color) 250 | 251 | def update(self, **kwargs) -> set[str]: 252 | """Update any known properties from the given keyword-arguments 253 | 254 | Returns: 255 | set: The property names, if any, that changed 256 | """ 257 | log_updated = kwargs.pop('LOG_UPDATED', False) 258 | props_changed = set() 259 | self._updating_props = True 260 | for attr in self._prop_attrs: 261 | if attr not in kwargs: 262 | continue 263 | val = kwargs[attr] 264 | if attr == 'control' and val != b'': 265 | if self.control == val: 266 | # logger.debug(f'resetting control, {val=}, {self.control=}') 267 | self.control = b'' 268 | if getattr(self, attr) == val: 269 | continue 270 | props_changed.add(attr) 271 | setattr(self, attr, val) 272 | if attr == 'brightness': 273 | val = cast(int, val) 274 | self.normalized_brightness = val / 3 275 | if log_updated: 276 | logger.debug(f'{self!r}.{attr} = {val!r}') 277 | self._updating_props = False 278 | if 'control' in props_changed and self.control != b'': 279 | self.emit('on_control', self, self.control) 280 | if len(props_changed): 281 | self.emit('on_update', self, props_changed) 282 | return props_changed 283 | 284 | def update_from_display(self, display: Display) -> set[str]: 285 | """Update this instance from the values of the given 286 | :class:`~.messages.Display` object 287 | 288 | Returns: 289 | set: The property names, if any, that changed 290 | """ 291 | attrs = set(self._prop_attrs) 292 | is_control = display.type.name == 'control' 293 | if is_control: 294 | attrs.discard('text') 295 | else: 296 | attrs.discard('control') 297 | kw = {attr:getattr(display, attr) for attr in attrs} 298 | kw['LOG_UPDATED'] = True 299 | props_changed = self.update(**kw) 300 | return props_changed 301 | 302 | def to_dict(self) -> dict: 303 | """Serialize to a :class:`dict` 304 | """ 305 | d = {attr:getattr(self, attr) for attr in self._prop_attrs} 306 | d['index'] = self.index 307 | if self.screen is None: 308 | d['id'] = None 309 | else: 310 | d['id'] = self.id 311 | return d 312 | 313 | # def to_display(self) -> 'tslumd.messages.Display': 314 | # """Create a :class:`~.messages.Display` from this instance 315 | # """ 316 | # kw = self.to_dict() 317 | # return Display(**kw) 318 | 319 | def _on_prop_changed(self, instance, value, **kwargs): 320 | if self._updating_props: 321 | return 322 | prop = kwargs['property'] 323 | if prop.name == 'control' and value != b'': 324 | self.emit('on_control', self, value) 325 | if prop.name == 'brightness': 326 | value = cast(int, value) 327 | self.normalized_brightness = value / 3 328 | self.emit('on_update', self, set([prop.name])) 329 | 330 | def __getitem__(self, key: StrOrTallyType) -> TallyColor: 331 | if not isinstance(key, TallyType): 332 | key = TallyType.from_str(key) 333 | if key.is_iterable: 334 | color = TallyColor.OFF 335 | for tt in key: 336 | color |= getattr(self, tt.name) 337 | return color 338 | return getattr(self, key.name) 339 | 340 | def __setitem__(self, key: StrOrTallyType, value: StrOrTallyColor): 341 | if not isinstance(key, TallyType): 342 | key = TallyType.from_str(key) 343 | if not isinstance(value, TallyColor): 344 | value = TallyColor.from_str(value) 345 | if key.is_iterable: 346 | for tt in key: 347 | setattr(self, tt.name, value) 348 | else: 349 | setattr(self, key.name, value) 350 | 351 | def __eq__(self, other): 352 | if not isinstance(other, Tally): 353 | return NotImplemented 354 | return self.to_dict() == other.to_dict() 355 | 356 | def __ne__(self, other): 357 | if not isinstance(other, Tally): 358 | return NotImplemented 359 | return self.to_dict() != other.to_dict() 360 | 361 | def __repr__(self): 362 | return f'<{self.__class__.__name__}: ({self})>' 363 | 364 | def __str__(self): 365 | if self.__id is None: 366 | return f'{self.index} - "{self.text}"' 367 | return f'{self.id} - "{self.text}"' 368 | 369 | class Screen(Dispatcher): 370 | """A group of :class:`Tally` displays 371 | 372 | Properties: 373 | scontrol(bytes): Any control data received for the screen 374 | 375 | :Events: 376 | .. event:: on_tally_added(tally: Tally) 377 | 378 | Fired when a new :class:`Tally` instance is added to the screen 379 | 380 | .. event:: on_tally_update(tally: Tally, props_changed: set[str]) 381 | 382 | Fired when any :class:`Tally` property changes. This is a 383 | retransmission of :event:`Tally.on_update` 384 | 385 | .. event:: on_tally_control(tally: Tally, data: bytes) 386 | 387 | Fired when control data is received for a :class:`Tally` object. 388 | This is a retransmission of :event:`Tally.on_control` 389 | 390 | .. event:: on_control(instance: Screen, data: bytes) 391 | 392 | Fired when control data is received for the :class:`Screen` itself 393 | 394 | .. versionadded:: 0.0.3 395 | 396 | """ 397 | 398 | tallies: dict[int, Tally] 399 | """Mapping of :class:`Tally` objects within the screen using their 400 | :attr:`~Tally.index` as keys 401 | """ 402 | 403 | scontrol = Property(b'') 404 | 405 | _events_ = [ 406 | 'on_tally_added', 'on_tally_update', 'on_tally_control', 'on_control', 407 | ] 408 | def __init__(self, index_: int): 409 | self.__index = index_ 410 | self.tallies = {} 411 | self.bind(scontrol=self._on_scontrol_prop) 412 | 413 | @property 414 | def index(self) -> int: 415 | """The screen index from 0 to 65534 (``0xFFFE``) 416 | """ 417 | return self.__index 418 | 419 | @property 420 | def is_broadcast(self) -> bool: 421 | """``True`` if the screen is to be "broadcast", meaning sent to all 422 | :attr:`screen indices<.messages.Message.screen>`. 423 | 424 | (if the :attr:`index` is ``0xffff``) 425 | """ 426 | return self.index == 0xffff 427 | 428 | @classmethod 429 | def broadcast(cls, **kwargs) -> 'Screen': 430 | """Create a :attr:`broadcast ` :class:`Screen` 431 | 432 | (with :attr:`index` set to ``0xffff``) 433 | """ 434 | return cls(0xffff, **kwargs) 435 | 436 | def broadcast_tally(self, **kwargs) -> Tally: 437 | """Create a temporary :class:`Tally` using :meth:`Tally.broadcast` 438 | 439 | Arguments: 440 | **kwargs: Keyword arguments to pass to the :class:`Tally` constructor 441 | 442 | Note: 443 | The tally object is not stored in :attr:`tallies` and no event 444 | propagation (:event:`on_tally_added`, :event:`on_tally_update`, 445 | :event:`on_tally_control`) is handled by the :class:`Screen`. 446 | """ 447 | return Tally.broadcast(screen=self, **kwargs) 448 | 449 | def add_tally(self, index_: int, **kwargs) -> Tally: 450 | """Create a :class:`Tally` object and add it to :attr:`tallies` 451 | 452 | Arguments: 453 | index_: The tally :attr:`~Tally.index` 454 | **kwargs: Keyword arguments passed to create the tally instance 455 | 456 | Raises: 457 | KeyError: If the given ``index_`` already exists 458 | """ 459 | if index_ in self: 460 | raise KeyError(f'Tally exists for index {index_}') 461 | tally = Tally(index_, screen=self, **kwargs) 462 | self._add_tally_obj(tally) 463 | return tally 464 | 465 | def get_or_create_tally(self, index_: int) -> Tally: 466 | """If a :class:`Tally` object matching the given index exists, return 467 | it. Otherwise create one and add it to :attr:`tallies` 468 | 469 | This method is similar to :meth:`add_tally` and it can be used to avoid 470 | exception handling. It does not however take keyword arguments and 471 | is only intended for object creation. 472 | """ 473 | if index_ in self: 474 | return self[index_] 475 | return self.add_tally(index_) 476 | 477 | def _add_tally_obj(self, tally: Tally): 478 | assert not tally.is_broadcast 479 | self.tallies[tally.index] = tally 480 | tally.bind( 481 | on_update=self._on_tally_updated, 482 | on_control=self._on_tally_control, 483 | ) 484 | self.emit('on_tally_added', tally) 485 | 486 | def update_from_message(self, msg: Message): 487 | """Handle an incoming :class:`~.Message` 488 | """ 489 | if msg.screen != self.index and not msg.broadcast: 490 | return 491 | if msg.type == MessageType.control: 492 | self.scontrol = msg.scontrol 493 | else: 494 | for dmsg in msg.displays: 495 | self.handle_dmsg(dmsg) 496 | 497 | def handle_dmsg(self, dmsg: Display): 498 | if dmsg.is_broadcast: 499 | for tally in self: 500 | tally.update_from_display(dmsg) 501 | else: 502 | if dmsg.index not in self: 503 | tally = Tally.from_display(dmsg, screen=self) 504 | self._add_tally_obj(tally) 505 | if dmsg.type == MessageType.control: 506 | tally.emit('on_control', tally, tally.control) 507 | else: 508 | tally = self[dmsg.index] 509 | tally.update_from_display(dmsg) 510 | 511 | def _on_tally_updated(self, *args, **kwargs): 512 | self.emit('on_tally_update', *args, **kwargs) 513 | 514 | def _on_tally_control(self, *args, **kwargs): 515 | self.emit('on_tally_control', *args, **kwargs) 516 | 517 | def _on_scontrol_prop(self, instance: 'Screen', value: bytes, **kwargs): 518 | if not len(value): 519 | return 520 | self.emit('on_control', self, value) 521 | 522 | def __getitem__(self, key: int) -> Tally: 523 | return self.tallies[key] 524 | 525 | def __contains__(self, key: int) -> bool: 526 | return key in self.tallies 527 | 528 | def keys(self) -> Iterator[int]: 529 | yield from sorted((k for k in self.tallies.keys() if k != 0xffff)) 530 | 531 | def values(self) -> Iterator[Tally]: 532 | for key in self.keys(): 533 | yield self[key] 534 | 535 | def items(self) -> Iterator[Tuple[int, Tally]]: 536 | for key in self.keys(): 537 | yield key, self[key] 538 | 539 | def __iter__(self) -> Iterator[Tally]: 540 | yield from self.values() 541 | 542 | def __repr__(self): 543 | return f'<{self.__class__.__name__}: {self}>' 544 | 545 | def __str__(self): 546 | return f'{self.index}' 547 | -------------------------------------------------------------------------------- /src/tslumd/utils.py: -------------------------------------------------------------------------------- 1 | try: 2 | import loguru 3 | from loguru import logger 4 | except ImportError: # pragma: no cover 5 | loguru = None 6 | import logging 7 | logger = logging.getLogger(__name__) 8 | import functools 9 | import inspect 10 | 11 | if loguru is not None: 12 | logger_catch = logger.catch 13 | else: 14 | def logger_catch(f): 15 | if inspect.iscoroutinefunction(f): 16 | @functools.wraps(f) 17 | async def wrapper(*args, **kwargs): 18 | try: 19 | return await f(*args, **kwargs) 20 | except Exception as exc: 21 | logger.exception(exc) 22 | # raise 23 | else: 24 | @functools.wraps(f) 25 | def wrapper(*args, **kwargs): 26 | try: 27 | f(*args, **kwargs) 28 | except Exception as exc: 29 | logger.error(f'Error in {f!r}', exc_info=True) 30 | return wrapper 31 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import json 3 | import socket 4 | import pytest 5 | 6 | from tslumd import TallyColor, Message, Display 7 | 8 | HERE = Path(__file__).resolve().parent 9 | DATA_DIR = HERE / 'data' 10 | MESSAGE_FILE = DATA_DIR / 'uhs500-message.umd' 11 | MESSAGE_JSON = DATA_DIR / 'uhs500-tally.json' 12 | 13 | @pytest.fixture(scope='session') 14 | def non_loopback_hostaddr(): 15 | hostname, aliases, addrs = socket.gethostbyname_ex(socket.gethostname()) 16 | addrs = [addr for addr in addrs if addr != '127.0.0.1'] 17 | assert len(addrs) 18 | return addrs[0] 19 | 20 | @pytest.fixture 21 | def uhs500_msg_bytes() -> bytes: 22 | """Real message data received from an AV-UHS500 switcher 23 | """ 24 | return MESSAGE_FILE.read_bytes() 25 | 26 | @pytest.fixture 27 | def uhs500_msg_parsed() -> Message: 28 | """Expected :class:`~tslumd.messages.Message` object 29 | matching data from :func:`uhs500_msg_bytes` 30 | """ 31 | data = json.loads(MESSAGE_JSON.read_text()) 32 | data['scontrol'] = b'' 33 | displays = [] 34 | for disp in data['displays']: 35 | for key in ['rh_tally', 'txt_tally', 'lh_tally']: 36 | disp[key] = getattr(TallyColor, disp[key]) 37 | displays.append(Display(**disp)) 38 | data['displays'] = displays 39 | return Message(**data) 40 | 41 | @pytest.fixture 42 | def udp_port0(unused_udp_port_factory): 43 | return unused_udp_port_factory() 44 | 45 | @pytest.fixture 46 | def udp_port(unused_udp_port_factory): 47 | return unused_udp_port_factory() 48 | -------------------------------------------------------------------------------- /tests/data/uhs500-message.umd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nocarryr/tslumd/f3df164dae57ca8a9babe83cbe0390be6856a5da/tests/data/uhs500-message.umd -------------------------------------------------------------------------------- /tests/data/uhs500-tally.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 0, 3 | "flags": 0, 4 | "screen": 0, 5 | "displays": [ 6 | { 7 | "index": 1, 8 | "text": "IN1", 9 | "brightness": 0, 10 | "rh_tally": "RED", 11 | "txt_tally": "OFF", 12 | "lh_tally": "OFF" 13 | }, 14 | { 15 | "index": 2, 16 | "text": "IN2", 17 | "brightness": 0, 18 | "rh_tally": "OFF", 19 | "txt_tally": "GREEN", 20 | "lh_tally": "OFF" 21 | }, 22 | { 23 | "index": 3, 24 | "text": "SDI IN3", 25 | "brightness": 0, 26 | "rh_tally": "OFF", 27 | "txt_tally": "OFF", 28 | "lh_tally": "OFF" 29 | }, 30 | { 31 | "index": 4, 32 | "text": "SDI IN4", 33 | "brightness": 0, 34 | "rh_tally": "OFF", 35 | "txt_tally": "OFF", 36 | "lh_tally": "OFF" 37 | }, 38 | { 39 | "index": 5, 40 | "text": "SDI IN5", 41 | "brightness": 0, 42 | "rh_tally": "OFF", 43 | "txt_tally": "OFF", 44 | "lh_tally": "OFF" 45 | }, 46 | { 47 | "index": 6, 48 | "text": "SDI IN6", 49 | "brightness": 0, 50 | "rh_tally": "OFF", 51 | "txt_tally": "OFF", 52 | "lh_tally": "OFF" 53 | }, 54 | { 55 | "index": 7, 56 | "text": "SDI IN7", 57 | "brightness": 0, 58 | "rh_tally": "OFF", 59 | "txt_tally": "OFF", 60 | "lh_tally": "OFF" 61 | }, 62 | { 63 | "index": 8, 64 | "text": "SDI IN8", 65 | "brightness": 0, 66 | "rh_tally": "OFF", 67 | "txt_tally": "OFF", 68 | "lh_tally": "OFF" 69 | }, 70 | { 71 | "index": 145, 72 | "text": "CBGD1", 73 | "brightness": 0, 74 | "rh_tally": "OFF", 75 | "txt_tally": "OFF", 76 | "lh_tally": "OFF" 77 | }, 78 | { 79 | "index": 146, 80 | "text": "CBGD2", 81 | "brightness": 0, 82 | "rh_tally": "OFF", 83 | "txt_tally": "OFF", 84 | "lh_tally": "OFF" 85 | }, 86 | { 87 | "index": 147, 88 | "text": "CBAR", 89 | "brightness": 0, 90 | "rh_tally": "OFF", 91 | "txt_tally": "OFF", 92 | "lh_tally": "OFF" 93 | }, 94 | { 95 | "index": 148, 96 | "text": "Black", 97 | "brightness": 0, 98 | "rh_tally": "OFF", 99 | "txt_tally": "OFF", 100 | "lh_tally": "OFF" 101 | }, 102 | { 103 | "index": 149, 104 | "text": "Still 1V", 105 | "brightness": 0, 106 | "rh_tally": "OFF", 107 | "txt_tally": "OFF", 108 | "lh_tally": "OFF" 109 | }, 110 | { 111 | "index": 150, 112 | "text": "Still 1K", 113 | "brightness": 0, 114 | "rh_tally": "OFF", 115 | "txt_tally": "OFF", 116 | "lh_tally": "OFF" 117 | }, 118 | { 119 | "index": 151, 120 | "text": "Still 2V", 121 | "brightness": 0, 122 | "rh_tally": "OFF", 123 | "txt_tally": "OFF", 124 | "lh_tally": "OFF" 125 | }, 126 | { 127 | "index": 152, 128 | "text": "Still 2K", 129 | "brightness": 0, 130 | "rh_tally": "OFF", 131 | "txt_tally": "OFF", 132 | "lh_tally": "OFF" 133 | }, 134 | { 135 | "index": 157, 136 | "text": "Clip 1V", 137 | "brightness": 0, 138 | "rh_tally": "OFF", 139 | "txt_tally": "OFF", 140 | "lh_tally": "OFF" 141 | }, 142 | { 143 | "index": 158, 144 | "text": "Clip 1K", 145 | "brightness": 0, 146 | "rh_tally": "OFF", 147 | "txt_tally": "OFF", 148 | "lh_tally": "OFF" 149 | }, 150 | { 151 | "index": 159, 152 | "text": "Clip 2V", 153 | "brightness": 0, 154 | "rh_tally": "OFF", 155 | "txt_tally": "OFF", 156 | "lh_tally": "OFF" 157 | }, 158 | { 159 | "index": 160, 160 | "text": "Clip 2K", 161 | "brightness": 0, 162 | "rh_tally": "OFF", 163 | "txt_tally": "OFF", 164 | "lh_tally": "OFF" 165 | }, 166 | { 167 | "index": 165, 168 | "text": "MV1", 169 | "brightness": 0, 170 | "rh_tally": "OFF", 171 | "txt_tally": "OFF", 172 | "lh_tally": "OFF" 173 | }, 174 | { 175 | "index": 166, 176 | "text": "MV2", 177 | "brightness": 0, 178 | "rh_tally": "OFF", 179 | "txt_tally": "OFF", 180 | "lh_tally": "OFF" 181 | }, 182 | { 183 | "index": 171, 184 | "text": "Key Out", 185 | "brightness": 0, 186 | "rh_tally": "OFF", 187 | "txt_tally": "OFF", 188 | "lh_tally": "OFF" 189 | }, 190 | { 191 | "index": 172, 192 | "text": "CLN", 193 | "brightness": 0, 194 | "rh_tally": "OFF", 195 | "txt_tally": "OFF", 196 | "lh_tally": "OFF" 197 | }, 198 | { 199 | "index": 201, 200 | "text": "PGM", 201 | "brightness": 0, 202 | "rh_tally": "RED", 203 | "txt_tally": "OFF", 204 | "lh_tally": "OFF" 205 | }, 206 | { 207 | "index": 203, 208 | "text": "PVW", 209 | "brightness": 0, 210 | "rh_tally": "OFF", 211 | "txt_tally": "GREEN", 212 | "lh_tally": "OFF" 213 | }, 214 | { 215 | "index": 209, 216 | "text": "ME PGM", 217 | "brightness": 0, 218 | "rh_tally": "OFF", 219 | "txt_tally": "OFF", 220 | "lh_tally": "OFF" 221 | }, 222 | { 223 | "index": 227, 224 | "text": "AUX1", 225 | "brightness": 0, 226 | "rh_tally": "OFF", 227 | "txt_tally": "OFF", 228 | "lh_tally": "OFF" 229 | }, 230 | { 231 | "index": 228, 232 | "text": "AUX2", 233 | "brightness": 0, 234 | "rh_tally": "OFF", 235 | "txt_tally": "OFF", 236 | "lh_tally": "OFF" 237 | }, 238 | { 239 | "index": 229, 240 | "text": "AUX3", 241 | "brightness": 0, 242 | "rh_tally": "OFF", 243 | "txt_tally": "OFF", 244 | "lh_tally": "OFF" 245 | }, 246 | { 247 | "index": 230, 248 | "text": "AUX4", 249 | "brightness": 0, 250 | "rh_tally": "OFF", 251 | "txt_tally": "OFF", 252 | "lh_tally": "OFF" 253 | }, 254 | { 255 | "index": 251, 256 | "text": "CLOCK", 257 | "brightness": 0, 258 | "rh_tally": "OFF", 259 | "txt_tally": "OFF", 260 | "lh_tally": "OFF" 261 | }, 262 | { 263 | "index": 255, 264 | "text": "IN-A1", 265 | "brightness": 0, 266 | "rh_tally": "OFF", 267 | "txt_tally": "OFF", 268 | "lh_tally": "OFF" 269 | }, 270 | { 271 | "index": 256, 272 | "text": "IN-A2", 273 | "brightness": 0, 274 | "rh_tally": "OFF", 275 | "txt_tally": "OFF", 276 | "lh_tally": "OFF" 277 | }, 278 | { 279 | "index": 257, 280 | "text": "IN-A3", 281 | "brightness": 0, 282 | "rh_tally": "OFF", 283 | "txt_tally": "OFF", 284 | "lh_tally": "OFF" 285 | }, 286 | { 287 | "index": 258, 288 | "text": "IN-A4", 289 | "brightness": 0, 290 | "rh_tally": "OFF", 291 | "txt_tally": "OFF", 292 | "lh_tally": "OFF" 293 | }, 294 | { 295 | "index": 259, 296 | "text": "IN-B1", 297 | "brightness": 0, 298 | "rh_tally": "OFF", 299 | "txt_tally": "OFF", 300 | "lh_tally": "OFF" 301 | }, 302 | { 303 | "index": 260, 304 | "text": "IN-B2", 305 | "brightness": 0, 306 | "rh_tally": "OFF", 307 | "txt_tally": "OFF", 308 | "lh_tally": "OFF" 309 | }, 310 | { 311 | "index": 261, 312 | "text": "IN-B3", 313 | "brightness": 0, 314 | "rh_tally": "OFF", 315 | "txt_tally": "OFF", 316 | "lh_tally": "OFF" 317 | }, 318 | { 319 | "index": 262, 320 | "text": "IN-B4", 321 | "brightness": 0, 322 | "rh_tally": "OFF", 323 | "txt_tally": "OFF", 324 | "lh_tally": "OFF" 325 | }, 326 | { 327 | "index": 265, 328 | "text": "", 329 | "brightness": 0, 330 | "rh_tally": "OFF", 331 | "txt_tally": "OFF", 332 | "lh_tally": "OFF" 333 | } 334 | ] 335 | } -------------------------------------------------------------------------------- /tests/test_messages.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import pytest 3 | 4 | from tslumd import TallyColor, Message, Display, MessageType 5 | from tslumd.messages import ( 6 | Flags, ParseError, DmsgParseError, 7 | DmsgControlParseError, MessageParseError, MessageLengthError, 8 | ) 9 | 10 | @pytest.fixture 11 | def message_with_lots_of_displays(): 12 | msgobj = Message() 13 | # Message header byte length: 6 14 | # Dmsg header byte length: 4 15 | # Text length: 2(length bytes) + 9(chars) = 11 16 | # Each Dmsg total: 4 + 11 = 15 17 | # Dmsg's: 4096 * 15 = 61440 18 | # 4096 Dmsg's with Message header: 4096 * 15 + 6 = 61446 bytes 19 | for i in range(4096): 20 | msgobj.displays.append(Display(index=i, text=f'Foo {i:05d}')) 21 | return msgobj 22 | 23 | 24 | def test_uhs_message(uhs500_msg_bytes, uhs500_msg_parsed): 25 | parsed, remaining = Message.parse(uhs500_msg_bytes) 26 | assert not len(remaining) 27 | assert parsed == uhs500_msg_parsed 28 | 29 | def test_messages(): 30 | msgobj = Message(version=1, screen=5) 31 | rh_tallies = [getattr(TallyColor, attr) for attr in ['RED','OFF','GREEN','AMBER']] 32 | lh_tallies = [getattr(TallyColor, attr) for attr in ['GREEN','RED','OFF','RED']] 33 | txt_tallies = [getattr(TallyColor, attr) for attr in ['OFF','GREEN','AMBER','GREEN']] 34 | txts = ['foo', 'bar', 'baz', 'blah'] 35 | indices = [4,3,7,1] 36 | for i in range(4): 37 | disp = Display( 38 | index=indices[i], rh_tally=rh_tallies[i], lh_tally=lh_tallies[i], 39 | txt_tally=txt_tallies[i], text=txts[i], brightness=i, 40 | ) 41 | msgobj.displays.append(disp) 42 | 43 | parsed, remaining = Message.parse(msgobj.build_message()) 44 | assert not len(remaining) 45 | 46 | for i in range(len(rh_tallies)): 47 | disp1, disp2 = msgobj.displays[i], parsed.displays[i] 48 | assert disp1.rh_tally == disp2.rh_tally == rh_tallies[i] 49 | assert disp1.lh_tally == disp2.lh_tally == lh_tallies[i] 50 | assert disp1.txt_tally == disp2.txt_tally == txt_tallies[i] 51 | assert disp1.text == disp2.text == txts[i] 52 | assert disp1.index == disp2.index == indices[i] 53 | assert disp1 == disp2 54 | 55 | for attr in ['version', 'flags', 'screen', 'scontrol']: 56 | assert getattr(msgobj, attr) == getattr(parsed, attr) 57 | 58 | assert msgobj == parsed 59 | 60 | 61 | def test_packet_length(faker, message_with_lots_of_displays): 62 | msgobj = message_with_lots_of_displays 63 | 64 | # Make sure the 2048 byte limit is respected 65 | with pytest.raises(MessageLengthError): 66 | _ = msgobj.build_message() 67 | 68 | # Ensure that the limit can be bypassed 69 | msg_bytes = msgobj.build_message(ignore_packet_length=True) 70 | parsed, remaining = Message.parse(msg_bytes) 71 | assert parsed == msgobj 72 | 73 | # Iterate over individual message packets and make sure we get all displays 74 | all_parsed_displays = [] 75 | for msg_bytes in msgobj.build_messages(): 76 | assert len(msg_bytes) <= 2048 77 | parsed, remaining = Message.parse(msg_bytes) 78 | assert not len(remaining) 79 | all_parsed_displays.extend(parsed.displays) 80 | 81 | assert len(all_parsed_displays) == len(msgobj.displays) 82 | for disp, parsed_disp in zip(msgobj.displays, all_parsed_displays): 83 | assert disp.index == parsed_disp.index 84 | assert disp.text == parsed_disp.text 85 | 86 | # Create an SCONTROL that exceeds the limit 87 | msgobj = Message(scontrol=faker.binary(length=2048)) 88 | with pytest.raises(MessageLengthError): 89 | it = msgobj.build_messages() 90 | _ = next(it) 91 | 92 | # Create a Dmsg control that exceeds the limit 93 | msgobj = Message(displays=[Display(index=0, control=faker.binary(length=2048))]) 94 | with pytest.raises(MessageLengthError): 95 | it = msgobj.build_messages() 96 | _ = next(it) 97 | 98 | 99 | def test_broadcast_message(faker): 100 | for i in range(1000): 101 | # Create Messages with random `screen` value in the non-broadcast range 102 | # and ensure the `is_broadcast` field is correct in both the 103 | # instance and its parsed version 104 | screen = faker.pyint(max_value=0xfffe) 105 | msgobj = Message(screen=screen) 106 | msgobj.displays.append(Display(index=i)) 107 | assert not msgobj.is_broadcast 108 | 109 | parsed, remaining = Message.parse(msgobj.build_message()) 110 | assert not msgobj.is_broadcast 111 | 112 | # Create broadcast Messages using both methods and check the `is_broadcast` 113 | # field on the instances and their parsed versions 114 | msgobj1 = Message(screen=0xffff) 115 | msgobj1.displays.append(Display(index=1)) 116 | assert msgobj1.is_broadcast 117 | parsed1, remaining = Message.parse(msgobj1.build_message()) 118 | assert parsed1.is_broadcast 119 | 120 | msgobj2 = Message.broadcast(displays=[Display(index=1)]) 121 | assert msgobj2.is_broadcast 122 | parsed2, remaining = Message.parse(msgobj2.build_message()) 123 | assert parsed2.is_broadcast 124 | 125 | assert msgobj1 == msgobj2 == parsed1 == parsed2 126 | 127 | def test_broadcast_display(uhs500_msg_parsed, faker): 128 | 129 | disp_attrs = ('rh_tally', 'txt_tally', 'lh_tally', 'text', 'brightness') 130 | msgobj = Message() 131 | 132 | for uhs_disp in uhs500_msg_parsed.displays: 133 | assert not uhs_disp.is_broadcast 134 | 135 | # General kwargs excluding the `index` 136 | kw = {attr:getattr(uhs_disp, attr) for attr in disp_attrs} 137 | 138 | # Create random Displays within non-broadcast range and check the 139 | # `is_broadcast` field of the instance and its parsed version 140 | for _ in range(1000): 141 | ix = faker.pyint(max_value=0xfffe) 142 | disp = Display(index=ix, **kw) 143 | assert not disp.is_broadcast 144 | 145 | parsed, remaining = Display.from_dmsg(msgobj.flags, disp.to_dmsg(msgobj.flags)) 146 | assert not parsed.is_broadcast 147 | assert parsed == disp 148 | 149 | # Create broadcast Displays using both methods and check the 150 | # `is_broadcast` field on the instances and their parsed versions 151 | bc_disp1 = Display.broadcast(**kw) 152 | bc_disp2 = Display(index=0xffff, **kw) 153 | assert bc_disp1.is_broadcast 154 | assert bc_disp2.is_broadcast 155 | 156 | parsed1, remaining = Display.from_dmsg(msgobj.flags, bc_disp1.to_dmsg(msgobj.flags)) 157 | assert parsed1.is_broadcast 158 | 159 | parsed2, remaining = Display.from_dmsg(msgobj.flags, bc_disp2.to_dmsg(msgobj.flags)) 160 | assert parsed2.is_broadcast 161 | 162 | assert bc_disp1 == bc_disp2 == parsed1 == parsed2 163 | 164 | # Add the broadcast Display to the Message at the top 165 | msgobj.displays.append(bc_disp1) 166 | 167 | # Check the `is_broadcast` field in the displays after Message building / parsing 168 | parsed, remaining = Message.parse(msgobj.build_message()) 169 | for parsed_disp, bc_disp in zip(parsed.displays, msgobj.displays): 170 | assert parsed_disp.is_broadcast 171 | assert parsed_disp == bc_disp 172 | 173 | 174 | 175 | def test_scontrol(faker): 176 | for _ in range(100): 177 | data_len = faker.pyint(min_value=1, max_value=1024) 178 | control_data = faker.binary(length=data_len) 179 | 180 | msgobj = Message(scontrol=control_data) 181 | assert msgobj.type == MessageType.control 182 | assert Flags.SCONTROL in msgobj.flags 183 | 184 | msg_bytes = msgobj.build_message() 185 | parsed, remaining = Message.parse(msg_bytes) 186 | assert not len(remaining) 187 | 188 | assert parsed.type == MessageType.control 189 | assert parsed.scontrol == control_data 190 | assert parsed == msgobj 191 | 192 | disp = Display(index=1) 193 | 194 | with pytest.raises(ValueError) as excinfo: 195 | disp_msg = Message(displays=[disp], scontrol=control_data) 196 | assert 'SCONTROL' in str(excinfo.value) 197 | 198 | def test_dmsg_control(uhs500_msg_parsed, faker): 199 | tested_zero = False 200 | for _ in range(10): 201 | msgobj = Message(version=1, screen=5) 202 | for orig_disp in uhs500_msg_parsed.displays: 203 | if not tested_zero: 204 | data_len = 0 205 | tested_zero = True 206 | else: 207 | data_len = faker.pyint(min_value=0, max_value=1024) 208 | control_data = faker.binary(length=data_len) 209 | 210 | kw = orig_disp.to_dict() 211 | del kw['text'] 212 | kw['control'] = control_data 213 | if not len(control_data): 214 | kw['type'] = MessageType.control 215 | disp = Display(**kw) 216 | 217 | assert disp.type == MessageType.control 218 | 219 | disp_bytes = disp.to_dmsg(msgobj.flags) 220 | parsed_disp, remaining = Display.from_dmsg(msgobj.flags, disp_bytes) 221 | 222 | assert not len(remaining) 223 | assert parsed_disp.control == control_data 224 | assert parsed_disp == disp 225 | 226 | msgobj.displays.append(disp) 227 | 228 | parsed = None 229 | for msg_bytes in msgobj.build_messages(): 230 | _parsed, remaining = Message.parse(msg_bytes) 231 | assert not len(remaining) 232 | if parsed is None: 233 | parsed = _parsed 234 | else: 235 | parsed.displays.extend(_parsed.displays) 236 | assert parsed == msgobj 237 | 238 | with pytest.raises(ValueError) as excinfo: 239 | disp = Display(index=1, control=b'foo', text='foo') 240 | excstr = str(excinfo.value).lower() 241 | assert 'control' in excstr and 'text' in excstr 242 | 243 | with pytest.raises(ValueError) as excinfo: 244 | disp = Display(index=1, text='foo', type=MessageType.control) 245 | excstr = str(excinfo.value).lower() 246 | assert 'control' in excstr and 'text' in excstr 247 | 248 | def test_invalid_message(uhs500_msg_bytes, faker): 249 | bad_bytes = faker.binary(length=5) 250 | with pytest.raises(MessageParseError) as excinfo: 251 | r = Message.parse(bad_bytes) 252 | assert 'header' in str(excinfo.value).lower() 253 | 254 | bad_bytes = bytearray(uhs500_msg_bytes) 255 | bad_byte_count = struct.pack('