├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── demo ├── demo.cast ├── demo.gif └── demo.zsh ├── netaudio ├── __init__.py ├── __main__.py ├── console │ ├── __init__.py │ ├── application.py │ └── commands │ │ ├── __init__.py │ │ ├── channel │ │ ├── __init__.py │ │ └── _list.py │ │ ├── config │ │ └── __init__.py │ │ ├── device │ │ ├── __init__.py │ │ └── _list.py │ │ ├── server │ │ ├── __init__.py │ │ ├── _http.py │ │ └── _mdns.py │ │ └── subscription │ │ ├── __init__.py │ │ ├── _add.py │ │ ├── _list.py │ │ └── _remove.py ├── dante │ ├── __init__.py │ ├── browser.py │ ├── channel.py │ ├── const.py │ ├── control.py │ ├── device.py │ ├── multicast.py │ └── subscription.py ├── utils │ ├── __init__.py │ └── timeout.py └── version.py ├── poetry.lock ├── pylintrc ├── pyproject.toml └── tests ├── __init__.py └── test_aes67.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .eggs 3 | .sw* 4 | dist/ 5 | __pycache__ 6 | debug.log 7 | .hypothesis/ 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.0.10 (2022-02-24) 2 | ### Fix 3 | 4 | - Handled PipeError, such as when piping to head, for example. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | This program exists for my own personal use. I've published it here only so 2 | that others who find it useful can give me feedback or examine what I've done 3 | to more easily implement their own version of this. 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or distribute 4 | this software, either in source code form or as a compiled binary, for any 5 | purpose, commercial or non-commercial, and by any means. 6 | 7 | In jurisdictions that recognize copyright laws, the author or authors of this 8 | software dedicate any and all copyright interest in the software to the public 9 | domain. We make this dedication for the benefit of the public at large and to 10 | the detriment of our heirs and successors. We intend this dedication to be an 11 | overt act of relinquishment in perpetuity of all present and future rights to 12 | this software under copyright law. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 18 | ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 19 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ### Description 3 | 4 | This is a python program for controlling Dante network audio devices (and 5 | possibly others in the future). It's early, so expect things to break or 6 | switches to change. Use this at your own risk; it's not ready for anything 7 | other than a test environment and could make the devices behave unexpectedly. 8 | The first goal is to do everything that Dante Controller can do that would be 9 | useful for control of the devices from a command-line interface or within 10 | scripts. 11 | 12 | For more information, check out the [gearspace discussion](https://gearspace.com/board/music-computers/1221989-dante-routing-without-dante-controller-possible.html). 13 | 14 | ### Demo 15 | 16 |

netctl usage demo

17 | 18 | ### Features 19 | 20 | #### Current 21 | 22 | - AVIO input/output gain control 23 | - Add/remove subscriptions 24 | - CLI 25 | - Display active subscriptions, Rx and Tx channels, devices names and 26 | addresses, subscription status 27 | - JSON output 28 | - Set device latency, sample rate, encoding 29 | - Set/reset channel names, device names 30 | - mDNS device discovery 31 | 32 | #### Planned 33 | 34 | - Change channel/device names without affecting existing subscriptions 35 | - Change/display device settings (AES67 mode) 36 | - Client/server modes 37 | - Command prompt 38 | - Control of Shure wireless devices ([Axient 39 | receivers](https://pubs.shure.com/view/command-strings/AD4/en-US.pdf) and 40 | [PSM 41 | transmitters](https://pubs.shure.com/view/command-strings/PSM1000/en-US.pdf)) 42 | - Gather information from multicast traffic (make, model, lock status, 43 | subscription changes) 44 | - Signal presence indicator 45 | - Stand-alone command API 46 | - TUI 47 | - Web application UI 48 | - XML output (such as a Dante preset file) 49 | 50 | ### Installation 51 | 52 | To install from PyPI on most systems, use pip or pipx: 53 | 54 | ```bash 55 | pipx install netaudio 56 | ``` 57 | 58 | ```bash 59 | pip install netaudio 60 | ``` 61 | 62 | To install the package from a clone: 63 | ```bash 64 | pipx install --force --include-deps . 65 | ``` 66 | 67 | #### Arch Linux 68 | 69 | To install from AUR, build the package with 70 | [aur/python-netaudio](https://aur.archlinux.org/packages/python-netaudio). 71 | Otherwise, install with pipx. 72 | 73 | For development, install the following packages: 74 | 75 | ```bash 76 | pacman -S community/python-pipx community/python-poetry 77 | ``` 78 | 79 | #### WSL / Ubuntu 80 | ```bash 81 | apt-get install pipx 82 | pipx install netaudio 83 | ``` 84 | 85 | For development, also install poetry: 86 | 87 | ```bash 88 | pipx install poetry 89 | ``` 90 | 91 | #### MacOS 92 | 93 | Install pipx with brew and then use it to install: 94 | 95 | ```bash 96 | brew install pipx 97 | brew link pipx 98 | pipx install netaudio 99 | ``` 100 | 101 | For development, use brew to install and link poetry: 102 | 103 | ```bash 104 | brew install poetry 105 | brew link poetry 106 | ``` 107 | 108 | ### Usage 109 | 110 | To run without installing or for development, use poetry: 111 | 112 | ```bash 113 | poetry install 114 | poetry run netaudio 115 | ``` 116 | 117 | Run tests during development: 118 | 119 | ```bash 120 | poetry run pytest 121 | ``` 122 | 123 | Otherwise, run `netaudio`. 124 | 125 | ### Documentation 126 | 127 | - [Examples](https://github.com/chris-ritsen/network-audio-controller/wiki/Examples) 128 | - [Technical details](https://github.com/chris-ritsen/network-audio-controller/wiki/Technical-details) 129 | - [Testing](https://github.com/chris-ritsen/network-audio-controller/wiki/Testing) 130 | -------------------------------------------------------------------------------- /demo/demo.cast: -------------------------------------------------------------------------------- 1 | {"version": 2, "width": 69, "height": 17, "timestamp": 1645747486, "env": {"SHELL": "/usr/bin/zsh", "TERM": "screen-256color"}} 2 | [0.155286, "o", "^P"] 3 | [0.291394, "o", " \r\r"] 4 | [0.298296, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J​\u001b[K\u001b[?2004h\u001b[Kasciinema rec --overwrite ~/projects/netaudio/netaudio/demo/demo.cast \r\u001b[K"] 5 | [0.351822, "o", "\u001bM./demo.zsh\u001b[K\u001b[1B\r\u001b[K\u001bM\u001b[10C"] 6 | [0.8001, "o", "\u001b[?2004l\u001b[1B\r"] 7 | [0.840171, "o", "\u001b[H\u001b[J"] 8 | [0.840337, "o", "netaudio device list\r\n"] 9 | [2.526916, "o", "avio-input-2\r\navio-output-2\r\navio-usb\r\navio-usb-2\r\n"] 10 | [2.527055, "o", "avio-usb-3\r\ndinet-tx-1\r\nlx-dante\r\n"] 11 | [2.549683, "o", "\r\nnetaudio channel list --device-name=avio-output-2\r\n"] 12 | [4.232943, "o", "\u001b[32mavio-output-2\u001b[0m\r\n\u001b[32mrx channels\u001b[0m\r\n1:CH1\r\n2:CH2\r\n"] 13 | [4.260061, "o", "\r\nnetaudio channel list --device-name=avio-usb-2\r\n"] 14 | [5.951085, "o", "\u001b[32mavio-usb-2\u001b[0m\r\n"] 15 | [5.951236, "o", "\u001b[32mtx channels\u001b[0m\r\n1:Left\r\n2:Right\r\n\r\n\u001b[32mrx channels\u001b[0m\r\n1:playback_1\r\n"] 16 | [5.9513, "o", "2:playback_2\r\n"] 17 | [5.998281, "o", "\r\nnetaudio subscription list | head -n15 | sort -R | head -n10 | sort\r\n"] 18 | [7.694006, "o", "02@lx-dante <- 02@ad4d [Subscription unresolved]\r\n03@lx-dante <- Output 18@a32 [Subscription unresolved]\r\n04@lx-dante <- Output 17@a32 [Subscription unresolved]\r\n06@lx-dante <- Output 20@a32 [Subscription unresolved]\r\n07@lx-dante <- Left@avio-usb [Connected (Unicast)]\r\nCH2@avio-output-2 <- Output 17@a32 [Subscription unresolved]\r\nLeft@avio-usb-3 <- Output 17@a32 [Subscription unresolved]\r\nplayback_1@avio-usb-2 <- Output 17@a32 [Subscription unresolved]\r\nplayback_1@avio-usb <- Output 17@a32 [Subscription unresolved]\r\nplayback_2@avio-usb-2 <- Output 17@a32 [Subscription unresolved]\r\n"] 19 | [7.696529, "o", "\r\nnetaudio subscription remove --rx-channel-name=128 --rx-device-name=lx-dante\r\n"] 20 | [9.443559, "o", "\r\nnetaudio subscription add --tx-device-name='lx-dante' --tx-channel-name='128' --rx-channel-name='128' --rx-device-name='lx-dante'\r\n"] 21 | [11.127356, "o", "128@lx-dante <- 128@lx-dante\r\n"] 22 | [11.165537, "o", "\r\nnetaudio config --set-device-name='DI Box' --device-host='dinet-tx-1'\r\n"] 23 | [12.857215, "o", "Setting device name for dinet-tx-1 192.168.1.41 to DI Box\r\n"] 24 | [12.888997, "o", "\r\nnetaudio device list | grep -i 'DI Box'\r\n"] 25 | [14.577533, "o", "DI Box\r\n"] 26 | [14.601044, "o", "\r\nnetaudio config --set-device-name='dinet-tx-1' --device-host='192.168.1.41'\r\n"] 27 | [16.285949, "o", "Setting device name for DI Box 192.168.1.41 to dinet-tx-1\r\n"] 28 | [16.324525, "o", "\r\nnetaudio device list | grep -i 'dinet-tx'\r\n"] 29 | [18.012021, "o", "dinet-tx-1\r\n"] 30 | [18.037254, "o", " \r\r"] 31 | [18.044613, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J​\u001b[K\u001b[?2004h\u001b[K"] 32 | [19.804416, "o", "\u001b[?2004l\u001b[K\r\r\n"] 33 | -------------------------------------------------------------------------------- /demo/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris-ritsen/network-audio-controller/f4eef6a0021d29b36b9d805d7cb0f9c29f583673/demo/demo.gif -------------------------------------------------------------------------------- /demo/demo.zsh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/zsh 2 | 3 | clear 4 | 5 | echo netaudio device list 6 | netaudio device list 7 | 8 | echo '' 9 | echo 'netaudio channel list --device-name=avio-output-2' 10 | netaudio channel list --device-name=avio-output-2 11 | 12 | echo '' 13 | echo 'netaudio channel list --device-name=avio-usb-2' 14 | netaudio channel list --device-name=avio-usb-2 15 | 16 | echo '' 17 | echo 'netaudio subscription list | head -n15 | sort -R | head -n10 | sort' 18 | netaudio subscription list | head -n15 | sort -R | head -n10 | sort 19 | 20 | echo '' 21 | echo "netaudio subscription remove --rx-channel-name=128 --rx-device-name=lx-dante" 22 | netaudio subscription remove --rx-channel-name=128 --rx-device-name=lx-dante 23 | 24 | echo '' 25 | echo "netaudio subscription add --tx-device-name='lx-dante' --tx-channel-name='128' --rx-channel-name='128' --rx-device-name='lx-dante'" 26 | netaudio subscription add --tx-device-name='lx-dante' --tx-channel-name='128' --rx-channel-name='128' --rx-device-name='lx-dante' 27 | 28 | echo '' 29 | echo "netaudio config --set-device-name='DI Box' --device-host='dinet-tx-1'" 30 | netaudio config --set-device-name='DI Box' --device-host='dinet-tx-1' 31 | 32 | echo '' 33 | echo "netaudio device list | grep -i 'DI Box'" 34 | netaudio device list | grep -i 'DI Box' 35 | 36 | echo '' 37 | echo "netaudio config --set-device-name='dinet-tx-1' --device-host='192.168.1.41'" 38 | netaudio config --set-device-name='dinet-tx-1' --device-host='192.168.1.41' 39 | 40 | echo '' 41 | echo "netaudio device list | grep -i 'dinet-tx'" 42 | netaudio device list | grep -i 'dinet-tx' 43 | 44 | netaudio device list --json | underscore map 'value' | underscore pluck ipv4 --outfmt text | sort 45 | -------------------------------------------------------------------------------- /netaudio/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from .dante.browser import DanteBrowser 4 | from .dante.channel import DanteChannel 5 | from .dante.control import DanteControl 6 | from .dante.device import DanteDevice 7 | from .dante.multicast import DanteMulticast 8 | from .dante.subscription import DanteSubscription 9 | 10 | from .console.application import main 11 | 12 | __author__ = "Chris Ritsen" 13 | __maintainer__ = "Chris Ritsen " 14 | 15 | if sys.version_info <= (3, 9): 16 | raise ImportError("Python version > 3.9 required.") 17 | -------------------------------------------------------------------------------- /netaudio/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import signal 4 | import sys 5 | 6 | 7 | def handler(signum, frame): 8 | sys.exit(main()) 9 | 10 | 11 | if __name__ == "__main__": 12 | from netaudio.console.application import main 13 | 14 | signal.signal(signal.SIGINT, handler) 15 | 16 | sys.exit(main()) 17 | -------------------------------------------------------------------------------- /netaudio/console/__init__.py: -------------------------------------------------------------------------------- 1 | from .commands.channel import ChannelCommand 2 | from .commands.config import ConfigCommand 3 | from .commands.device import DeviceCommand 4 | from .commands.server import ServerCommand 5 | from .commands.server._http import ServerHttpCommand 6 | from .commands.server._mdns import ServerMdnsCommand 7 | from .commands.subscription import SubscriptionCommand 8 | from .commands.subscription._add import SubscriptionAddCommand 9 | from .commands.subscription._remove import SubscriptionRemoveCommand 10 | -------------------------------------------------------------------------------- /netaudio/console/application.py: -------------------------------------------------------------------------------- 1 | from cleo.application import Application 2 | from netaudio import version 3 | 4 | from netaudio.console.commands import ( 5 | ChannelCommand, 6 | ConfigCommand, 7 | DeviceCommand, 8 | ServerCommand, 9 | SubscriptionCommand, 10 | ) 11 | 12 | # Fix Windows issue, See: https://stackoverflow.com/q/58718659/ 13 | try: 14 | from signal import signal, SIGPIPE, SIG_DFL 15 | signal(SIGPIPE, SIG_DFL) 16 | except ImportError: # If SIGPIPE is not available (win32), 17 | pass # we don't have to do anything to ignore it. 18 | 19 | 20 | def main() -> int: 21 | commands = [ 22 | ChannelCommand, 23 | ConfigCommand, 24 | DeviceCommand, 25 | ServerCommand, 26 | SubscriptionCommand, 27 | ] 28 | 29 | application = Application("netaudio", version.version) 30 | 31 | for command in commands: 32 | application.add(command()) 33 | 34 | return application.run() 35 | 36 | 37 | if __name__ == "__main__": 38 | main() 39 | -------------------------------------------------------------------------------- /netaudio/console/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from .channel import ChannelCommand 2 | from .config import ConfigCommand 3 | from .device import DeviceCommand 4 | from .server import ServerCommand 5 | from .subscription import SubscriptionCommand 6 | from .subscription._add import SubscriptionAddCommand 7 | from .subscription._remove import SubscriptionRemoveCommand 8 | -------------------------------------------------------------------------------- /netaudio/console/commands/channel/__init__.py: -------------------------------------------------------------------------------- 1 | from cleo.commands.command import Command 2 | from cleo.helpers import option 3 | 4 | from ._list import ChannelListCommand 5 | 6 | 7 | class ChannelCommand(Command): 8 | name = "channel" 9 | description = "Control channels" 10 | commands = [ChannelListCommand()] 11 | 12 | def handle(self): 13 | return self.call("help", self._config.name) 14 | -------------------------------------------------------------------------------- /netaudio/console/commands/channel/_list.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import ipaddress 3 | import json 4 | import socket 5 | 6 | from json import JSONEncoder 7 | 8 | from cleo.commands.command import Command 9 | from cleo.helpers import option 10 | 11 | from netaudio.dante.browser import DanteBrowser 12 | from netaudio.utils.timeout import Timeout 13 | 14 | 15 | def _default(self, obj): 16 | return getattr(obj.__class__, "to_json", _default.default)(obj) 17 | 18 | 19 | _default.default = JSONEncoder().default 20 | JSONEncoder.default = _default 21 | 22 | 23 | def get_host_by_name(host): 24 | ipv4 = None 25 | 26 | try: 27 | ipv4 = ipaddress.ip_address(Timeout(socket.gethostbyname, 0.1)(host)) 28 | except socket.gaierror: 29 | pass 30 | except TimeoutError: 31 | pass 32 | 33 | return ipv4 34 | 35 | 36 | class ChannelListCommand(Command): 37 | name = "list" 38 | description = "List channels" 39 | 40 | options = [ 41 | option("json", None, "Output as JSON", flag=True), 42 | option("device-host", None, "Specify device by host", flag=False), 43 | option("device-name", None, "Specify device by name", flag=False), 44 | ] 45 | 46 | def print_channel_list(self, devices): 47 | if self.option("json"): 48 | channels = {} 49 | 50 | for _, device in devices.items(): 51 | channels[device.name] = { 52 | "receivers": device.rx_channels, 53 | "transmitters": device.tx_channels, 54 | } 55 | 56 | json_object = json.dumps(channels, indent=2) 57 | self.line(f"{json_object}") 58 | else: 59 | for index, (_, device) in enumerate(devices.items()): 60 | self.line(f"{device.name}") 61 | if device.tx_channels: 62 | self.line("tx channels") 63 | 64 | for _, channel in device.tx_channels.items(): 65 | self.line(f"{channel}") 66 | 67 | if device.rx_channels: 68 | if device.tx_channels: 69 | self.line("") 70 | 71 | self.line("rx channels") 72 | 73 | for _, channel in device.rx_channels.items(): 74 | self.line(f"{channel}") 75 | 76 | if index < len(devices) - 1: 77 | self.line("") 78 | 79 | def filter_devices(self, devices): 80 | if self.option("device-name"): 81 | devices = dict( 82 | filter( 83 | lambda d: d[1].name == self.option("device-name"), devices.items() 84 | ) 85 | ) 86 | elif self.option("device-host"): 87 | host = self.option("device-host") 88 | ipv4 = None 89 | 90 | try: 91 | ipv4 = ipaddress.ip_address(host) 92 | except ValueError: 93 | pass 94 | 95 | possible_names = set([host, host + ".local.", host + "."]) 96 | 97 | if possible_names.intersection(set(devices.keys())): 98 | devices = dict( 99 | filter( 100 | lambda d: d[1].server_name in possible_names, devices.items() 101 | ) 102 | ) 103 | else: 104 | try: 105 | ipv4 = get_host_by_name(self.option("device-host")) 106 | except TimeoutError: 107 | pass 108 | 109 | devices = dict(filter(lambda d: d[1].ipv4 == ipv4, devices.items())) 110 | 111 | return devices 112 | 113 | async def channel_list(self): 114 | dante_browser = DanteBrowser(mdns_timeout=1.5) 115 | devices = await dante_browser.get_devices() 116 | 117 | if self.option("device-name"): 118 | for _, device in devices.items(): 119 | await device.get_controls() 120 | 121 | devices = self.filter_devices(devices) 122 | 123 | if not self.option("device-name"): 124 | for _, device in devices.items(): 125 | await device.get_controls() 126 | 127 | devices = dict(sorted(devices.items(), key=lambda x: x[1].name)) 128 | self.print_channel_list(devices) 129 | 130 | def handle(self): 131 | asyncio.run(self.channel_list()) 132 | -------------------------------------------------------------------------------- /netaudio/console/commands/config/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import ipaddress 3 | import json 4 | 5 | from json import JSONEncoder 6 | 7 | from cleo.commands.command import Command 8 | from cleo.helpers import option 9 | 10 | from netaudio.dante.browser import DanteBrowser 11 | from netaudio.utils import get_host_by_name 12 | 13 | 14 | def _default(self, obj): 15 | return getattr(obj.__class__, "to_json", _default.default)(obj) 16 | 17 | 18 | _default.default = JSONEncoder().default 19 | JSONEncoder.default = _default 20 | 21 | 22 | class ConfigCommand(Command): 23 | name = "config" 24 | description = "Configure devices" 25 | 26 | options_channel_type = ["rx", "tx"] 27 | options_encoding = [16, 24, 32] 28 | options_rate = [44100, 48000, 88200, 96000, 176400, 192000] 29 | options_gain_level = list(range(1, 6)) 30 | 31 | options = [ 32 | option( 33 | "channel-number", 34 | None, 35 | "Specify a channel for control by number", 36 | flag=False, 37 | ), 38 | option( 39 | "channel-type", 40 | None, 41 | "Specify a channel for control by number {options_channel_type}", 42 | flag=False, 43 | ), 44 | option( 45 | "device-host", 46 | None, 47 | "Specify a device to configure by network address", 48 | flag=False, 49 | ), 50 | option( 51 | "device-name", None, "Specify a device to configure by name", flag=False 52 | ), 53 | option("reset-channel-name", None, "Reset a channel name", flag=True), 54 | option("reset-device-name", None, "Set the device name", flag=True), 55 | option("identify", None, "Identify the device by flashing an LED", flag=True), 56 | option("set-channel-name", None, "Set a channel name", flag=False), 57 | option("set-device-name", None, "Set the device name", flag=False), 58 | option( 59 | "set-encoding", None, f"Set the encoding. {options_encoding}", flag=False 60 | ), 61 | option( 62 | "set-gain-level", 63 | None, 64 | f"Set the gain level on a an AVIO device. Lower numbers are higher gain. {options_gain_level}", 65 | flag=False, 66 | ), 67 | option( 68 | "set-latency", None, "Set the device latency in milliseconds", flag=False 69 | ), 70 | option( 71 | "set-sample-rate", 72 | None, 73 | f"Set the sample rate of a device {options_rate}", 74 | flag=False, 75 | ), 76 | option( 77 | "aes67-enable", 78 | None, 79 | f"Enable AES67 mode. Reboot needed to apply. Note: You also need to add multicast channels for AES67 to work", 80 | flag=True, 81 | ), 82 | option( 83 | "aes67-disable", 84 | None, 85 | f"Disable AES67 mode. Reboot needed to apply", 86 | flag=True, 87 | ), 88 | ] 89 | 90 | async def set_gain_level(self, device, channel_number, gain_level): 91 | device_type = None 92 | label = None 93 | 94 | if device.model_id in ["DAI1", "DAI2"]: 95 | device_type = "input" 96 | 97 | label = { 98 | 1: "+24 dBu", 99 | 2: "+4dBu", 100 | 3: "+0 dBu", 101 | 4: "0 dBV", 102 | 5: "-10 dBV", 103 | } 104 | elif device.model_id in ["DAO1", "DAO2"]: 105 | device_type = "output" 106 | 107 | label = { 108 | 1: "+18 dBu", 109 | 2: "+4 dBu", 110 | 3: "+0 dBu", 111 | 4: "0 dBV", 112 | 5: "-10 dBV", 113 | } 114 | 115 | try: 116 | gain_level = int(gain_level) 117 | except ValueError: 118 | self.line("Invalid value for gain level") 119 | return 120 | 121 | try: 122 | channel_number = int(channel_number) 123 | except ValueError: 124 | self.line("Invalid channel number") 125 | return 126 | 127 | if channel_number: 128 | if ( 129 | device_type == "output" and channel_number not in device.rx_channels 130 | ) or (device_type == "input" and channel_number not in device.tx_channels): 131 | self.line("Invalid channel number") 132 | return 133 | 134 | if gain_level not in self.options_gain_level: 135 | self.line("Invalid value for gain level") 136 | return 137 | 138 | if device_type: 139 | self.line( 140 | f"Setting gain level of {device.name} {device.ipv4} to {label[gain_level]} on channel {channel_number}" 141 | ) 142 | await device.set_gain_level(channel_number, gain_level, device_type) 143 | else: 144 | self.line("This device does not support gain control") 145 | 146 | def filter_devices(self, devices): 147 | if self.option("device-name"): 148 | devices = dict( 149 | filter( 150 | lambda d: d[1].name == self.option("device-name"), devices.items() 151 | ) 152 | ) 153 | elif self.option("device-host"): 154 | host = self.option("device-host") 155 | ipv4 = None 156 | 157 | try: 158 | ipv4 = ipaddress.ip_address(host) 159 | except ValueError: 160 | pass 161 | 162 | possible_names = set([host, host + ".local.", host + "."]) 163 | 164 | if possible_names.intersection(set(devices.keys())): 165 | devices = dict( 166 | filter( 167 | lambda d: d[1].server_name in possible_names, devices.items() 168 | ) 169 | ) 170 | else: 171 | try: 172 | ipv4 = get_host_by_name(host) 173 | except TimeoutError: 174 | pass 175 | 176 | devices = dict(filter(lambda d: d[1].ipv4 == ipv4, devices.items())) 177 | 178 | return devices 179 | 180 | async def device_configure(self): 181 | option_names = list(map(lambda o: o.long_name, self.options)) 182 | options_given = any(list([self.option(o) for o in option_names])) 183 | 184 | if not options_given: 185 | return self.call("help", self._config.name) 186 | 187 | dante_browser = DanteBrowser(mdns_timeout=1.5) 188 | devices = await dante_browser.get_devices() 189 | 190 | for _, device in devices.items(): 191 | await device.get_controls() 192 | 193 | devices = self.filter_devices(devices) 194 | devices = dict(sorted(devices.items(), key=lambda x: x[1].name)) 195 | 196 | try: 197 | device = list(devices.values()).pop() 198 | except IndexError: 199 | self.line("Device not found") 200 | return 201 | 202 | if self.option("reset-channel-name") or self.option("set-channel-name"): 203 | if self.option("channel-number"): 204 | channel_number = int(self.option("channel-number")) 205 | else: 206 | self.line("Must specify a channel number") 207 | 208 | if ( 209 | self.option("channel-type") 210 | and self.option("channel-type") in self.options_channel_type 211 | ): 212 | channel_type = self.option("channel-type") 213 | elif self.option("channel-type"): 214 | self.line("Invalid channel type") 215 | else: 216 | self.line("Must specify a channel type") 217 | 218 | if channel_number and channel_type: 219 | if self.option("reset-channel-name"): 220 | self.line( 221 | f"Resetting name of {channel_type} channel {channel_number} for {device.name} {device.ipv4}" 222 | ) 223 | await device.reset_channel_name(channel_type, channel_number) 224 | elif self.option("set-channel-name"): 225 | new_channel_name = self.option("set-channel-name") 226 | 227 | if len(new_channel_name) > 31: 228 | self.line("New channel name will be truncated") 229 | new_channel_name = new_channel_name[:31] 230 | 231 | self.line( 232 | f"Setting name of {channel_type} channel {channel_number} for {device.name} {device.ipv4} to {new_channel_name}" 233 | ) 234 | await device.set_channel_name( 235 | channel_type, channel_number, new_channel_name 236 | ) 237 | 238 | if self.option("reset-device-name"): 239 | self.line(f"Resetting device name for {device.name} {device.ipv4}") 240 | await device.reset_name() 241 | 242 | if self.option("identify"): 243 | self.line(f"Identifying device {device.name} {device.ipv4}") 244 | await device.identify() 245 | 246 | if self.option("set-device-name"): 247 | new_device_name = self.option("set-device-name") 248 | 249 | if len(new_device_name) > 31: 250 | self.line("New device name will be truncated") 251 | new_device_name = new_device_name[:31] 252 | 253 | self.line( 254 | f"Setting device name for {device.name} {device.ipv4} to {new_device_name}" 255 | ) 256 | await device.set_name(self.option("set-device-name")) 257 | 258 | if self.option("set-latency"): 259 | latency = int(self.option("set-latency")) 260 | self.line(f"Setting latency of {device} to {latency:g} ms") 261 | await device.set_latency(latency) 262 | 263 | if self.option("set-sample-rate"): 264 | sample_rate = int(self.option("set-sample-rate")) 265 | if sample_rate in self.options_rate: 266 | self.line( 267 | f"Setting sample rate of {device.name} {device.ipv4} to {sample_rate}" 268 | ) 269 | await device.set_sample_rate(sample_rate) 270 | else: 271 | self.line("Invalid sample rate") 272 | 273 | if self.option("set-encoding"): 274 | encoding = int(self.option("set-encoding")) 275 | 276 | if encoding in self.options_encoding: 277 | self.line( 278 | f"Setting encoding of {device.name} {device.ipv4} to {encoding}" 279 | ) 280 | await device.set_encoding(encoding) 281 | else: 282 | self.line("Invalid encoding") 283 | 284 | if self.option("set-gain-level"): 285 | await self.set_gain_level( 286 | device, self.option("channel-number"), self.option("set-gain-level") 287 | ) 288 | 289 | if self.option("aes67-enable"): 290 | # OPT: use --enable-aes67=[True|False] instead, didn't know how 291 | is_enabled = True 292 | await device.enable_aes67(is_enabled) 293 | 294 | if self.option("aes67-disable"): 295 | is_enabled = False 296 | await device.enable_aes67(is_enabled) 297 | 298 | def handle(self): 299 | asyncio.run(self.device_configure()) 300 | -------------------------------------------------------------------------------- /netaudio/console/commands/device/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from cleo.commands.command import Command 4 | from cleo.helpers import option 5 | 6 | from netaudio import DanteBrowser 7 | 8 | from ._list import DeviceListCommand 9 | 10 | 11 | class DeviceCommand(Command): 12 | name = "device" 13 | description = "Control devices" 14 | commands = [DeviceListCommand()] 15 | 16 | def handle(self): 17 | return self.call("help", self._config.name) 18 | -------------------------------------------------------------------------------- /netaudio/console/commands/device/_list.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import ipaddress 3 | import json 4 | import pprint 5 | import socket 6 | 7 | from json import JSONEncoder 8 | 9 | from cleo.commands.command import Command 10 | from cleo.helpers import option 11 | 12 | from redis import Redis 13 | from redis.exceptions import ConnectionError as RedisConnectionError 14 | 15 | from netaudio.dante.browser import DanteBrowser 16 | from netaudio.dante.channel import DanteChannel 17 | from netaudio.dante.const import SERVICE_CMC, SERVICES 18 | from netaudio.dante.device import DanteDevice 19 | from netaudio.dante.subscription import DanteSubscription 20 | from netaudio.utils.timeout import Timeout 21 | 22 | 23 | def _default(self, obj): 24 | return getattr(obj.__class__, "to_json", _default.default)(obj) 25 | 26 | 27 | _default.default = JSONEncoder().default 28 | JSONEncoder.default = _default 29 | 30 | 31 | def get_host_by_name(host): 32 | ipv4 = None 33 | 34 | try: 35 | ipv4 = ipaddress.ip_address(Timeout(socket.gethostbyname, 0.1)(host)) 36 | except socket.gaierror: 37 | pass 38 | except TimeoutError: 39 | pass 40 | 41 | return ipv4 42 | 43 | 44 | class DeviceListCommand(Command): 45 | name = "list" 46 | description = "List devices" 47 | 48 | options = [ 49 | option("json", None, "Output as JSON", flag=True), 50 | option("host", None, "Specify device by host", flag=False), 51 | option("name", None, "Specify device by name", flag=False), 52 | ] 53 | 54 | def filter_devices(self, devices): 55 | if self.option("name"): 56 | devices = dict( 57 | filter(lambda d: d[1].name == self.option("name"), devices.items()) 58 | ) 59 | elif self.option("host"): 60 | host = self.option("host") 61 | ipv4 = None 62 | 63 | try: 64 | ipv4 = ipaddress.ip_address(host) 65 | except ValueError: 66 | pass 67 | 68 | possible_names = set([host, host + ".local.", host + "."]) 69 | 70 | if possible_names.intersection(set(devices.keys())): 71 | devices = dict( 72 | filter( 73 | lambda d: d[1].server_name in possible_names, devices.items() 74 | ) 75 | ) 76 | else: 77 | try: 78 | ipv4 = get_host_by_name(host) 79 | except TimeoutError: 80 | pass 81 | 82 | devices = dict(filter(lambda d: d[1].ipv4 == ipv4, devices.items())) 83 | 84 | return devices 85 | 86 | def get_devices_from_redis(self): 87 | redis_client = None 88 | redis_host = "localhost" 89 | redis_port = 6379 90 | redis_db = 0 91 | 92 | try: 93 | redis_client = Redis( 94 | db=redis_db, 95 | decode_responses=True, 96 | host=redis_host, 97 | port=redis_port, 98 | socket_timeout=0.1, 99 | ) 100 | 101 | redis_client.ping() 102 | except RedisConnectionError: 103 | return None 104 | 105 | if not redis_client: 106 | return None 107 | 108 | host_keys = redis_client.smembers("netaudio:dante:hosts") 109 | devices = {} 110 | 111 | for host_key in host_keys: 112 | host_data = redis_client.hgetall(f"netaudio:dante:host:{host_key}") 113 | 114 | if not host_data or "server_name" not in host_data: 115 | continue 116 | 117 | server_name = host_data["server_name"] 118 | 119 | device = DanteDevice(server_name=server_name) 120 | device.ipv4 = host_data.get("ipv4") 121 | 122 | device_data = redis_client.hgetall(f"netaudio:dante:device:{server_name}") 123 | 124 | if device_data: 125 | rx_channels = json.loads(device_data.get("rx_channels", "{}")) 126 | 127 | for channel_number, rx_channel_data in rx_channels.items(): 128 | rx_channel = DanteChannel() 129 | rx_channel.channel_type = "rx" 130 | rx_channel.device = self 131 | rx_channel.name = rx_channel_data.get("name") 132 | rx_channel.number = channel_number 133 | rx_channel.status_code = rx_channel_data.get("status_code") 134 | device.rx_channels[channel_number] = rx_channel 135 | 136 | tx_channels = json.loads(device_data.get("tx_channels", "{}")) 137 | 138 | for channel_number, tx_channel_data in tx_channels.items(): 139 | tx_channel = DanteChannel() 140 | tx_channel.channel_type = "tx" 141 | tx_channel.device = self 142 | tx_channel.name = tx_channel_data.get("name") 143 | tx_channel.number = channel_number 144 | tx_channel.status_code = tx_channel_data.get("status_code") 145 | device.tx_channels[channel_number] = tx_channel 146 | 147 | device.rx_count = int(device_data.get("rx_channel_count"), 0) 148 | device.tx_count = int(device_data.get("tx_channel_count"), 0) 149 | 150 | subscriptions = json.loads(device_data.get("subscriptions", "{}")) 151 | 152 | for ( 153 | subscription_number, 154 | subscription_data, 155 | ) in subscriptions.items(): 156 | subscription = DanteSubscription() 157 | subscription.rx_channel_name = subscription_data.get( 158 | "rx_channel_name" 159 | ) 160 | 161 | subscription.rx_device_name = subscription_data.get( 162 | "rx_device_name" 163 | ) 164 | 165 | subscription.tx_channel_name = subscription_data.get( 166 | "tx_channel_name" 167 | ) 168 | 169 | subscription.tx_device_name = subscription_data.get( 170 | "tx_device_name" 171 | ) 172 | 173 | subscription.status_code = subscription_data.get("status_code") 174 | 175 | subscription.rx_channel_status_code = subscription_data.get( 176 | "rx_channel_status_code" 177 | ) 178 | 179 | subscription.status_message = subscription_data.get( 180 | "status_message", [] 181 | ) 182 | 183 | device.subscriptions.append(subscription) 184 | 185 | device.name = device_data.get("device_name") 186 | device.sample_rate = device_data.get("sample_rate_status") 187 | device.model_id = device_data.get("model") 188 | device.software = device_data.get("software") 189 | device.latency = device_data.get("latency") 190 | 191 | service_keys = redis_client.keys(f"netaudio:dante:service:{server_name}:*") 192 | 193 | for service_key in service_keys: 194 | service_data = redis_client.hgetall(service_key) 195 | 196 | service_properties_key = service_key.replace( 197 | "service", "service:properties" 198 | ) 199 | 200 | service_properties = redis_client.hgetall(service_properties_key) 201 | 202 | if service_data: 203 | service_name = service_data.get("name") 204 | device.services[service_name] = { 205 | "ipv4": service_data.get("ipv4"), 206 | "name": service_data.get("name"), 207 | "port": int(service_data.get("port", 0)), 208 | "properties": { 209 | k: v 210 | for k, v in service_properties.items() 211 | if k not in ["ipv4", "name", "port"] 212 | }, 213 | "server_name": server_name, 214 | "type": service_data.get("type"), 215 | } 216 | 217 | if ( 218 | "id" in service_properties 219 | and service_data.get("type") == SERVICE_CMC 220 | ): 221 | device.mac_address = service_properties["id"] 222 | 223 | device.services = dict(sorted(device.services.items())) 224 | devices[server_name] = device 225 | 226 | return devices if devices else None 227 | 228 | async def device_list(self): 229 | cached_devices = self.get_devices_from_redis() 230 | 231 | if cached_devices is not None: 232 | devices = cached_devices 233 | else: 234 | dante_browser = DanteBrowser(mdns_timeout=1.5) 235 | devices = await dante_browser.get_devices() 236 | 237 | if self.option("name"): 238 | for _, device in devices.items(): 239 | await device.get_controls() 240 | 241 | devices = self.filter_devices(devices) 242 | 243 | if not self.option("name"): 244 | for _, device in devices.items(): 245 | await device.get_controls() 246 | 247 | devices = dict(sorted(devices.items(), key=lambda x: x[1].name)) 248 | 249 | if self.option("json"): 250 | json_object = json.dumps(devices, indent=2) 251 | self.line(f"{json_object}") 252 | else: 253 | for _, device in devices.items(): 254 | self.line(f"{device}") 255 | 256 | def handle(self): 257 | asyncio.run(self.device_list()) 258 | -------------------------------------------------------------------------------- /netaudio/console/commands/server/__init__.py: -------------------------------------------------------------------------------- 1 | from cleo.commands.command import Command 2 | from cleo.helpers import option 3 | from ._http import ServerHttpCommand 4 | from ._mdns import ServerMdnsCommand 5 | 6 | 7 | class ServerCommand(Command): 8 | name = "server" 9 | description = "Servers" 10 | commands = [ServerHttpCommand(), ServerMdnsCommand()] 11 | 12 | def handle(self): 13 | return self.call("help", self._config.name) 14 | -------------------------------------------------------------------------------- /netaudio/console/commands/server/_http.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import uvicorn 4 | 5 | from cleo.commands.command import Command 6 | 7 | from fastapi import FastAPI, HTTPException, Path, Body 8 | from fastapi.middleware.cors import CORSMiddleware 9 | from netaudio.dante.browser import DanteBrowser 10 | import logging 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | app = FastAPI() 15 | dante_browser = DanteBrowser(mdns_timeout=1.5) 16 | 17 | origins = [ 18 | "http://192.168.1.107:3002", 19 | ] 20 | 21 | app.add_middleware( 22 | CORSMiddleware, 23 | allow_origins=origins, 24 | allow_credentials=True, 25 | allow_methods=["*"], 26 | allow_headers=["*"], 27 | ) 28 | 29 | 30 | async def device_list(): 31 | devices = await dante_browser.get_devices() 32 | 33 | for _, device in devices.items(): 34 | await device.get_controls() 35 | 36 | devices = dict(sorted(devices.items(), key=lambda x: x[1].name)) 37 | return devices 38 | 39 | 40 | @app.get("/devices") 41 | async def list_devices(): 42 | try: 43 | devices = await device_list() 44 | return json.loads(json.dumps(devices, indent=2)) 45 | except Exception as e: 46 | raise HTTPException(status_code=500, detail=str(e)) 47 | 48 | 49 | @app.post("/subscribe/{rx_device_name}/{rx_channel_name}/{tx_device_name}/{tx_channel_name}") 50 | async def subscribe_device(rx_device_name: str,rx_channel_name: str,tx_device_name: str,tx_channel_name: str, payload: dict = Body(...)): 51 | 52 | logger.info(f"rx_d: {rx_device_name} {rx_channel_name} {tx_device_name} {tx_channel_name}",rx_device_name,rx_channel_name,tx_device_name,tx_channel_name) 53 | dante_devices = await dante_browser.get_devices() 54 | 55 | for _, device in dante_devices.items(): 56 | await device.get_controls() 57 | 58 | rx_channel = None 59 | rx_device = None 60 | tx_channel = None 61 | tx_device = None 62 | 63 | tx_device = next( 64 | filter( 65 | lambda d: d[1].name == tx_device_name, 66 | dante_devices.items(), 67 | ) 68 | )[1] 69 | tx_channel = next( 70 | filter( 71 | lambda c: tx_channel_name == c[1].friendly_name 72 | or tx_channel_name == c[1].name 73 | and not c[1].friendly_name, 74 | tx_device.tx_channels.items(), 75 | ) 76 | )[1] 77 | rx_device = next( 78 | filter( 79 | lambda d: d[1].name == rx_device_name, 80 | dante_devices.items(), 81 | ) 82 | )[1] 83 | rx_channel = next( 84 | filter( 85 | lambda c: c[1].name == rx_channel_name, 86 | rx_device.rx_channels.items(), 87 | ) 88 | )[1] 89 | 90 | if rx_channel and rx_device and tx_channel and tx_channel: 91 | await rx_device.add_subscription(rx_channel, tx_channel, tx_device) 92 | else: 93 | raise HTTPException(status_code=404, detail="Device or Channel not found") 94 | return {} 95 | 96 | @app.post("/devices/{device_name}/rx_name/{rx_number}") 97 | async def name_rx_device(device_name: str,rx_number: int, payload: dict = Body(...)): 98 | name = payload["name"] 99 | devices = await device_list() 100 | device = next((d for d in devices.values() if d.name == device_name), None) 101 | if not device: 102 | raise HTTPException(status_code=404, detail="Device not found") 103 | try: 104 | await device.set_channel_name("rx",rx_number,name) 105 | except Exception as e: 106 | raise HTTPException(status_code=500, detail=str(e)) 107 | return {} 108 | 109 | @app.post("/devices/{device_name}/configure") 110 | async def configure_device(device_name: str, payload: dict = Body(...)): 111 | devices = await device_list() 112 | device = next((d for d in devices.values() if d.name == device_name), None) 113 | 114 | if not device: 115 | raise HTTPException(status_code=404, detail="Device not found") 116 | 117 | if "reset_device_name" in payload: 118 | await device.reset_name() 119 | 120 | if "device_name" in payload: 121 | await device.set_name(payload["device_name"]) 122 | 123 | if "identify" in payload and payload["identify"]: 124 | await device.identify() 125 | 126 | if "sample_rate" in payload: 127 | await device.set_sample_rate(payload["sample_rate"]) 128 | 129 | if "encoding" in payload: 130 | await device.set_encoding(payload["encoding"]) 131 | 132 | if all(k in payload for k in ["gain_level", "channel_number", "channel_type"]): 133 | await device.set_gain_level( 134 | payload["channel_number"], payload["gain_level"], payload["channel_type"] 135 | ) 136 | 137 | if "aes67" in payload: 138 | await device.enable_aes67(payload["aes67"]) 139 | 140 | return json.loads(json.dumps(device, indent=2)) 141 | 142 | 143 | class ServerHttpCommand(Command): 144 | name = "http" 145 | description = "Run an HTTP server" 146 | 147 | def handle(self): 148 | uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info") 149 | -------------------------------------------------------------------------------- /netaudio/console/commands/server/_mdns.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import ipaddress 3 | import json 4 | import os 5 | import random 6 | import socket 7 | import struct 8 | import sys 9 | import threading 10 | import time 11 | import traceback 12 | 13 | import logging 14 | from concurrent.futures import ThreadPoolExecutor 15 | import signal 16 | 17 | from json import JSONEncoder 18 | from queue import Queue 19 | from threading import Thread, Event 20 | 21 | from redis import Redis 22 | 23 | from cleo.commands.command import Command 24 | from redis.exceptions import ConnectionError as RedisConnectionError 25 | from sqlitedict import SqliteDict 26 | 27 | from netaudio.dante.browser import DanteBrowser 28 | 29 | # from netaudio.utils import get_host_by_name 30 | 31 | from netaudio.dante.const import ( 32 | DEFAULT_MULTICAST_METERING_PORT, 33 | DEVICE_CONTROL_PORT, 34 | DEVICE_HEARTBEAT_PORT, 35 | DEVICE_INFO_PORT, 36 | DEVICE_INFO_SRC_PORT2, 37 | DEVICE_SETTINGS_PORT, 38 | MESSAGE_TYPES, 39 | MESSAGE_TYPE_ACCESS_STATUS, 40 | MESSAGE_TYPE_AES67_STATUS, 41 | MESSAGE_TYPE_AUDIO_INTERFACE_STATUS, 42 | MESSAGE_TYPE_CHANGE, 43 | MESSAGE_TYPE_CHANNEL_COUNTS_QUERY, 44 | MESSAGE_TYPE_CLEAR_CONFIG_STATUS, 45 | MESSAGE_TYPE_CLOCKING_STATUS, 46 | MESSAGE_TYPE_CODEC_STATUS, 47 | MESSAGE_TYPE_CONTROL, 48 | MESSAGE_TYPE_ENCODING_STATUS, 49 | MESSAGE_TYPE_IFSTATS_STATUS, 50 | MESSAGE_TYPE_INTERFACE_STATUS, 51 | MESSAGE_TYPE_LOCK_STATUS, 52 | MESSAGE_TYPE_MANF_VERSIONS_STATUS, 53 | MESSAGE_TYPE_MONITORING_STRINGS, 54 | MESSAGE_TYPE_NAME_QUERY, 55 | MESSAGE_TYPE_PROPERTY_CHANGE, 56 | MESSAGE_TYPE_QUERY, 57 | MESSAGE_TYPE_ROUTING_DEVICE_CHANGE, 58 | MESSAGE_TYPE_ROUTING_READY, 59 | MESSAGE_TYPE_RX_CHANNEL_CHANGE, 60 | MESSAGE_TYPE_RX_CHANNEL_QUERY, 61 | MESSAGE_TYPE_RX_FLOW_CHANGE, 62 | MESSAGE_TYPE_SAMPLE_RATE_PULLUP_STATUS, 63 | MESSAGE_TYPE_SAMPLE_RATE_STATUS, 64 | MESSAGE_TYPE_STATUS, 65 | MESSAGE_TYPE_STRINGS, 66 | MESSAGE_TYPE_SWITCH_VLAN_STATUS, 67 | MESSAGE_TYPE_TX_CHANNEL_FRIENDLY_NAMES_QUERY, 68 | MESSAGE_TYPE_TX_CHANNEL_QUERY, 69 | MESSAGE_TYPE_TX_FLOW_CHANGE, 70 | MESSAGE_TYPE_UNICAST_CLOCKING_STATUS, 71 | MESSAGE_TYPE_UPGRADE_STATUS, 72 | MESSAGE_TYPE_VERSIONS_STATUS, 73 | MESSAGE_TYPE_VOLUME_LEVELS, 74 | MULTICAST_GROUP_CONTROL_MONITORING, 75 | MULTICAST_GROUP_HEARTBEAT, 76 | PORTS, 77 | SERVICES, 78 | SERVICE_ARC, 79 | SERVICE_CHAN, 80 | SERVICE_CMC, 81 | SERVICE_DBC, 82 | SUBSCRIPTION_STATUS_LABELS, 83 | SUBSCRIPTION_STATUS_NONE, 84 | ) 85 | 86 | 87 | def _default(self, obj): 88 | return getattr(obj.__class__, "to_json", _default.default)(obj) 89 | 90 | 91 | logging.basicConfig(level=logging.INFO) 92 | logger = logging.getLogger(__name__) 93 | 94 | _default.default = JSONEncoder().default 95 | JSONEncoder.default = _default 96 | 97 | sockets = {} 98 | redis_client = None 99 | 100 | redis_socket_path = os.environ.get("REDIS_SOCKET") 101 | redis_host = os.environ.get("REDIS_HOST") or "localhost" 102 | redis_port = os.environ.get("REDIS_PORT") or 6379 103 | redis_db = os.environ.get("REDIS_DB") or 0 104 | 105 | try: 106 | if redis_socket_path: 107 | redis_client = Redis( 108 | db=redis_db, 109 | decode_responses=False, 110 | socket_timeout=0.1, 111 | unix_socket_path=redis_socket_path, 112 | ) 113 | elif os.environ.get("REDIS_PORT") or os.environ.get("REDIS_HOST"): 114 | redis_client = Redis( 115 | db=redis_db, 116 | decode_responses=False, 117 | host=redis_host, 118 | socket_timeout=0.1, 119 | port=redis_port, 120 | ) 121 | if redis_client: 122 | redis_client.ping() 123 | except RedisConnectionError: 124 | redis_client = None 125 | 126 | 127 | def get_name_lengths(device_name): 128 | name_len = len(device_name) 129 | offset = (name_len & 1) - 2 130 | padding = 10 - (name_len + offset) 131 | name_len1 = (len(device_name) * 2) + padding 132 | name_len2 = name_len1 + 2 133 | name_len3 = name_len2 + 4 134 | 135 | return (name_len1, name_len2, name_len3) 136 | 137 | 138 | def volume_level_query(device_name, ipv4, mac, port, timeout=True): 139 | data_len = 0 140 | device_name_hex = device_name.encode().hex() 141 | ip_hex = ipv4.packed.hex() 142 | 143 | name_len1, name_len2, name_len3 = get_name_lengths(device_name) 144 | 145 | if len(device_name) % 2 == 0: 146 | device_name_hex = f"{device_name_hex}00" 147 | 148 | if len(device_name) < 2: 149 | data_len = 54 150 | elif len(device_name) < 4: 151 | data_len = 56 152 | else: 153 | data_len = len(device_name) + (len(device_name) & 1) + 54 154 | 155 | unknown_arg = "16" 156 | message_hex = f"120000{data_len:02x}ffff301000000000{mac}0000000400{name_len1:02x}000100{name_len2:02x}000a{device_name_hex}{unknown_arg}0001000100{name_len3:02x}0001{port:04x}{timeout:04x}0000{ip_hex}{port:04x}0000" 157 | 158 | return bytes.fromhex(message_hex) 159 | 160 | 161 | def parse_volume_level_status(message, server_name): 162 | redis_device_key = ":".join(["netaudio", "dante", "device", server_name]) 163 | cached_device = redis_decode(redis_client.hgetall(redis_device_key)) 164 | volume_levels = {"rx": {}, "tx": {}} 165 | rx_channel_count_raw = tx_channel_count_raw = None 166 | 167 | if "rx_channel_count" in cached_device: 168 | rx_channel_count_raw = int(cached_device["rx_channel_count"]) 169 | 170 | if "tx_channel_count" in cached_device: 171 | tx_channel_count_raw = int(cached_device["tx_channel_count"]) 172 | 173 | if not rx_channel_count_raw and not tx_channel_count_raw: 174 | print(f"Need channel counts to parse this request for {server_name}") 175 | return volume_levels 176 | 177 | dante_message = bytes.fromhex(message["message_hex"]) 178 | rx_channels = dante_message[-1 - rx_channel_count_raw : -1] 179 | tx_channels = dante_message[ 180 | -1 - rx_channel_count_raw - tx_channel_count_raw : -1 - rx_channel_count_raw 181 | ] 182 | 183 | for index in range(0, rx_channel_count_raw - 1): 184 | volume_levels["rx"][index + 1] = rx_channels[index] 185 | 186 | for index in range(0, tx_channel_count_raw - 1): 187 | volume_levels["tx"][index + 1] = tx_channels[index] 188 | 189 | return volume_levels 190 | 191 | 192 | def parse_message_type_access_status(message): 193 | return {"access_status": None} 194 | 195 | 196 | def parse_message_type_codec_status(message): 197 | return {"codec_status": None} 198 | 199 | 200 | def parse_message_type_upgrade_status(message): 201 | return {"upgrade_status": None} 202 | 203 | 204 | def parse_message_type_switch_vlan_status(message): 205 | return {"switch_vlan_status": None} 206 | 207 | 208 | def parse_message_type_sample_rate_pullup_status(message): 209 | return {"sample_rate_pullup_status": None} 210 | 211 | 212 | def parse_message_type_clear_config_status(message): 213 | return {"clear_config_status": None} 214 | 215 | 216 | def parse_message_type_encoding_status(message): 217 | return {"encoding_status": None} 218 | 219 | 220 | def parse_message_type_sample_rate_status(message): 221 | return {"sample_rate_status": None} 222 | 223 | 224 | def parse_message_type_aes67_status(message): 225 | return {"aes67_status": None} 226 | 227 | 228 | def parse_message_type_lock_status(message): 229 | return {"lock_status": None} 230 | 231 | 232 | def parse_message_type_clocking_status(message): 233 | return {"clocking_status": None} 234 | 235 | 236 | def parse_message_type_interface_status(message): 237 | return {"interface_status": None} 238 | 239 | 240 | def parse_message_type_versions_status(message): 241 | model = message[88:].partition(b"\x00")[0].decode("utf-8") 242 | model_id = message[43:].partition(b"\x00")[0].decode("utf-8").replace("\u0003", "") 243 | 244 | return { 245 | "model": model, 246 | "model_id": model_id, 247 | } 248 | 249 | 250 | def parse_message_type_manf_versions_status(message): 251 | manufacturer = message[76:].partition(b"\x00")[0].decode("utf-8") 252 | model = message[204:].partition(b"\x00")[0].decode("utf-8") 253 | 254 | return { 255 | "manufacturer": manufacturer, 256 | "model": model, 257 | } 258 | 259 | 260 | def parse_message_type_audio_interface_status(message): 261 | return {"audio_interface_status": None} 262 | 263 | 264 | def parse_message_type_ifstats_status(message): 265 | return {"ifstats_status": None} 266 | 267 | 268 | def parse_message_type_routing_ready(message): 269 | return {"routing_ready": None} 270 | 271 | 272 | def parse_message_type_tx_flow_change(message): 273 | return {"tx_flow_change": None} 274 | 275 | 276 | def parse_message_type_unicast_clocking_status(message): 277 | return {"unicast_clocking_status": None} 278 | 279 | 280 | def cache_device_value_json(server_name, key, value): 281 | redis_device_key = ":".join(["netaudio", "dante", "device", server_name]) 282 | redis_client.hset( 283 | redis_device_key, 284 | key=None, 285 | value=None, 286 | mapping={ 287 | key: json.dumps(value, indent=2), 288 | }, 289 | ) 290 | 291 | 292 | def cache_device_value(server_name, key, value): 293 | redis_device_key = ":".join(["netaudio", "dante", "device", server_name]) 294 | redis_client.hset( 295 | redis_device_key, 296 | key=None, 297 | value=None, 298 | mapping={ 299 | key: value, 300 | }, 301 | ) 302 | 303 | 304 | def redis_decode(cached_dict): 305 | return { 306 | key.decode("utf-8"): value.decode("utf-8") for key, value in cached_dict.items() 307 | } 308 | 309 | 310 | def parse_dante_message(message): 311 | dante_message = bytes.fromhex(message["message_hex"]) 312 | parsed_dante_message = {} 313 | 314 | src_host = message["src_host"] 315 | src_port = message["src_port"] 316 | timestamp = message["time"] 317 | server_name = None 318 | 319 | if "multicast_group" in message: 320 | multicast_group = message["multicast_group"] 321 | 322 | if "multicast_port" in message: 323 | multicast_port = message["multicast_port"] 324 | 325 | message_type = int.from_bytes(dante_message[26:28], "big") 326 | 327 | cached_host = redis_decode( 328 | redis_client.hgetall(":".join(["netaudio", "dante", "host", src_host])) 329 | ) 330 | 331 | # Message was not parsed: 192.168.1.37:1064 -> 224.0.0.231:8702 type `224` (Metering Status) from `AD4D-fd4e13.local.` 332 | 333 | parsed_message = { 334 | "message": dante_message, 335 | "message_type": str(message_type), 336 | "parsed_message": parsed_dante_message, 337 | "src_host": src_host, 338 | "src_port": src_port, 339 | "time": timestamp, 340 | } 341 | 342 | parsed_message_redis_hash = { 343 | "message": dante_message, 344 | "message_type": str(message_type), 345 | "src_host": src_host, 346 | "src_port": src_port, 347 | "time": timestamp, 348 | } 349 | 350 | if "server_name" in cached_host: 351 | server_name = cached_host["server_name"] 352 | else: 353 | parsed_message["error"] = "Could not find server name for cached host" 354 | print(parsed_message["error"]) 355 | return parsed_message 356 | 357 | if ( 358 | message_type == MESSAGE_TYPE_AUDIO_INTERFACE_STATUS 359 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 360 | and multicast_port == DEVICE_INFO_PORT 361 | ): 362 | parsed_dante_message = parse_message_type_audio_interface_status(dante_message) 363 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 364 | "message_type_string" 365 | ] = MESSAGE_TYPE_STRINGS[message_type] 366 | elif ( 367 | message_type == MESSAGE_TYPE_ACCESS_STATUS 368 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 369 | and multicast_port == DEVICE_INFO_PORT 370 | ): 371 | access_status = parse_message_type_access_status(dante_message) 372 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 373 | "message_type_string" 374 | ] = MESSAGE_TYPE_STRINGS[message_type] 375 | elif ( 376 | message_type == MESSAGE_TYPE_ROUTING_READY 377 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 378 | and multicast_port == DEVICE_INFO_PORT 379 | ): 380 | parsed_dante_message = parse_message_type_routing_ready(dante_message) 381 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 382 | "message_type_string" 383 | ] = MESSAGE_TYPE_STRINGS[message_type] 384 | elif ( 385 | message_type == MESSAGE_TYPE_TX_FLOW_CHANGE 386 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 387 | and multicast_port == DEVICE_INFO_PORT 388 | ): 389 | parsed_dante_message = parse_message_type_tx_flow_change(dante_message) 390 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 391 | "message_type_string" 392 | ] = MESSAGE_TYPE_STRINGS[message_type] 393 | 394 | elif ( 395 | message_type == MESSAGE_TYPE_UNICAST_CLOCKING_STATUS 396 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 397 | and multicast_port == DEVICE_INFO_PORT 398 | ): 399 | parsed_dante_message = parse_message_type_unicast_clocking_status(dante_message) 400 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 401 | "message_type_string" 402 | ] = MESSAGE_TYPE_STRINGS[message_type] 403 | elif ( 404 | message_type == MESSAGE_TYPE_IFSTATS_STATUS 405 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 406 | and multicast_port == DEVICE_INFO_PORT 407 | ): 408 | parsed_dante_message = parse_message_type_ifstats_status(dante_message) 409 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 410 | "message_type_string" 411 | ] = MESSAGE_TYPE_STRINGS[message_type] 412 | elif ( 413 | message_type == MESSAGE_TYPE_VERSIONS_STATUS 414 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 415 | and multicast_port == DEVICE_INFO_PORT 416 | ): 417 | parsed_dante_message = parse_message_type_versions_status(dante_message) 418 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 419 | "message_type_string" 420 | ] = MESSAGE_TYPE_STRINGS[message_type] 421 | # print( 422 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port} type `{message_type}` ({MESSAGE_TYPE_STRINGS[message_type]}) from `{server_name}`" 423 | # ) 424 | elif ( 425 | message_type == MESSAGE_TYPE_MANF_VERSIONS_STATUS 426 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 427 | and multicast_port == DEVICE_INFO_PORT 428 | ): 429 | parsed_dante_message = parse_message_type_manf_versions_status(dante_message) 430 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 431 | "message_type_string" 432 | ] = MESSAGE_TYPE_STRINGS[message_type] 433 | # print( 434 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port} type `{message_type}` ({MESSAGE_TYPE_STRINGS[message_type]}) from `{server_name}`" 435 | # ) 436 | elif message_type == MESSAGE_TYPE_PROPERTY_CHANGE: 437 | pass 438 | elif ( 439 | multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 440 | and multicast_port == DEFAULT_MULTICAST_METERING_PORT 441 | ): 442 | volume_levels = parse_volume_level_status(message, server_name) 443 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 444 | "message_type_string" 445 | ] = MESSAGE_TYPE_MONITORING_STRINGS[MESSAGE_TYPE_VOLUME_LEVELS] 446 | 447 | # print( 448 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port} ({MESSAGE_TYPE_MONITORING_STRINGS[MESSAGE_TYPE_VOLUME_LEVELS]}) from `{server_name}`" 449 | # ) 450 | cache_device_value_json(server_name, "rx_volume_levels", volume_levels["rx"]) 451 | cache_device_value_json(server_name, "tx_volume_levels", volume_levels["tx"]) 452 | elif ( 453 | message_type == MESSAGE_TYPE_SAMPLE_RATE_PULLUP_STATUS 454 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 455 | and multicast_port == DEVICE_INFO_PORT 456 | ): 457 | sample_rate_pullup_status = parse_message_type_sample_rate_pullup_status( 458 | dante_message 459 | ) 460 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 461 | "message_type_string" 462 | ] = MESSAGE_TYPE_STRINGS[message_type] 463 | # print( 464 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port} type `{message_type}` ({MESSAGE_TYPE_STRINGS[message_type]}) from `{server_name}`" 465 | # ) 466 | # print(sample_rate_pullup_status) 467 | cache_device_value_json( 468 | server_name, 469 | "sample_rate_pullup_status", 470 | sample_rate_pullup_status["sample_rate_pullup_status"], 471 | ) 472 | elif ( 473 | message_type == MESSAGE_TYPE_ENCODING_STATUS 474 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 475 | and multicast_port == DEVICE_INFO_PORT 476 | ): 477 | encoding_status = parse_message_type_encoding_status(dante_message) 478 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 479 | "message_type_string" 480 | ] = MESSAGE_TYPE_STRINGS[message_type] 481 | # print( 482 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port} type `{message_type}` ({MESSAGE_TYPE_STRINGS[message_type]}) from `{server_name}`" 483 | # ) 484 | # print(encoding_status) 485 | cache_device_value_json( 486 | server_name, "encoding_status", encoding_status["encoding_status"] 487 | ) 488 | elif ( 489 | message_type == MESSAGE_TYPE_CLEAR_CONFIG_STATUS 490 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 491 | and multicast_port == DEVICE_INFO_PORT 492 | ): 493 | clear_config_status = parse_message_type_clear_config_status(dante_message) 494 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 495 | "message_type_string" 496 | ] = MESSAGE_TYPE_STRINGS[message_type] 497 | # print( 498 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port} type `{message_type}` ({MESSAGE_TYPE_STRINGS[message_type]}) from `{server_name}`" 499 | # ) 500 | # print(clear_config_status) 501 | cache_device_value_json( 502 | server_name, 503 | "clear_config_status", 504 | clear_config_status["clear_config_status"], 505 | ) 506 | elif ( 507 | message_type == MESSAGE_TYPE_SAMPLE_RATE_STATUS 508 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 509 | and multicast_port == DEVICE_INFO_PORT 510 | ): 511 | sample_rate_status = parse_message_type_sample_rate_status(dante_message) 512 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 513 | "message_type_string" 514 | ] = MESSAGE_TYPE_STRINGS[message_type] 515 | # print( 516 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port} type `{message_type}` ({MESSAGE_TYPE_STRINGS[message_type]}) from `{server_name}`" 517 | # ) 518 | # print(sample_rate_status) 519 | cache_device_value_json( 520 | server_name, "sample_rate_status", sample_rate_status["sample_rate_status"] 521 | ) 522 | elif ( 523 | message_type == MESSAGE_TYPE_SWITCH_VLAN_STATUS 524 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 525 | and multicast_port == DEVICE_INFO_PORT 526 | ): 527 | switch_vlan_status = parse_message_type_switch_vlan_status(dante_message) 528 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 529 | "message_type_string" 530 | ] = MESSAGE_TYPE_STRINGS[message_type] 531 | # print( 532 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port} type `{message_type}` ({MESSAGE_TYPE_STRINGS[message_type]}) from `{server_name}`" 533 | # ) 534 | # print(switch_vlan_status) 535 | cache_device_value_json( 536 | server_name, "switch_vlan_status", switch_vlan_status["switch_vlan_status"] 537 | ) 538 | elif ( 539 | message_type == MESSAGE_TYPE_UPGRADE_STATUS 540 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 541 | and multicast_port == DEVICE_INFO_PORT 542 | ): 543 | upgrade_status = parse_message_type_upgrade_status(dante_message) 544 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 545 | "message_type_string" 546 | ] = MESSAGE_TYPE_STRINGS[message_type] 547 | # print( 548 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port} type `{message_type}` ({MESSAGE_TYPE_STRINGS[message_type]}) from `{server_name}`" 549 | # ) 550 | # print(upgrade_status) 551 | cache_device_value_json( 552 | server_name, "upgrade_status", upgrade_status["upgrade_status"] 553 | ) 554 | elif ( 555 | message_type == MESSAGE_TYPE_INTERFACE_STATUS 556 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 557 | and multicast_port == DEVICE_INFO_PORT 558 | ): 559 | interface_status = parse_message_type_interface_status(dante_message) 560 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 561 | "message_type_string" 562 | ] = MESSAGE_TYPE_STRINGS[message_type] 563 | # print( 564 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port} type `{message_type}` ({MESSAGE_TYPE_STRINGS[message_type]}) from `{server_name}`" 565 | # ) 566 | # print(interface_status) 567 | cache_device_value_json( 568 | server_name, "interface_status", interface_status["interface_status"] 569 | ) 570 | elif ( 571 | message_type == MESSAGE_TYPE_CLOCKING_STATUS 572 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 573 | and multicast_port == DEVICE_INFO_PORT 574 | ): 575 | clocking_status = parse_message_type_clocking_status(dante_message) 576 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 577 | "message_type_string" 578 | ] = MESSAGE_TYPE_STRINGS[message_type] 579 | # print( 580 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port} type `{message_type}` ({MESSAGE_TYPE_STRINGS[message_type]}) from `{server_name}`" 581 | # ) 582 | cache_device_value_json( 583 | server_name, "clocking_status", clocking_status["clocking_status"] 584 | ) 585 | elif ( 586 | multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 587 | and src_port in [DEVICE_SETTINGS_PORT, DEVICE_INFO_SRC_PORT2] 588 | and message_type 589 | in [ 590 | MESSAGE_TYPE_ROUTING_DEVICE_CHANGE, 591 | MESSAGE_TYPE_RX_CHANNEL_CHANGE, 592 | MESSAGE_TYPE_RX_FLOW_CHANGE, 593 | ] 594 | ): 595 | print("Rx change for", server_name, message_type) 596 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 597 | "message_type_string" 598 | ] = MESSAGE_TYPE_STRINGS[message_type] 599 | parsed_rx_channels = get_rx_channels(server_name) 600 | cache_device_value_json( 601 | server_name, "rx_channels", parsed_rx_channels["rx_channels"] 602 | ) 603 | cache_device_value_json( 604 | server_name, "subscriptions", parsed_rx_channels["subscriptions"] 605 | ) 606 | elif ( 607 | message_type == MESSAGE_TYPE_LOCK_STATUS 608 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 609 | and multicast_port == DEVICE_INFO_PORT 610 | ): 611 | lock_status = parse_message_type_lock_status(dante_message) 612 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 613 | "message_type_string" 614 | ] = MESSAGE_TYPE_STRINGS[message_type] 615 | # print( 616 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port} type `{message_type}` ({MESSAGE_TYPE_STRINGS[message_type]}) from `{server_name}`" 617 | # ) 618 | cache_device_value_json(server_name, "lock_status", lock_status["lock_status"]) 619 | elif ( 620 | message_type == MESSAGE_TYPE_CODEC_STATUS 621 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 622 | and multicast_port == DEVICE_INFO_PORT 623 | ): 624 | codec_status = parse_message_type_codec_status(dante_message) 625 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 626 | "message_type_string" 627 | ] = MESSAGE_TYPE_STRINGS[message_type] 628 | # print( 629 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port} type `{message_type}` ({MESSAGE_TYPE_STRINGS[message_type]}) from `{server_name}`" 630 | # ) 631 | cache_device_value_json( 632 | server_name, "codec_status", codec_status["codec_status"] 633 | ) 634 | elif ( 635 | message_type == MESSAGE_TYPE_AES67_STATUS 636 | and multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 637 | and multicast_port == DEVICE_INFO_PORT 638 | ): 639 | aes67_status = parse_message_type_aes67_status(dante_message) 640 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 641 | "message_type_string" 642 | ] = MESSAGE_TYPE_STRINGS[message_type] 643 | # print( 644 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port} type `{message_type}` ({MESSAGE_TYPE_STRINGS[message_type]}) from `{server_name}`" 645 | # ) 646 | cache_device_value_json( 647 | server_name, "aes67_status", aes67_status["aes67_status"] 648 | ) 649 | elif ( 650 | multicast_group == MULTICAST_GROUP_CONTROL_MONITORING 651 | and src_port in [DEVICE_SETTINGS_PORT, DEVICE_INFO_SRC_PORT2] 652 | and message_type 653 | in [ 654 | MESSAGE_TYPE_ROUTING_DEVICE_CHANGE, 655 | MESSAGE_TYPE_RX_CHANNEL_CHANGE, 656 | MESSAGE_TYPE_RX_FLOW_CHANGE, 657 | ] 658 | ): 659 | print("Rx change for", server_name, message_type) 660 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 661 | "message_type_string" 662 | ] = MESSAGE_TYPE_STRINGS[message_type] 663 | parsed_rx_channels = get_rx_channels(server_name) 664 | cache_device_value_json( 665 | server_name, "rx_channels", parsed_rx_channels["rx_channels"] 666 | ) 667 | cache_device_value_json( 668 | server_name, "subscriptions", parsed_rx_channels["subscriptions"] 669 | ) 670 | else: 671 | if message_type in MESSAGE_TYPE_STRINGS: 672 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 673 | "message_type_string" 674 | ] = MESSAGE_TYPE_STRINGS[message_type] 675 | else: 676 | # print(f"Unknown message type: {message_type} from `{server_name}`") 677 | parsed_message_redis_hash["message_type_string"] = parsed_message[ 678 | "message_type_string" 679 | ] = "Unknown" 680 | 681 | # print( 682 | # f"Message was not parsed: {src_host}:{src_port} -> {multicast_group}:{multicast_port} type `{message_type}` ({parsed_message['message_type_string']}) from `{server_name}`" 683 | # ) 684 | 685 | # if parsed_dante_message: 686 | # redis_device_key = ":".join(["netaudio", "dante", "device", server_name]) 687 | # for key in parsed_dante_message.items(): 688 | # print( 689 | # { 690 | # key: json.dumps(parsed_dante_message, indent=2), 691 | # } 692 | # ) 693 | # redis_client.hset( 694 | # redis_device_key, 695 | # key=None, 696 | # value=None, 697 | # mapping={ 698 | # key: json.dumps(parsed_dante_message[key], indent=2), 699 | # }, 700 | # ) 701 | 702 | parsed_message["parsed_message"] = parsed_dante_message 703 | parsed_message_redis_hash["parsed_message"] = json.dumps( 704 | parsed_dante_message, indent=2 705 | ) 706 | 707 | if multicast_group: 708 | parsed_message["multicast_group"] = parsed_message_redis_hash[ 709 | "multicast_group" 710 | ] = multicast_group 711 | 712 | if multicast_port: 713 | parsed_message["multicast_port"] = parsed_message_redis_hash[ 714 | "multicast_port" 715 | ] = multicast_port 716 | 717 | # redis_message_key = ":".join( 718 | # ["netaudio", "dante", "device", "message", "received", src_host, str(timestamp)] 719 | # ) 720 | # redis_client.hset( 721 | # redis_message_key, 722 | # key=None, 723 | # value=None, 724 | # mapping=parsed_message_redis_hash, 725 | # ) 726 | 727 | # if parsed_message["parsed_message"]: 728 | # print(parsed_message["parsed_message"]) 729 | 730 | # cached_message = redis_client.hgetall(redis_message_key) 731 | # cached_device = redis_client.hgetall(redis_device_key) 732 | # print("cached device:", cached_device) 733 | # print("cached:", cached_message) 734 | # 735 | # if multicast_group and multicast_port 736 | # print( 737 | # f"{src_host}:{src_port} -> {multicast_group}:{multicast_port}\n {MESSAGE_TYPE_STRINGS[message_type]}\n {dante_message.hex()}" 738 | # ) 739 | 740 | return parsed_message 741 | 742 | 743 | def message_channel_counts_query(): 744 | message_length = 10 745 | message_type = MESSAGE_TYPE_CHANNEL_COUNTS_QUERY 746 | flags = 0 747 | sequence_id1 = random.randint(0, 255) 748 | sequence_id2 = random.randint(0, 65535) 749 | message_hex = f"27{sequence_id1:02x}{message_length:04x}{sequence_id2:04x}{message_type:04x}{flags:04x}" 750 | 751 | return bytes.fromhex(message_hex) 752 | 753 | 754 | def message_device_name_query(): 755 | message_length = 10 756 | message_type = MESSAGE_TYPE_NAME_QUERY 757 | flags = 0 758 | sequence_id1 = random.randint(0, 255) 759 | sequence_id2 = random.randint(0, 65535) 760 | message_hex = f"27{sequence_id1:02x}{message_length:04x}{sequence_id2:04x}{message_type:04x}{flags:04x}" 761 | 762 | return bytes.fromhex(message_hex) 763 | 764 | 765 | def message_rx_channels_query(page): 766 | flags = channel_pagination(page) 767 | message_length = 16 768 | message_type = MESSAGE_TYPE_RX_CHANNEL_QUERY 769 | sequence_id1 = random.randint(0, 255) 770 | sequence_id2 = random.randint(0, 65535) 771 | message_hex = f"27{sequence_id1:02x}{message_length:04x}{sequence_id2:04x}{message_type:04x}{flags}" 772 | 773 | return bytes.fromhex(message_hex) 774 | 775 | 776 | def message_tx_channels_friendly_names_query(page): 777 | flags = channel_pagination(page) 778 | message_length = 16 779 | message_type = MESSAGE_TYPE_TX_CHANNEL_FRIENDLY_NAMES_QUERY 780 | sequence_id1 = random.randint(0, 255) 781 | sequence_id2 = random.randint(0, 65535) 782 | message_hex = f"27{sequence_id1:02x}{message_length:04x}{sequence_id2:04x}{message_type:04x}{flags}" 783 | 784 | return bytes.fromhex(message_hex) 785 | 786 | 787 | def message_tx_channels_query(page): 788 | flags = channel_pagination(page) 789 | message_length = 16 790 | message_type = MESSAGE_TYPE_TX_CHANNEL_QUERY 791 | sequence_id1 = random.randint(0, 255) 792 | sequence_id2 = random.randint(0, 65535) 793 | message_hex = f"27{sequence_id1:02x}{message_length:04x}{sequence_id2:04x}{message_type:04x}{flags}" 794 | 795 | return bytes.fromhex(message_hex) 796 | 797 | 798 | def parse_message_type_name_query(message): 799 | device_name = message[10:-1].decode("utf-8") 800 | 801 | return { 802 | "name": device_name, 803 | } 804 | 805 | 806 | def parse_message_type_channel_counts_query(message): 807 | rx_count = int.from_bytes(message[15:16], "big") 808 | tx_count = int.from_bytes(message[13:14], "big") 809 | 810 | return { 811 | "rx_channel_count": rx_count, 812 | "tx_channel_count": tx_count, 813 | } 814 | 815 | 816 | def get_label(hex_str, offset): 817 | parsed_get_label = None 818 | 819 | try: 820 | hex_substring = hex_str[offset * 2 :] 821 | partitioned_bytes = bytes.fromhex(hex_substring).partition(b"\x00")[0] 822 | parsed_get_label = partitioned_bytes.decode("utf-8") 823 | except Exception: 824 | pass 825 | # traceback.print_exc() 826 | 827 | return parsed_get_label 828 | 829 | 830 | def parse_message_type_tx_channel_friendly_names_query( 831 | message, name, tx_count, sample_rate 832 | ): 833 | tx_channels_friendly_names = {} 834 | tx_friendly_names = message.hex() 835 | 836 | for index in range(0, min(tx_count, 32)): 837 | str1 = tx_friendly_names[(24 + (index * 12)) : (36 + (index * 12))] 838 | n = 4 839 | channel = [str1[i : i + 4] for i in range(0, len(str1), n)] 840 | channel_number = int(channel[1], 16) 841 | channel_offset = channel[2] 842 | tx_channel_friendly_name = get_label(tx_friendly_names, channel_offset) 843 | 844 | if tx_channel_friendly_name: 845 | tx_channels_friendly_names[channel_number] = tx_channel_friendly_name 846 | 847 | return {"tx_channels_friendly_names": tx_channels_friendly_names} 848 | 849 | 850 | def parse_message_type_tx_channel_query(message, name, tx_count, sample_rate): 851 | # has_disabled_channels = False 852 | tx_channels = {} 853 | transmitters = message.hex() 854 | 855 | # if sample_rate: 856 | # has_disabled_channels = transmitters.count(f"{sample_rate:06x}") == 2 857 | 858 | # first_channel = [] 859 | 860 | for index in range(0, min(tx_count, 32)): 861 | str1 = transmitters[(24 + (index * 16)) : (40 + (index * 16))] 862 | n = 4 863 | channel = [str1[i : i + 4] for i in range(0, len(str1), n)] 864 | 865 | # if index == 0: 866 | # first_channel = channel 867 | 868 | if channel: 869 | o1 = (int(channel[2], 16) * 2) + 2 870 | o2 = o1 + 6 871 | sample_rate_hex = transmitters[o1:o2] 872 | 873 | if sample_rate_hex != "000000": 874 | sample_rate = int(sample_rate_hex, 16) 875 | 876 | channel_number = int(channel[0], 16) 877 | # channel_status = channel[1][2:] 878 | # channel_group = channel[2] 879 | channel_offset = int(channel[3], 16) 880 | 881 | # channel_enabled = channel_group == first_channel[2] 882 | # channel_disabled = channel_group != first_channel[2] 883 | 884 | # if channel_disabled: 885 | # break 886 | 887 | tx_channel_name = get_label(transmitters, channel_offset) 888 | 889 | if tx_channel_name is None or channel_number == 0: 890 | break 891 | 892 | tx_channel = {} 893 | tx_channel["channel_type"] = "tx" 894 | tx_channel["number"] = channel_number 895 | tx_channel["device"] = name 896 | tx_channel["name"] = tx_channel_name 897 | 898 | # if channel_number in tx_friendly_channel_names: 899 | # tx_channel.friendly_name = tx_friendly_channel_names[channel_number] 900 | 901 | tx_channels[channel_number] = tx_channel 902 | 903 | # if has_disabled_channels: 904 | # break 905 | 906 | return {"tx_channels": tx_channels} 907 | 908 | 909 | def parse_message_type_rx_channel_query(message, name, rx_count): 910 | hex_rx_response = message.hex() 911 | rx_channels = {} 912 | subscriptions = {} 913 | 914 | for index in range(0, min(rx_count, 16)): 915 | n = 4 916 | str1 = hex_rx_response[(24 + (index * 40)) : (56 + (index * 40))] 917 | channel = [str1[i : i + n] for i in range(0, len(str1), n)] 918 | 919 | channel_number = int(channel[0], 16) 920 | channel_offset = int(channel[3], 16) 921 | device_offset = int(channel[4], 16) 922 | rx_channel_offset = int(channel[5], 16) 923 | rx_channel_status_code = int(channel[6], 16) 924 | subscription_status_code = int(channel[7], 16) 925 | 926 | rx_channel_name = get_label(hex_rx_response, rx_channel_offset) 927 | tx_device_name = get_label(hex_rx_response, device_offset) 928 | 929 | if channel_offset != 0: 930 | tx_channel_name = get_label(hex_rx_response, channel_offset) 931 | else: 932 | tx_channel_name = rx_channel_name 933 | 934 | channel_status_text = None 935 | subscription = {} 936 | rx_channel = {} 937 | 938 | rx_channel["channel_type"] = "rx" 939 | rx_channel["device_name"] = name 940 | rx_channel["name"] = rx_channel_name 941 | rx_channel["number"] = channel_number 942 | rx_channel["status_code"] = rx_channel_status_code 943 | 944 | if channel_status_text: 945 | rx_channel["status_text"] = channel_status_text 946 | 947 | rx_channels[channel_number] = rx_channel 948 | 949 | subscription["rx_channel_name"] = rx_channel_name 950 | subscription["rx_channel_number"] = channel_number 951 | subscription["rx_device_name"] = name 952 | 953 | subscription["status_code"] = subscription_status_code 954 | subscription["rx_channel_status_code"] = rx_channel_status_code 955 | 956 | if rx_channel_status_code in SUBSCRIPTION_STATUS_LABELS: 957 | subscription["rx_channel_status_text"] = SUBSCRIPTION_STATUS_LABELS[ 958 | rx_channel_status_code 959 | ] 960 | 961 | if subscription_status_code == SUBSCRIPTION_STATUS_NONE: 962 | subscription["tx_device_name"] = None 963 | subscription["tx_channel_name"] = None 964 | elif tx_device_name == ".": 965 | subscription["tx_channel_name"] = tx_channel_name 966 | subscription["tx_device_name"] = name 967 | else: 968 | subscription["tx_channel_name"] = tx_channel_name 969 | subscription["tx_device_name"] = tx_device_name 970 | 971 | subscription["status_message"] = SUBSCRIPTION_STATUS_LABELS[ 972 | subscription_status_code 973 | ] 974 | subscriptions[channel_number] = subscription 975 | 976 | return {"rx_channels": rx_channels, "subscriptions": subscriptions} 977 | 978 | 979 | def parse_dante_arc_message(dante_message): 980 | parsed_dante_message = {} 981 | 982 | message_type = int.from_bytes(dante_message[6:8], "big") 983 | 984 | if message_type == MESSAGE_TYPE_NAME_QUERY: 985 | parsed_dante_message = parse_message_type_name_query(dante_message) 986 | elif message_type == MESSAGE_TYPE_CHANNEL_COUNTS_QUERY: 987 | parsed_dante_message = parse_message_type_channel_counts_query(dante_message) 988 | else: 989 | print(f"Message type {message_type} was not parsed") 990 | 991 | return parsed_dante_message 992 | 993 | 994 | def channel_pagination(page): 995 | message_args = f"0000000100{page:x}10000" 996 | 997 | return message_args 998 | 999 | 1000 | def get_tx_channels(server_name): 1001 | tx_channels = {} 1002 | # tx_channels_friendly_names = {} 1003 | 1004 | redis_service_key = ":".join( 1005 | ["netaudio", "dante", "service", server_name, SERVICE_ARC] 1006 | ) 1007 | cached_service = redis_decode(redis_client.hgetall(redis_service_key)) 1008 | port = int(cached_service["port"]) 1009 | sock = sockets[server_name][port] 1010 | 1011 | redis_device_key = ":".join(["netaudio", "dante", "device", server_name]) 1012 | 1013 | cached_device = redis_decode(redis_client.hgetall(redis_device_key)) 1014 | 1015 | if "tx_channel_count" in cached_device: 1016 | tx_count = int(cached_device["tx_channel_count"]) 1017 | 1018 | if "name" in cached_device: 1019 | name = cached_device["name"] 1020 | 1021 | try: 1022 | for page in range(0, max(int(tx_count / 16), 1)): 1023 | query = message_tx_channels_query(page) 1024 | sock.send(query) 1025 | tx_channels_message = sock.recvfrom(2048)[0] 1026 | parsed_tx_channels_query = parse_message_type_tx_channel_query( 1027 | tx_channels_message, name, tx_count, None 1028 | ) 1029 | tx_channels = tx_channels | parsed_tx_channels_query["tx_channels"] 1030 | 1031 | # query = message_tx_channels_friendly_names_query(page) 1032 | # sock.send(query) 1033 | # tx_channels_friendly_names_message = sock.recvfrom(2048)[0] 1034 | # parsed_tx_channels_friendly_names_query = ( 1035 | # parse_message_type_tx_channel_friendly_names_query( 1036 | # tx_channels_friendly_names_message, name, tx_count, None 1037 | # ) 1038 | # ) 1039 | # tx_channels_friendly_names = ( 1040 | # tx_channels_friendly_names 1041 | # | parsed_tx_channels_friendly_names_query["tx_channels_friendly_names"] 1042 | # ) 1043 | except Exception: 1044 | traceback.print_exc() 1045 | 1046 | return { 1047 | "tx_channels": tx_channels, 1048 | # "tx_channels_friendly_names": tx_channels_friendly_names, 1049 | } 1050 | 1051 | 1052 | def get_rx_channels(server_name): 1053 | rx_channels = {} 1054 | subscriptions = {} 1055 | 1056 | redis_service_key = ":".join( 1057 | ["netaudio", "dante", "service", server_name, SERVICE_ARC] 1058 | ) 1059 | cached_service = redis_decode(redis_client.hgetall(redis_service_key)) 1060 | port = int(cached_service["port"]) 1061 | sock = sockets[server_name][port] 1062 | 1063 | redis_device_key = ":".join(["netaudio", "dante", "device", server_name]) 1064 | 1065 | cached_device = redis_decode(redis_client.hgetall(redis_device_key)) 1066 | 1067 | if "rx_channel_count" in cached_device: 1068 | rx_count = int(cached_device["rx_channel_count"]) 1069 | 1070 | if "name" in cached_device: 1071 | name = cached_device["name"] 1072 | 1073 | try: 1074 | for page in range(0, max(int(rx_count / 16), 1)): 1075 | query = message_rx_channels_query(page) 1076 | sock.send(query) 1077 | rx_channels_message = sock.recvfrom(2048)[0] 1078 | parsed_rx_channels_query = parse_message_type_rx_channel_query( 1079 | rx_channels_message, name, rx_count 1080 | ) 1081 | rx_channels = rx_channels | parsed_rx_channels_query["rx_channels"] 1082 | subscriptions = subscriptions | parsed_rx_channels_query["subscriptions"] 1083 | except Exception: 1084 | traceback.print_exc() 1085 | 1086 | return { 1087 | "rx_channels": rx_channels, 1088 | "subscriptions": subscriptions, 1089 | } 1090 | 1091 | 1092 | def device_initialize_arc(server_name): 1093 | redis_service_key = ":".join( 1094 | ["netaudio", "dante", "service", server_name, SERVICE_ARC] 1095 | ) 1096 | cached_service = redis_decode(redis_client.hgetall(redis_service_key)) 1097 | port = int(cached_service["port"]) 1098 | 1099 | try: 1100 | sock = sockets[server_name][port] 1101 | sock.send(message_device_name_query()) 1102 | device_name_message = sock.recvfrom(2048)[0] 1103 | parsed_name_query = parse_dante_arc_message(device_name_message) 1104 | device_name = parsed_name_query["name"] 1105 | 1106 | redis_device_key = ":".join(["netaudio", "dante", "device", server_name]) 1107 | redis_client.hset( 1108 | redis_device_key, 1109 | key=None, 1110 | value=None, 1111 | mapping={ 1112 | "name": device_name, 1113 | }, 1114 | ) 1115 | 1116 | sock.send(message_channel_counts_query()) 1117 | channel_count_message = sock.recvfrom(2048)[0] 1118 | parsed_channel_count_query = parse_dante_arc_message(channel_count_message) 1119 | rx_count = parsed_channel_count_query["rx_channel_count"] 1120 | tx_count = parsed_channel_count_query["tx_channel_count"] 1121 | 1122 | redis_client.hset( 1123 | redis_device_key, 1124 | key=None, 1125 | value=None, 1126 | mapping={ 1127 | "rx_channel_count": rx_count, 1128 | "tx_channel_count": tx_count, 1129 | }, 1130 | ) 1131 | 1132 | parsed_rx_channels = get_rx_channels(server_name) 1133 | cache_device_value_json( 1134 | server_name, "rx_channels", parsed_rx_channels["rx_channels"] 1135 | ) 1136 | cache_device_value_json( 1137 | server_name, "subscriptions", parsed_rx_channels["subscriptions"] 1138 | ) 1139 | 1140 | parsed_tx_channels = get_tx_channels(server_name) 1141 | cache_device_value_json( 1142 | server_name, "tx_channels", parsed_tx_channels["tx_channels"] 1143 | ) 1144 | 1145 | cached_device = redis_decode(redis_client.hgetall(redis_device_key)) 1146 | 1147 | rx_channels = json.loads(cached_device["rx_channels"]) 1148 | tx_channels = json.loads(cached_device["tx_channels"]) 1149 | 1150 | print(f"{device_name} rx:{len(rx_channels)} tx:{len(tx_channels)}") 1151 | 1152 | except Exception: 1153 | traceback.print_exc() 1154 | 1155 | redis_client.hset( 1156 | redis_device_key, 1157 | key=None, 1158 | value=None, 1159 | mapping={ 1160 | "device_name": device_name, 1161 | "ipv4": cached_service["ipv4"], 1162 | "rx_channel_count": rx_count, 1163 | "server_name": server_name, 1164 | "tx_channel_count": tx_count, 1165 | }, 1166 | ) 1167 | 1168 | redis_client.sadd(":".join(["netaudio", "dante", "devices"]), device_name) 1169 | 1170 | 1171 | def parse_dante_service_change(message): 1172 | service = message["service"] 1173 | server_name = service["server_name"] 1174 | ipv4 = service["ipv4"] 1175 | 1176 | if not server_name in sockets: 1177 | sockets[server_name] = {} 1178 | 1179 | state_change = message["state_change"] 1180 | 1181 | if state_change["name"] == "Added": 1182 | redis_client.sadd(":".join(["netaudio", "dante", "hosts"]), service["ipv4"]) 1183 | redis_client.sadd(":".join(["netaudio", "dante", "servers"]), server_name) 1184 | redis_client.sadd(":".join(["netaudio", "dante", "services"]), service["name"]) 1185 | 1186 | for port in PORTS: 1187 | if port in sockets[server_name]: 1188 | continue 1189 | 1190 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 1191 | sock.bind(("", 0)) 1192 | sock.settimeout(0.01) 1193 | sock.connect((ipv4, port)) 1194 | sockets[server_name][port] = sock 1195 | 1196 | redis_host_key = ":".join(["netaudio", "dante", "host", service["ipv4"]]) 1197 | redis_client.hset( 1198 | redis_host_key, 1199 | key=None, 1200 | value=None, 1201 | mapping={"ipv4": service["ipv4"], "server_name": server_name}, 1202 | ) 1203 | 1204 | key = ":".join(["netaudio", "dante", "server", server_name]) 1205 | redis_client.hset( 1206 | key, 1207 | key=None, 1208 | value=None, 1209 | mapping={ 1210 | "name": server_name, 1211 | "ipv4": ipv4, 1212 | }, 1213 | ) 1214 | 1215 | key = ":".join(["netaudio", "dante", "service", server_name, service["type"]]) 1216 | redis_client.hset( 1217 | key, 1218 | key=None, 1219 | value=None, 1220 | mapping={ 1221 | "ipv4": ipv4, 1222 | "name": service["name"], 1223 | "port": service["port"], 1224 | "server_name": server_name, 1225 | "type": service["type"], 1226 | }, 1227 | ) 1228 | 1229 | if service["properties"]: 1230 | key = ":".join( 1231 | [ 1232 | "netaudio", 1233 | "dante", 1234 | "service", 1235 | "properties", 1236 | server_name, 1237 | service["type"], 1238 | ] 1239 | ) 1240 | redis_client.hset(key, key=None, value=None, mapping=service["properties"]) 1241 | 1242 | if ( 1243 | not service["port"] in sockets[server_name] 1244 | and service["type"] == SERVICE_ARC 1245 | ): 1246 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 1247 | sock.bind(("", 0)) 1248 | sock.settimeout(1) 1249 | sock.connect((ipv4, service["port"])) 1250 | sockets[server_name][service["port"]] = sock 1251 | 1252 | device_initialize_arc(server_name) 1253 | 1254 | # print( 1255 | # f"Service added:\n {service['name']}\n {service['ipv4']}:{service['port']}" 1256 | # ) 1257 | elif state_change["name"] == "Updated": 1258 | pass 1259 | # print( 1260 | # f"Service updated:\n {service['name']}\n {service['ipv4']}:{service['port']}" 1261 | # ) 1262 | elif state_change["name"] == "Removed": 1263 | # redis_client.srem("hosts", service["ipv4"]) 1264 | # redis_client.srem("servers", service["server_name"]) 1265 | redis_client.srem("services", service["name"]) 1266 | print( 1267 | f"Service removed: {service['name']}\n {service['ipv4']}:{service['port']}" 1268 | ) 1269 | 1270 | # redis_service_key = ":".join(["netaudio", "dante", "service", service["name"]]) 1271 | # cached_service = redis_client.hgetall(redis_service_key) 1272 | # print("cached:", cached_service) 1273 | 1274 | 1275 | def multicast(group, port): 1276 | server_address = ("", port) 1277 | mc_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 1278 | 1279 | try: 1280 | mc_sock.bind(server_address) 1281 | except OSError as e: 1282 | print(e) 1283 | print(f"Failed to bind to multicast port {port}") 1284 | return 1285 | 1286 | group_bin = socket.inet_aton(group) 1287 | mreq = struct.pack("4sL", group_bin, socket.INADDR_ANY) 1288 | mc_sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) 1289 | 1290 | while True: 1291 | try: 1292 | data, addr = mc_sock.recvfrom(2048) 1293 | timestamp = time.time_ns() 1294 | 1295 | src_host, src_port = addr 1296 | 1297 | message = { 1298 | "message_hex": data.hex(), 1299 | "multicast_group": group, 1300 | "multicast_port": port, 1301 | "src_host": src_host, 1302 | "src_port": src_port, 1303 | "time": timestamp, 1304 | } 1305 | 1306 | if group == MULTICAST_GROUP_HEARTBEAT and port == DEVICE_HEARTBEAT_PORT: 1307 | # print("heartbeat from", addr[0]) 1308 | 1309 | cached_host = redis_decode( 1310 | redis_client.hgetall( 1311 | ":".join(["netaudio", "dante", "host", addr[0]]) 1312 | ) 1313 | ) 1314 | 1315 | if "server_name" in cached_host: 1316 | server_name = cached_host["server_name"] 1317 | cache_device_value(server_name, "last_seen_at", timestamp) 1318 | redis_device_key = ":".join( 1319 | ["netaudio", "dante", "device", server_name] 1320 | ) 1321 | redis_client.expire(redis_device_key, 5) 1322 | 1323 | redis_server_key = ":".join( 1324 | ["netaudio", "dante", "server", server_name] 1325 | ) 1326 | redis_client.expire(redis_server_key, 5) 1327 | 1328 | redis_host_key = ":".join(["netaudio", "dante", "host", addr[0]]) 1329 | redis_client.expire(redis_host_key, 5) 1330 | else: 1331 | parse_dante_message(message) 1332 | 1333 | except Exception: 1334 | traceback.print_exc() 1335 | 1336 | 1337 | class ServerMdnsCommand(Command): 1338 | name = "mdns" 1339 | description = "Run a daemon to monitor mDNS ports for changes to devices" 1340 | 1341 | def __init__(self): 1342 | super().__init__() 1343 | self.stop_event = Event() # Stop event for signaling shutdown 1344 | self.threads = [] # List of threads to manage 1345 | 1346 | def parse_services(self, queue): 1347 | while True: 1348 | message = queue.get() 1349 | parse_dante_service_change(message) 1350 | queue.task_done() 1351 | 1352 | def zeroconf_browser(self, queue): 1353 | dante_browser = DanteBrowser(0, queue) 1354 | dante_browser.sync_run() 1355 | 1356 | async def server_mdns(self): 1357 | queue = Queue() 1358 | 1359 | if not redis_client: 1360 | print( 1361 | "Couldn't connect to a redis server. Specify with env variables REDIS_SOCKET REDIS_HOST REDIS_PORT REDIS_DB" 1362 | ) 1363 | sys.exit(0) 1364 | 1365 | pattern = ":".join(["netaudio", "dante", "*"]) 1366 | 1367 | for key in redis_client.scan_iter(match=pattern): 1368 | redis_client.delete(key) 1369 | 1370 | self.threads.append( 1371 | Thread( 1372 | target=self.multicast_worker, 1373 | args=(MULTICAST_GROUP_CONTROL_MONITORING, DEVICE_INFO_PORT), 1374 | daemon=True, 1375 | ) 1376 | ) 1377 | 1378 | self.threads.append( 1379 | Thread( 1380 | target=self.multicast_worker, 1381 | args=( 1382 | MULTICAST_GROUP_CONTROL_MONITORING, 1383 | DEFAULT_MULTICAST_METERING_PORT, 1384 | ), 1385 | daemon=True, 1386 | ) 1387 | ) 1388 | 1389 | self.threads.append( 1390 | Thread( 1391 | target=self.multicast_worker, 1392 | args=(MULTICAST_GROUP_HEARTBEAT, DEVICE_HEARTBEAT_PORT), 1393 | daemon=True, 1394 | ) 1395 | ) 1396 | 1397 | self.threads.append( 1398 | Thread(target=self.parse_services, args=(queue,), daemon=True) 1399 | ) 1400 | 1401 | self.threads.append( 1402 | Thread(target=self.zeroconf_browser, args=(queue,), daemon=True) 1403 | ) 1404 | 1405 | for thread in self.threads: 1406 | thread.start() 1407 | 1408 | try: 1409 | while not self.stop_event.is_set(): 1410 | time.sleep(1) 1411 | except (KeyboardInterrupt, SystemExit): 1412 | print("Received stop signal, shutting down...") 1413 | self.stop_event.set() # Signal all threads to stop 1414 | 1415 | for thread in self.threads: 1416 | thread.join() 1417 | 1418 | def multicast_worker(self, group, port): 1419 | server_address = ("", port) 1420 | mc_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 1421 | 1422 | try: 1423 | mc_sock.bind(server_address) 1424 | except OSError as e: 1425 | print(e) 1426 | print(f"Failed to bind to multicast port {port}") 1427 | return 1428 | 1429 | group_bin = socket.inet_aton(group) 1430 | mreq = struct.pack("4sL", group_bin, socket.INADDR_ANY) 1431 | mc_sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) 1432 | 1433 | while not self.stop_event.is_set(): 1434 | try: 1435 | data, addr = mc_sock.recvfrom(2048) 1436 | timestamp = time.time_ns() 1437 | 1438 | src_host, src_port = addr 1439 | message = { 1440 | "message_hex": data.hex(), 1441 | "multicast_group": group, 1442 | "multicast_port": port, 1443 | "src_host": src_host, 1444 | "src_port": src_port, 1445 | "time": timestamp, 1446 | } 1447 | 1448 | parse_dante_message(message) 1449 | 1450 | except (socket.error, OSError): 1451 | if self.stop_event.is_set(): 1452 | break 1453 | traceback.print_exc() 1454 | 1455 | def handle(self): 1456 | asyncio.run(self.server_mdns()) 1457 | -------------------------------------------------------------------------------- /netaudio/console/commands/subscription/__init__.py: -------------------------------------------------------------------------------- 1 | from cleo.commands.command import Command 2 | from cleo.helpers import option 3 | 4 | from ._add import SubscriptionAddCommand 5 | from ._list import SubscriptionListCommand 6 | from ._remove import SubscriptionRemoveCommand 7 | 8 | 9 | class SubscriptionCommand(Command): 10 | name = "subscription" 11 | description = "Control subscriptions" 12 | commands = [ 13 | SubscriptionAddCommand(), 14 | SubscriptionListCommand(), 15 | SubscriptionRemoveCommand(), 16 | ] 17 | 18 | def handle(self): 19 | return self.call("help", self._config.name) 20 | -------------------------------------------------------------------------------- /netaudio/console/commands/subscription/_add.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from cleo.commands.command import Command 4 | from cleo.helpers import option 5 | 6 | from netaudio.dante.browser import DanteBrowser 7 | 8 | 9 | class SubscriptionAddCommand(Command): 10 | name = "add" 11 | description = "Add a subscription" 12 | 13 | options = [ 14 | option("rx-channel-name", None, "Specify Rx channel by name", flag=False), 15 | option("rx-channel-number", None, "Specify Rx channel by number", flag=False), 16 | option("rx-device-host", None, "Specify Tx device by host", flag=False), 17 | option("rx-device-name", None, "Specify Tx device by name", flag=False), 18 | option("tx-channel-name", None, "Specify Tx channel by name", flag=False), 19 | option("tx-channel-number", None, "Specify Tx channel by number", flag=False), 20 | option("tx-device-host", None, "Specify Tx device by host", flag=False), 21 | option("tx-device-name", None, "Specify Tx device by name", flag=False), 22 | ] 23 | 24 | async def subscription_add(self): 25 | dante_browser = DanteBrowser(mdns_timeout=1.5) 26 | dante_devices = await dante_browser.get_devices() 27 | 28 | for _, device in dante_devices.items(): 29 | await device.get_controls() 30 | 31 | rx_channel = None 32 | rx_device = None 33 | tx_channel = None 34 | tx_device = None 35 | 36 | if self.option("tx-device-name"): 37 | tx_device = next( 38 | filter( 39 | lambda d: d[1].name == self.option("tx-device-name"), 40 | dante_devices.items(), 41 | ) 42 | )[1] 43 | elif self.option("tx-device-host"): 44 | tx_device = next( 45 | filter( 46 | lambda d: d[1].ipv4 == self.option("tx-device-host"), 47 | dante_devices.items(), 48 | ) 49 | )[1] 50 | 51 | if self.option("tx-channel-name"): 52 | tx_channel = next( 53 | filter( 54 | lambda c: self.option("tx-channel-name") == c[1].friendly_name 55 | or self.option("tx-channel-name") == c[1].name 56 | and not c[1].friendly_name, 57 | tx_device.tx_channels.items(), 58 | ) 59 | )[1] 60 | elif self.option("tx-channel-number"): 61 | tx_channel = next( 62 | filter( 63 | lambda c: c[1].number == self.option("tx-channel-number"), 64 | tx_device.tx_channels.items(), 65 | ) 66 | )[1] 67 | 68 | if self.option("rx-device-name"): 69 | rx_device = next( 70 | filter( 71 | lambda d: d[1].name == self.option("rx-device-name"), 72 | dante_devices.items(), 73 | ) 74 | )[1] 75 | elif self.option("rx-device-host"): 76 | rx_device = next( 77 | filter( 78 | lambda d: d[1].ipv4 == self.option("rx-device-host"), 79 | dante_devices.items(), 80 | ) 81 | )[1] 82 | 83 | if self.option("rx-channel-name"): 84 | rx_channel = next( 85 | filter( 86 | lambda c: c[1].name == self.option("rx-channel-name"), 87 | rx_device.rx_channels.items(), 88 | ) 89 | )[1] 90 | elif self.option("rx-channel-number"): 91 | rx_channel = next( 92 | filter( 93 | lambda c: c[1].number == self.option("rx-channel-number"), 94 | rx_device.rx_channels.items(), 95 | ) 96 | )[1] 97 | 98 | if rx_device and not tx_device: 99 | tx_device = rx_device 100 | 101 | if rx_channel and rx_device and tx_channel and tx_channel: 102 | self.line( 103 | f"{rx_channel.name}@{rx_device.name} <- {tx_channel.name}@{tx_device.name}" 104 | ) 105 | await rx_device.add_subscription(rx_channel, tx_channel, tx_device) 106 | 107 | def handle(self): 108 | asyncio.run(self.subscription_add()) 109 | -------------------------------------------------------------------------------- /netaudio/console/commands/subscription/_list.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import os 4 | 5 | from json import JSONEncoder 6 | 7 | from cleo.commands.command import Command 8 | from cleo.helpers import option 9 | from redis import Redis 10 | from redis.exceptions import ConnectionError as RedisConnectionError 11 | 12 | from netaudio.dante.browser import DanteBrowser 13 | 14 | # from netaudio.dante.cache import DanteCache 15 | 16 | 17 | def _default(self, obj): 18 | return getattr(obj.__class__, "to_json", _default.default)(obj) 19 | 20 | 21 | _default.default = JSONEncoder().default 22 | JSONEncoder.default = _default 23 | 24 | 25 | class SubscriptionListCommand(Command): 26 | name = "list" 27 | description = "List subscriptions" 28 | 29 | options = [option("json", None, "Output as JSON", flag=True)] 30 | 31 | # options = [ 32 | # option('rx-channel-name', None, 'Filter by Rx channel name', flag=False), 33 | # option('rx-channel-number', None, 'Filter by Rx channel number', flag=False), 34 | # option('rx-device-host', None, 'Filter by Rx device host', flag=False), 35 | # option('rx-device-name', None, 'Filter by Rx device name', flag=False), 36 | # option('tx-channel-name', None, 'Filter by Tx channel name', flag=False), 37 | # option('tx-channel-number', None, 'Filter by Tx channel number', flag=False), 38 | # option('tx-device-host', None, 'Filter by Tx device host', flag=False), 39 | # option('tx-device-name', None, 'Filter by Tx device name', flag=False), 40 | # ] 41 | 42 | async def subscription_list(self): 43 | subscriptions = [] 44 | 45 | redis_enabled = None 46 | 47 | redis_socket_path = os.environ.get("REDIS_SOCKET") 48 | redis_host = os.environ.get("REDIS_HOST") or "localhost" 49 | redis_port = os.environ.get("REDIS_PORT") or 6379 50 | redis_db = os.environ.get("REDIS_DB") or 0 51 | 52 | try: 53 | redis_client = None 54 | 55 | if redis_socket_path: 56 | redis_client = Redis( 57 | db=redis_db, 58 | decode_responses=False, 59 | socket_timeout=0.1, 60 | unix_socket_path=redis_socket_path, 61 | ) 62 | elif os.environ.get("REDIS_PORT") or os.environ.get("REDIS_HOST"): 63 | redis_client = Redis( 64 | db=redis_db, 65 | decode_responses=False, 66 | host=redis_host, 67 | socket_timeout=0.1, 68 | port=redis_port, 69 | ) 70 | if redis_client: 71 | redis_client.ping() 72 | redis_enabled = True 73 | except RedisConnectionError: 74 | redis_enabled = False 75 | 76 | if redis_enabled: 77 | # dante_cache = DanteCache() 78 | devices = await dante_cache.get_devices() 79 | devices = dict(sorted(devices.items(), key=lambda x: x[1].name)) 80 | else: 81 | dante_browser = DanteBrowser(mdns_timeout=1.5) 82 | devices = await dante_browser.get_devices() 83 | devices = dict(sorted(devices.items(), key=lambda x: x[1].name)) 84 | 85 | for _, device in devices.items(): 86 | await device.get_controls() 87 | 88 | # rx_channel = None 89 | # rx_device = None 90 | # tx_channel = None 91 | # tx_device = None 92 | 93 | # if self.option('tx-device-name'): 94 | # tx_device = next(filter(lambda d: d[1].name == self.option('tx-device-name'), devices.items()))[1] 95 | # elif self.option('tx-device-host'): 96 | # tx_device = next(filter(lambda d: d[1].ipv4 == self.option('tx-device-host'), devices.items()))[1] 97 | 98 | # if self.option('tx-channel-name'): 99 | # tx_channel = next(filter(lambda c: c[1].name == self.option('tx-channel-name'), tx_device.tx_channels.items()))[1] 100 | # elif self.option('tx-channel-number'): 101 | # tx_channel = next(filter(lambda c: c[1].number == self.option('tx-channel-number'), tx_device.tx_channels.items()))[1] 102 | 103 | # if self.option('rx-device-name'): 104 | # rx_device = next(filter(lambda d: d[1].name == self.option('rx-device-name'), devices.items()))[1] 105 | # elif self.option('rx-device-host'): 106 | # rx_device = next(filter(lambda d: d[1].ipv4 == self.option('rx-device-host'), devices.items()))[1] 107 | 108 | # if self.option('rx-channel-name'): 109 | # rx_channel = next(filter(lambda c: c[1].name == self.option('rx-channel-name'), rx_device.rx_channels.items()))[1] 110 | # elif self.option('rx-channel-number'): 111 | # rx_channel = next(filter(lambda c: c[1].number == self.option('rx-channel-number'), rx_device.rx_channels.items()))[1] 112 | 113 | for _, device in devices.items(): 114 | for subscription in device.subscriptions: 115 | subscriptions.append(subscription) 116 | 117 | if self.option("json"): 118 | json_object = json.dumps(subscriptions, indent=2) 119 | self.line(f"{json_object}") 120 | else: 121 | for subscription in subscriptions: 122 | self.line(f"{subscription}") 123 | 124 | def handle(self): 125 | asyncio.run(self.subscription_list()) 126 | -------------------------------------------------------------------------------- /netaudio/console/commands/subscription/_remove.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from cleo.commands.command import Command 4 | from cleo.helpers import option 5 | 6 | from netaudio.dante.browser import DanteBrowser 7 | 8 | 9 | class SubscriptionRemoveCommand(Command): 10 | name = "remove" 11 | description = "Remove a subscription" 12 | 13 | options = [ 14 | option("rx-channel-name", None, "Specify Rx channel by name", flag=False), 15 | option("rx-channel-number", None, "Specify Rx channel by number", flag=False), 16 | option("rx-device-host", None, "Specify Rx device by host", flag=False), 17 | option("rx-device-name", None, "Specify Rx device by name", flag=False), 18 | ] 19 | 20 | async def subscription_add(self): 21 | dante_browser = DanteBrowser(mdns_timeout=1.5) 22 | dante_devices = await dante_browser.get_devices() 23 | 24 | for _, device in dante_devices.items(): 25 | await device.get_controls() 26 | 27 | rx_channel = None 28 | rx_device = None 29 | 30 | if self.option("rx-device-name"): 31 | rx_device = next( 32 | filter( 33 | lambda d: d[1].name == self.option("rx-device-name"), 34 | dante_devices.items(), 35 | ) 36 | )[1] 37 | elif self.option("rx-device-host"): 38 | rx_device = next( 39 | filter( 40 | lambda d: d[1].ipv4 == self.option("rx-device-host"), 41 | dante_devices.items(), 42 | ) 43 | )[1] 44 | 45 | if self.option("rx-channel-name"): 46 | rx_channel = next( 47 | filter( 48 | lambda c: c[1].name == self.option("rx-channel-name"), 49 | rx_device.rx_channels.items(), 50 | ) 51 | )[1] 52 | elif self.option("rx-channel-number"): 53 | rx_channel = next( 54 | filter( 55 | lambda c: c[1].number == self.option("rx-channel-number"), 56 | rx_device.rx_channels.items(), 57 | ) 58 | )[1] 59 | 60 | if rx_channel and rx_device: 61 | await rx_device.remove_subscription(rx_channel) 62 | 63 | def handle(self): 64 | asyncio.run(self.subscription_add()) 65 | -------------------------------------------------------------------------------- /netaudio/dante/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris-ritsen/network-audio-controller/f4eef6a0021d29b36b9d805d7cb0f9c29f583673/netaudio/dante/__init__.py -------------------------------------------------------------------------------- /netaudio/dante/browser.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import os 4 | import traceback 5 | 6 | from queue import Queue 7 | from json import JSONEncoder 8 | 9 | from zeroconf import DNSService, DNSText 10 | 11 | from zeroconf import ( 12 | IPVersion, 13 | ServiceStateChange, 14 | ServiceBrowser, 15 | ServiceInfo, 16 | Zeroconf, 17 | ) 18 | 19 | from zeroconf.asyncio import ( 20 | AsyncServiceBrowser, 21 | AsyncServiceInfo, 22 | AsyncZeroconf, 23 | ) 24 | 25 | from netaudio.dante.const import SERVICE_CMC, SERVICES 26 | from netaudio.dante.device import DanteDevice 27 | 28 | 29 | def _default(self, obj): 30 | return getattr(obj.__class__, "to_json", _default.default)(obj) 31 | 32 | 33 | _default.default = JSONEncoder().default 34 | JSONEncoder.default = _default 35 | 36 | 37 | class DanteBrowser: 38 | def __init__(self, mdns_timeout, queue=None) -> None: 39 | self._devices = {} 40 | self.services = [] 41 | self.queue = queue 42 | self._mdns_timeout: float = mdns_timeout 43 | self.aio_browser: AsyncServiceBrowser = None 44 | self.aio_zc: AsyncZeroconf = None 45 | 46 | @property 47 | def mdns_timeout(self): 48 | return self._mdns_timeout 49 | 50 | @mdns_timeout.setter 51 | def mdns_timeout(self, mdns_timeout): 52 | self._mdns_timeout = mdns_timeout 53 | 54 | @property 55 | def devices(self): 56 | return self._devices 57 | 58 | @devices.setter 59 | def devices(self, devices): 60 | self._devices = devices 61 | 62 | def sync_parse_state_change(self, zeroconf, service_type, name, state_change): 63 | info = ServiceInfo(service_type, name) 64 | 65 | if state_change != ServiceStateChange.Removed: 66 | info_success = info.request(zeroconf, 3000) 67 | 68 | if not info_success: 69 | return 70 | 71 | service_properties = {} 72 | 73 | for key, value in info.properties.items(): 74 | key = key.decode("utf-8") 75 | 76 | if isinstance(value, bytes): 77 | value = value.decode("utf-8") 78 | 79 | service_properties[key] = value 80 | 81 | records = zeroconf.cache.entries_with_name(name) 82 | addresses = info.parsed_addresses() 83 | 84 | if not addresses: 85 | return 86 | 87 | for record in records: 88 | if isinstance(record, DNSService): 89 | ipv4 = addresses[0] 90 | 91 | message = { 92 | "service": { 93 | "ipv4": ipv4, 94 | "name": name, 95 | "port": info.port, 96 | "properties": service_properties, 97 | "server_name": record.server, 98 | "type": service_type, 99 | }, 100 | "state_change": { 101 | "name": state_change.name, 102 | "value": state_change.value, 103 | }, 104 | } 105 | 106 | self.queue.put(message) 107 | elif isinstance(record, DNSText): 108 | pass 109 | 110 | async def async_parse_state_change( 111 | self, zeroconf, service_type, name, state_change 112 | ): 113 | info = AsyncServiceInfo(service_type, name) 114 | 115 | if state_change != ServiceStateChange.Removed: 116 | info_success = await info.async_request(zeroconf, 3000) 117 | 118 | if not info_success: 119 | return 120 | 121 | service_properties = {} 122 | 123 | for key, value in info.properties.items(): 124 | key = key.decode("utf-8") 125 | 126 | if isinstance(value, bytes): 127 | value = value.decode("utf-8") 128 | 129 | service_properties[key] = value 130 | 131 | records = zeroconf.cache.entries_with_name(name) 132 | addresses = info.parsed_addresses() 133 | 134 | if not addresses: 135 | return 136 | 137 | for record in records: 138 | if isinstance(record, DNSService): 139 | ipv4 = addresses[0] 140 | 141 | message = { 142 | "service": { 143 | "ipv4": ipv4, 144 | "name": name, 145 | "port": info.port, 146 | "properties": service_properties, 147 | "server_name": record.server, 148 | "type": service_type, 149 | }, 150 | "state_change": { 151 | "name": state_change.name, 152 | "value": state_change.value, 153 | }, 154 | } 155 | 156 | json_message = json.dumps(message, indent=2) 157 | elif isinstance(record, DNSText): 158 | pass 159 | 160 | def async_on_service_state_change( 161 | self, 162 | zeroconf: Zeroconf, 163 | service_type: str, 164 | name: str, 165 | state_change: ServiceStateChange, 166 | ) -> None: 167 | 168 | if service_type == "_netaudio-chan._udp.local.": 169 | return 170 | 171 | loop = asyncio.get_running_loop() 172 | loop.create_task( 173 | self.async_parse_state_change(zeroconf, service_type, name, state_change) 174 | ) 175 | 176 | self.services.append( 177 | asyncio.ensure_future( 178 | self.async_parse_netaudio_service(zeroconf, service_type, name) 179 | ) 180 | ) 181 | 182 | def sync_on_service_state_change( 183 | self, 184 | zeroconf: Zeroconf, 185 | service_type: str, 186 | name: str, 187 | state_change: ServiceStateChange, 188 | ) -> None: 189 | if service_type == "_netaudio-chan._udp.local.": 190 | return 191 | 192 | self.sync_parse_state_change(zeroconf, service_type, name, state_change) 193 | 194 | def sync_run(self): 195 | zc = Zeroconf(ip_version=IPVersion.V4Only) 196 | services = SERVICES 197 | 198 | browser = ServiceBrowser( 199 | zc, 200 | services, 201 | handlers=[self.sync_on_service_state_change], 202 | ) 203 | 204 | browser.run() 205 | 206 | async def async_run(self) -> None: 207 | self.aio_zc = AsyncZeroconf(ip_version=IPVersion.V4Only) 208 | services = SERVICES 209 | 210 | self.aio_browser = AsyncServiceBrowser( 211 | self.aio_zc.zeroconf, 212 | services, 213 | handlers=[self.async_on_service_state_change], 214 | ) 215 | 216 | if self.mdns_timeout > 0: 217 | await asyncio.sleep(self.mdns_timeout) 218 | await self.async_close() 219 | 220 | async def async_close(self) -> None: 221 | assert self.aio_zc is not None 222 | assert self.aio_browser is not None 223 | await self.aio_browser.async_cancel() 224 | await self.aio_zc.async_close() 225 | 226 | async def get_devices(self) -> None: 227 | await self.get_services() 228 | await asyncio.gather(*self.services) 229 | 230 | device_hosts = {} 231 | 232 | for service in self.services: 233 | service = service.result() 234 | server_name = None 235 | 236 | if not service: 237 | continue 238 | 239 | if "server_name" in service: 240 | server_name = service["server_name"] 241 | 242 | if not server_name in device_hosts: 243 | device_hosts[server_name] = {} 244 | 245 | device_hosts[server_name][service["name"]] = service 246 | 247 | for hostname, device_services in device_hosts.items(): 248 | device = DanteDevice(server_name=hostname) 249 | 250 | try: 251 | for service_name, service in device_services.items(): 252 | device.services[service_name] = service 253 | 254 | service_properties = service["properties"] 255 | 256 | if not device.ipv4: 257 | device.ipv4 = service["ipv4"] 258 | 259 | if "id" in service_properties and service["type"] == SERVICE_CMC: 260 | device.mac_address = service_properties["id"] 261 | 262 | if "model" in service_properties: 263 | device.model_id = service_properties["model"] 264 | 265 | if "rate" in service_properties: 266 | device.sample_rate = int(service_properties["rate"]) 267 | 268 | if ( 269 | "router_info" in service_properties 270 | and service_properties["router_info"] == '"Dante Via"' 271 | ): 272 | device.software = "Dante Via" 273 | 274 | if "latency_ns" in service_properties: 275 | device.latency = int(service_properties["latency_ns"]) 276 | 277 | device.services = dict(sorted(device.services.items())) 278 | except Exception: 279 | traceback.print_exc() 280 | 281 | self.devices[hostname] = device 282 | 283 | return self.devices 284 | 285 | async def get_services(self) -> None: 286 | try: 287 | await self.async_run() 288 | except KeyboardInterrupt: 289 | await self.async_close() 290 | 291 | async def async_parse_netaudio_service( 292 | self, zeroconf: Zeroconf, service_type: str, name: str 293 | ) -> None: 294 | ipv4 = None 295 | service_properties = {} 296 | info = AsyncServiceInfo(service_type, name) 297 | info_success = await info.async_request(zeroconf, 3000) 298 | 299 | if not info_success: 300 | return 301 | 302 | host = zeroconf.cache.entries_with_name(name) 303 | addresses = info.parsed_addresses() 304 | 305 | if not addresses: 306 | return 307 | 308 | ipv4 = addresses[0] 309 | 310 | try: 311 | for key, value in info.properties.items(): 312 | key = key.decode("utf-8") 313 | 314 | if isinstance(value, bytes): 315 | value = value.decode("utf-8") 316 | 317 | service_properties[key] = value 318 | 319 | for record in host: 320 | if isinstance(record, DNSService): 321 | service = { 322 | "ipv4": ipv4, 323 | "name": name, 324 | "port": info.port, 325 | "properties": service_properties, 326 | "server_name": record.server, 327 | "type": info.type, 328 | } 329 | 330 | return service 331 | 332 | except Exception: 333 | traceback.print_exc() 334 | -------------------------------------------------------------------------------- /netaudio/dante/channel.py: -------------------------------------------------------------------------------- 1 | class DanteChannel: 2 | def __init__(self): 3 | self._channel_type = None 4 | self._device = None 5 | self._friendly_name = None 6 | self._name = None 7 | self._number = None 8 | self._status_code = None 9 | self._status_text = None 10 | self._volume = None 11 | 12 | def __str__(self): 13 | if self.friendly_name: 14 | name = self.friendly_name 15 | else: 16 | name = self.name 17 | 18 | if self.volume and self.volume != 254: 19 | text = f"{self.number}:{name} [{self.volume}]" 20 | else: 21 | text = f"{self.number}:{name}" 22 | 23 | return text 24 | 25 | @property 26 | def device(self): 27 | return self._device 28 | 29 | @device.setter 30 | def device(self, device): 31 | self._device = device 32 | 33 | @property 34 | def number(self): 35 | return self._number 36 | 37 | @number.setter 38 | def number(self, number): 39 | self._number = number 40 | 41 | @property 42 | def status_code(self): 43 | return self._status_code 44 | 45 | @status_code.setter 46 | def status_code(self, status_code): 47 | self._status_code = status_code 48 | 49 | @property 50 | def status_text(self): 51 | return self._status_text 52 | 53 | @status_text.setter 54 | def status_text(self, status_text): 55 | self._status_text = status_text 56 | 57 | @property 58 | def channel_type(self): 59 | return self._channel_type 60 | 61 | @channel_type.setter 62 | def channel_type(self, channel_type): 63 | self._channel_type = channel_type 64 | 65 | @property 66 | def friendly_name(self): 67 | return self._friendly_name 68 | 69 | @friendly_name.setter 70 | def friendly_name(self, friendly_name): 71 | self._friendly_name = friendly_name 72 | 73 | @property 74 | def name(self): 75 | return self._name 76 | 77 | @name.setter 78 | def name(self, name): 79 | self._name = name 80 | 81 | @property 82 | def volume(self): 83 | return self._volume 84 | 85 | @volume.setter 86 | def volume(self, volume): 87 | self._volume = volume 88 | 89 | def to_json(self): 90 | as_json = {"name": self.name} 91 | 92 | if self.friendly_name: 93 | as_json["friendly_name"] = self.friendly_name 94 | 95 | if self.status_text: 96 | as_json["status_text"] = self.status_text 97 | 98 | if self.volume: 99 | as_json["volume"] = self.volume 100 | 101 | return {key: as_json[key] for key in sorted(as_json.keys())} 102 | -------------------------------------------------------------------------------- /netaudio/dante/const.py: -------------------------------------------------------------------------------- 1 | SERVICE_ARC: str = "_netaudio-arc._udp.local." 2 | SERVICE_CHAN: str = "_netaudio-chan._udp.local." 3 | SERVICE_CMC: str = "_netaudio-cmc._udp.local." 4 | SERVICE_DBC: str = "_netaudio-dbc._udp.local." 5 | 6 | MULTICAST_GROUP_HEARTBEAT = "224.0.0.233" 7 | MULTICAST_GROUP_CONTROL_MONITORING = "224.0.0.231" 8 | 9 | SERVICES = [SERVICE_ARC, SERVICE_CHAN, SERVICE_CMC, SERVICE_DBC] 10 | 11 | FEATURE_VOLUME_UNSUPPORTED = [ 12 | "DAI1", 13 | "DAI2", 14 | "DAO1", 15 | "DAO2", 16 | "DIAES3", 17 | "DIOUSB", 18 | "DIUSBC", 19 | "_86012780000a0003", 20 | ] 21 | 22 | DEFAULT_MULTICAST_METERING_PORT = 8751 23 | DEVICE_CONTROL_PORT: int = 8800 24 | DEVICE_HEARTBEAT_PORT: int = 8708 25 | DEVICE_INFO_PORT: int = 8702 26 | DEVICE_INFO_SRC_PORT1 = 1029 27 | DEVICE_INFO_SRC_PORT2 = 1030 28 | DEVICE_SETTINGS_PORT: int = 8700 29 | MESSAGE_TYPE_VOLUME_LEVELS = 0 30 | 31 | PORTS = [DEVICE_CONTROL_PORT, DEVICE_INFO_PORT, DEVICE_SETTINGS_PORT] 32 | 33 | SUBSCRIPTION_STATUS_BUNDLE_FORMAT = 17 34 | SUBSCRIPTION_STATUS_CHANNEL_FORMAT = 16 35 | SUBSCRIPTION_STATUS_CHANNEL_LATENCY = 26 36 | SUBSCRIPTION_STATUS_CLOCK_DOMAIN = 27 37 | SUBSCRIPTION_STATUS_DYNAMIC = 9 38 | SUBSCRIPTION_STATUS_DYNAMIC_PROTOCOL = 31 39 | SUBSCRIPTION_STATUS_FLAG_NO_ADVERT = 256 40 | SUBSCRIPTION_STATUS_FLAG_NO_DBCP = 512 41 | SUBSCRIPTION_STATUS_HDCP_NEGOTIATION_ERROR = 112 42 | SUBSCRIPTION_STATUS_IDLE = 7 43 | SUBSCRIPTION_STATUS_INVALID_CHANNEL = 32 44 | SUBSCRIPTION_STATUS_INVALID_MSG = 25 45 | SUBSCRIPTION_STATUS_IN_PROGRESS = 8 46 | SUBSCRIPTION_STATUS_MANUAL = 14 47 | SUBSCRIPTION_STATUS_NONE = 0 48 | SUBSCRIPTION_STATUS_NO_CONNECTION = 15 49 | SUBSCRIPTION_STATUS_NO_DATA = 65536 50 | SUBSCRIPTION_STATUS_NO_RX = 18 51 | SUBSCRIPTION_STATUS_NO_TX = 20 52 | SUBSCRIPTION_STATUS_QOS_FAIL_RX = 22 53 | SUBSCRIPTION_STATUS_QOS_FAIL_TX = 23 54 | SUBSCRIPTION_STATUS_RESOLVED = 2 55 | SUBSCRIPTION_STATUS_RESOLVED_NONE = 5 56 | SUBSCRIPTION_STATUS_RESOLVE_FAIL = 3 57 | SUBSCRIPTION_STATUS_RX_FAIL = 19 58 | SUBSCRIPTION_STATUS_RX_LINK_DOWN = 29 59 | SUBSCRIPTION_STATUS_RX_NOT_READY = 36 60 | SUBSCRIPTION_STATUS_RX_UNSUPPORTED_SUB_MODE = 69 61 | SUBSCRIPTION_STATUS_STATIC = 10 62 | SUBSCRIPTION_STATUS_SUBSCRIBE_SELF = 4 63 | SUBSCRIPTION_STATUS_SUBSCRIBE_SELF_POLICY = 34 64 | SUBSCRIPTION_STATUS_SYSTEM_FAIL = 255 65 | SUBSCRIPTION_STATUS_TEMPLATE_FULL = 68 66 | SUBSCRIPTION_STATUS_TEMPLATE_MISMATCH_CONFIG = 67 67 | SUBSCRIPTION_STATUS_TEMPLATE_MISMATCH_DEVICE = 64 68 | SUBSCRIPTION_STATUS_TEMPLATE_MISMATCH_FORMAT = 65 69 | SUBSCRIPTION_STATUS_TEMPLATE_MISSING_CHANNEL = 66 70 | SUBSCRIPTION_STATUS_TX_ACCESS_CONTROL_DENIED = 96 71 | SUBSCRIPTION_STATUS_TX_ACCESS_CONTROL_PENDING = 97 72 | SUBSCRIPTION_STATUS_TX_CHANNEL_ENCRYPTED = 38 73 | SUBSCRIPTION_STATUS_TX_FAIL = 21 74 | SUBSCRIPTION_STATUS_TX_FANOUT_LIMIT_REACHED = 37 75 | SUBSCRIPTION_STATUS_TX_LINK_DOWN = 30 76 | SUBSCRIPTION_STATUS_TX_NOT_READY = 35 77 | SUBSCRIPTION_STATUS_TX_REJECTED_ADDR = 24 78 | SUBSCRIPTION_STATUS_TX_RESPONSE_UNEXPECTED = 39 79 | SUBSCRIPTION_STATUS_TX_SCHEDULER_FAILURE = 33 80 | SUBSCRIPTION_STATUS_TX_UNSUPPORTED_SUB_MODE = 70 81 | SUBSCRIPTION_STATUS_UNRESOLVED = 1 82 | SUBSCRIPTION_STATUS_UNSUPPORTED = 28 83 | 84 | SUBSCRIPTION_STATUSES = [ 85 | SUBSCRIPTION_STATUS_BUNDLE_FORMAT, 86 | SUBSCRIPTION_STATUS_CHANNEL_FORMAT, 87 | SUBSCRIPTION_STATUS_CHANNEL_LATENCY, 88 | SUBSCRIPTION_STATUS_CLOCK_DOMAIN, 89 | SUBSCRIPTION_STATUS_DYNAMIC, 90 | SUBSCRIPTION_STATUS_DYNAMIC_PROTOCOL, 91 | SUBSCRIPTION_STATUS_FLAG_NO_ADVERT, 92 | SUBSCRIPTION_STATUS_FLAG_NO_DBCP, 93 | SUBSCRIPTION_STATUS_HDCP_NEGOTIATION_ERROR, 94 | SUBSCRIPTION_STATUS_IDLE, 95 | SUBSCRIPTION_STATUS_INVALID_CHANNEL, 96 | SUBSCRIPTION_STATUS_INVALID_MSG, 97 | SUBSCRIPTION_STATUS_IN_PROGRESS, 98 | SUBSCRIPTION_STATUS_MANUAL, 99 | SUBSCRIPTION_STATUS_NONE, 100 | SUBSCRIPTION_STATUS_NO_CONNECTION, 101 | SUBSCRIPTION_STATUS_NO_DATA, 102 | SUBSCRIPTION_STATUS_NO_RX, 103 | SUBSCRIPTION_STATUS_NO_TX, 104 | SUBSCRIPTION_STATUS_QOS_FAIL_RX, 105 | SUBSCRIPTION_STATUS_QOS_FAIL_TX, 106 | SUBSCRIPTION_STATUS_RESOLVED, 107 | SUBSCRIPTION_STATUS_RESOLVED_NONE, 108 | SUBSCRIPTION_STATUS_RESOLVE_FAIL, 109 | SUBSCRIPTION_STATUS_RX_FAIL, 110 | SUBSCRIPTION_STATUS_RX_LINK_DOWN, 111 | SUBSCRIPTION_STATUS_RX_NOT_READY, 112 | SUBSCRIPTION_STATUS_RX_UNSUPPORTED_SUB_MODE, 113 | SUBSCRIPTION_STATUS_STATIC, 114 | SUBSCRIPTION_STATUS_SUBSCRIBE_SELF, 115 | SUBSCRIPTION_STATUS_SUBSCRIBE_SELF_POLICY, 116 | SUBSCRIPTION_STATUS_SYSTEM_FAIL, 117 | SUBSCRIPTION_STATUS_TEMPLATE_FULL, 118 | SUBSCRIPTION_STATUS_TEMPLATE_MISMATCH_CONFIG, 119 | SUBSCRIPTION_STATUS_TEMPLATE_MISMATCH_DEVICE, 120 | SUBSCRIPTION_STATUS_TEMPLATE_MISMATCH_FORMAT, 121 | SUBSCRIPTION_STATUS_TEMPLATE_MISSING_CHANNEL, 122 | SUBSCRIPTION_STATUS_TX_ACCESS_CONTROL_DENIED, 123 | SUBSCRIPTION_STATUS_TX_ACCESS_CONTROL_PENDING, 124 | SUBSCRIPTION_STATUS_TX_CHANNEL_ENCRYPTED, 125 | SUBSCRIPTION_STATUS_TX_FAIL, 126 | SUBSCRIPTION_STATUS_TX_FANOUT_LIMIT_REACHED, 127 | SUBSCRIPTION_STATUS_TX_LINK_DOWN, 128 | SUBSCRIPTION_STATUS_TX_NOT_READY, 129 | SUBSCRIPTION_STATUS_TX_REJECTED_ADDR, 130 | SUBSCRIPTION_STATUS_TX_RESPONSE_UNEXPECTED, 131 | SUBSCRIPTION_STATUS_TX_SCHEDULER_FAILURE, 132 | SUBSCRIPTION_STATUS_TX_UNSUPPORTED_SUB_MODE, 133 | SUBSCRIPTION_STATUS_UNRESOLVED, 134 | SUBSCRIPTION_STATUS_UNSUPPORTED, 135 | ] 136 | 137 | SUBSCRIPTION_STATUS_LABELS = { 138 | SUBSCRIPTION_STATUS_NONE: ("none", "No subscription for this channel"), 139 | SUBSCRIPTION_STATUS_FLAG_NO_ADVERT: ("No audio data.",), 140 | SUBSCRIPTION_STATUS_UNRESOLVED: ( 141 | "Subscription unresolved", 142 | "Unresolved", 143 | "cannot find this channel on the network", 144 | ), 145 | SUBSCRIPTION_STATUS_RESOLVED: ( 146 | "Subscription resolved", 147 | "Resolved", 148 | "channel found; preparing to create flow", 149 | ), 150 | SUBSCRIPTION_STATUS_UNRESOLVED: ( 151 | "Unresolved: channel not present", 152 | "Unresolved", 153 | "this channel is not present on the network", 154 | ), 155 | SUBSCRIPTION_STATUS_RESOLVE_FAIL: ( 156 | "Can't resolve subscription", 157 | "Resolve failed", 158 | "received an unexpected error when trying to resolve this channel", 159 | ), 160 | SUBSCRIPTION_STATUS_SUBSCRIBE_SELF: ( 161 | "Subscribed to own signal", 162 | "Connected (self)", 163 | ), 164 | SUBSCRIPTION_STATUS_IDLE: ( 165 | "Subscription idle", 166 | "Flow creation idle", 167 | "Insufficient information to create flow", 168 | ), 169 | SUBSCRIPTION_STATUS_IN_PROGRESS: ( 170 | "Subscription in progress", 171 | "Flow creation in progress", 172 | "communicating with transmitter to create flow", 173 | ), 174 | SUBSCRIPTION_STATUS_DYNAMIC: ("Connected (unicast)",), 175 | SUBSCRIPTION_STATUS_STATIC: ("Connected (multicast)",), 176 | SUBSCRIPTION_STATUS_MANUAL: ("Manually Configured",), 177 | SUBSCRIPTION_STATUS_NO_CONNECTION: ( 178 | "No connection", 179 | "could not communicate with transmitter", 180 | ), 181 | SUBSCRIPTION_STATUS_CHANNEL_FORMAT: ( 182 | "Incorrect channel format", 183 | "source and destination channels do not match", 184 | ), 185 | SUBSCRIPTION_STATUS_BUNDLE_FORMAT: ( 186 | "Incorrect flow format", 187 | "Incorrect multicast flow format", 188 | "flow format incompatible with receiver", 189 | ), 190 | SUBSCRIPTION_STATUS_NO_RX: ( 191 | "No Receive flows", 192 | "No more flows (RX)", 193 | "receiver cannot support any more flows", 194 | "Is receiver subscribed to too many different devices?", 195 | ), 196 | SUBSCRIPTION_STATUS_RX_FAIL: ( 197 | "Receive failure", 198 | "Receiver setup failed", 199 | "unexpected error on receiver", 200 | ), 201 | SUBSCRIPTION_STATUS_NO_TX: ( 202 | "No Transmit flows", 203 | "No more flows (TX)", 204 | "transmitter cannot support any more flows", 205 | "Reduce fan out by unsubscribing receivers or switching to multicast.", 206 | ), 207 | SUBSCRIPTION_STATUS_TX_FAIL: ( 208 | "Transmit failure", 209 | "Transmitter setup failed", 210 | "unexpected error on transmitter", 211 | ), 212 | SUBSCRIPTION_STATUS_QOS_FAIL_RX: ( 213 | "Receive bandwidth exceeded", 214 | "receiver can't reliably support any more inbound flows", 215 | "Reduce number of subscriptions or look for excessive multicast.", 216 | ), 217 | SUBSCRIPTION_STATUS_QOS_FAIL_TX: ( 218 | "Transmit bandwidth exceeded", 219 | "transmitter can't reliably support any more outbound flows", 220 | "Reduce fan out by unsubscribing receivers or switching to multicast.", 221 | ), 222 | SUBSCRIPTION_STATUS_TX_REJECTED_ADDR: ( 223 | "Subscription address rejected by transmitter", 224 | "Transmitter rejected address", 225 | "transmitter can't talk to receiver's address", 226 | "Check for address change on transmitter or receiver.", 227 | ), 228 | SUBSCRIPTION_STATUS_INVALID_MSG: ( 229 | "Subscription message rejected by transmitter", 230 | "Transmitter rejected message", 231 | "transmitter can't understand receiver's request", 232 | ), 233 | SUBSCRIPTION_STATUS_CHANNEL_LATENCY: ( 234 | "No suitable channel latency", 235 | "Incorrect channel latencies", 236 | "source demands more latency than the receiver has available", 237 | ), 238 | SUBSCRIPTION_STATUS_CLOCK_DOMAIN: ( 239 | "Mismatched clock domains", 240 | "The transmitter and receiver are not part of the same clock domain", 241 | ), 242 | SUBSCRIPTION_STATUS_UNSUPPORTED: ( 243 | "Unsupported feature", 244 | "The subscription cannot be completed as it requires features that are not supported on this device", 245 | ), 246 | SUBSCRIPTION_STATUS_RX_LINK_DOWN: ( 247 | "RX link down", 248 | "RX link down", 249 | "The subscription cannot be completed as RX link is down", 250 | ), 251 | SUBSCRIPTION_STATUS_TX_LINK_DOWN: ( 252 | "TX link down", 253 | "The subscription cannot be completed as TX link is down", 254 | ), 255 | SUBSCRIPTION_STATUS_DYNAMIC_PROTOCOL: ("Dynamic Protocol",), 256 | SUBSCRIPTION_STATUS_INVALID_CHANNEL: ( 257 | "Invalid Channel", 258 | "the subscription cannot be completed as channel is invalid", 259 | ), 260 | SUBSCRIPTION_STATUS_TEMPLATE_MISMATCH_DEVICE: ( 261 | "The receive channel's subscription does not match the templates TX device", 262 | "Template mismatch (device)", 263 | ), 264 | SUBSCRIPTION_STATUS_TEMPLATE_MISMATCH_FORMAT: ( 265 | "The receive channel's available audio formats do not match the template's audio format", 266 | "Template mismatch (format)", 267 | ), 268 | SUBSCRIPTION_STATUS_TEMPLATE_MISSING_CHANNEL: ( 269 | "The receive channel's subscription is not a part of the given multicast template", 270 | "Template missing channel", 271 | ), 272 | SUBSCRIPTION_STATUS_TEMPLATE_MISMATCH_CONFIG: ( 273 | "The receive channel's resolved information conflicts with the multicast templates resolved information", 274 | "Template mismatch (config)", 275 | ), 276 | SUBSCRIPTION_STATUS_TEMPLATE_FULL: ( 277 | "The receive channel's template is already full", 278 | "Template full", 279 | ), 280 | SUBSCRIPTION_STATUS_SYSTEM_FAIL: ( 281 | "System failure", 282 | "Incorrect multicast flow format", 283 | "flow format incompatible with receiver", 284 | ), 285 | SUBSCRIPTION_STATUS_TX_SCHEDULER_FAILURE: ( 286 | "TX Scheduler failure", 287 | "This is most often caused by a receiver with < 1ms unicast latency subscribing to a transmitter on a 100MB connection", 288 | ), 289 | SUBSCRIPTION_STATUS_SUBSCRIBE_SELF_POLICY: ( 290 | "Subscription to own signal disallowed by device", 291 | "Policy failure for subscription to self", 292 | "The device does not support local subscriptions for the given transmit and receive channels.", 293 | ), 294 | } 295 | 296 | 297 | REQUEST_DANTE_MODEL = 97 298 | REQUEST_MAKE_MODEL = 193 299 | RESPONSE_DANTE_MODEL = 96 300 | RESPONSE_MAKE_MODEL = 192 301 | 302 | 303 | MESSAGE_TYPE_CHANNEL_COUNTS_QUERY = 4096 304 | MESSAGE_TYPE_DEVICE_CONTROL = 4099 305 | MESSAGE_TYPE_IDENTIFY_DEVICE_QUERY = 4302 306 | MESSAGE_TYPE_NAME_CONTROL = 4097 307 | MESSAGE_TYPE_NAME_QUERY = 4098 308 | MESSAGE_TYPE_RX_CHANNEL_QUERY = 12288 309 | MESSAGE_TYPE_TX_CHANNEL_QUERY = 8192 310 | MESSAGE_TYPE_TX_CHANNEL_FRIENDLY_NAMES_QUERY = 8208 311 | 312 | MESSAGE_TYPE_ACCESS_CONTROL = 177 313 | MESSAGE_TYPE_ACCESS_STATUS = 176 314 | MESSAGE_TYPE_AES67_CONTROL = 4102 315 | MESSAGE_TYPE_AES67_STATUS = 4103 316 | MESSAGE_TYPE_AUDIO_INTERFACE_QUERY = 135 317 | MESSAGE_TYPE_AUDIO_INTERFACE_STATUS = 134 318 | MESSAGE_TYPE_CLEAR_CONFIG_CONTROL = 119 319 | MESSAGE_TYPE_CLEAR_CONFIG_STATUS = 120 320 | MESSAGE_TYPE_CLOCKING_CONTROL = 33 321 | MESSAGE_TYPE_CLOCKING_STATUS = 32 322 | MESSAGE_TYPE_CODEC_CONTROL = 4106 323 | MESSAGE_TYPE_CODEC_STATUS = 4107 324 | MESSAGE_TYPE_CONFIG_CONTROL = 115 325 | MESSAGE_TYPE_DDM_ENROLMENT_CONFIG_CONTROL = 65286 326 | MESSAGE_TYPE_DDM_ENROLMENT_CONFIG_STATUS = 65287 327 | MESSAGE_TYPE_DEVICE_REBOOT = 146 328 | MESSAGE_TYPE_EDK_BOARD_CONTROL = 161 329 | MESSAGE_TYPE_EDK_BOARD_STATUS = 160 330 | MESSAGE_TYPE_ENCODING_CONTROL = 131 331 | MESSAGE_TYPE_ENCODING_STATUS = 130 332 | MESSAGE_TYPE_HAREMOTE_CONTROL = 4097 333 | MESSAGE_TYPE_HAREMOTE_STATUS = 4096 334 | MESSAGE_TYPE_IDENTIFY_QUERY = 99 335 | MESSAGE_TYPE_IDENTIFY_STATUS = 98 336 | MESSAGE_TYPE_IFSTATS_QUERY = 65 337 | MESSAGE_TYPE_IFSTATS_STATUS = 64 338 | MESSAGE_TYPE_IGMP_VERS_CONTROL = 81 339 | MESSAGE_TYPE_IGMP_VERS_STATUS = 80 340 | MESSAGE_TYPE_INTERFACE_CONTROL = 19 341 | MESSAGE_TYPE_INTERFACE_STATUS = 17 342 | MESSAGE_TYPE_LED_QUERY = 209 343 | MESSAGE_TYPE_LED_STATUS = 208 344 | MESSAGE_TYPE_LOCK_QUERY = 4104 345 | MESSAGE_TYPE_LOCK_STATUS = 4105 346 | MESSAGE_TYPE_MANF_VERSIONS_QUERY = 193 347 | MESSAGE_TYPE_MANF_VERSIONS_STATUS = 192 348 | MESSAGE_TYPE_MASTER_QUERY = 35 349 | MESSAGE_TYPE_MASTER_STATUS = 34 350 | MESSAGE_TYPE_METERING_CONTROL = 225 351 | MESSAGE_TYPE_METERING_STATUS = 224 352 | MESSAGE_TYPE_NAME_ID_CONTROL = 39 353 | MESSAGE_TYPE_NAME_ID_STATUS = 38 354 | MESSAGE_TYPE_PROPERTY_CHANGE = 262 355 | MESSAGE_TYPE_ROUTING_DEVICE_CHANGE = 288 356 | MESSAGE_TYPE_ROUTING_READY = 256 357 | MESSAGE_TYPE_RX_CHANNEL_CHANGE = 258 358 | MESSAGE_TYPE_RX_CHANNEL_RX_ERROR_QUERY = 273 359 | MESSAGE_TYPE_RX_CHANNEL_RX_ERROR_STATUS = 272 360 | MESSAGE_TYPE_RX_ERROR_THRESHOLD_CONTROL = 275 361 | MESSAGE_TYPE_RX_ERROR_THRESHOLD_STATUS = 274 362 | MESSAGE_TYPE_RX_FLOW_CHANGE = 261 363 | MESSAGE_TYPE_SAMPLE_RATE_CONTROL = 129 364 | MESSAGE_TYPE_SAMPLE_RATE_PULLUP_CONTROL = 133 365 | MESSAGE_TYPE_SAMPLE_RATE_PULLUP_STATUS = 132 366 | MESSAGE_TYPE_SAMPLE_RATE_STATUS = 128 367 | MESSAGE_TYPE_SERIAL_PORT_CONTROL = 241 368 | MESSAGE_TYPE_SERIAL_PORT_STATUS = 240 369 | MESSAGE_TYPE_SWITCH_VLAN_CONTROL = 21 370 | MESSAGE_TYPE_SWITCH_VLAN_STATUS = 20 371 | MESSAGE_TYPE_SYS_RESET = 144 372 | MESSAGE_TYPE_TOPOLOGY_CHANGE = 16 373 | MESSAGE_TYPE_TX_CHANNEL_CHANGE = 257 374 | MESSAGE_TYPE_TX_FLOW_CHANGE = 260 375 | MESSAGE_TYPE_TX_LABEL_CHANGE = 259 376 | MESSAGE_TYPE_UNICAST_CLOCKING_CONTROL = 37 377 | MESSAGE_TYPE_UNICAST_CLOCKING_STATUS = 36 378 | MESSAGE_TYPE_UPGRADE_CONTROL = 113 379 | MESSAGE_TYPE_UPGRADE_STATUS = 112 380 | MESSAGE_TYPE_VERSIONS_QUERY = 97 381 | MESSAGE_TYPE_VERSIONS_STATUS = 96 382 | 383 | MESSAGE_TYPE_STATUS = [ 384 | MESSAGE_TYPE_ACCESS_STATUS, 385 | MESSAGE_TYPE_AES67_STATUS, 386 | MESSAGE_TYPE_AUDIO_INTERFACE_STATUS, 387 | MESSAGE_TYPE_CLEAR_CONFIG_STATUS, 388 | MESSAGE_TYPE_CLOCKING_STATUS, 389 | MESSAGE_TYPE_CODEC_STATUS, 390 | MESSAGE_TYPE_DDM_ENROLMENT_CONFIG_STATUS, 391 | MESSAGE_TYPE_EDK_BOARD_STATUS, 392 | MESSAGE_TYPE_ENCODING_STATUS, 393 | MESSAGE_TYPE_HAREMOTE_STATUS, 394 | MESSAGE_TYPE_IDENTIFY_STATUS, 395 | MESSAGE_TYPE_IFSTATS_STATUS, 396 | MESSAGE_TYPE_IGMP_VERS_STATUS, 397 | MESSAGE_TYPE_INTERFACE_STATUS, 398 | MESSAGE_TYPE_LED_STATUS, 399 | MESSAGE_TYPE_LOCK_STATUS, 400 | MESSAGE_TYPE_MANF_VERSIONS_STATUS, 401 | MESSAGE_TYPE_MASTER_STATUS, 402 | MESSAGE_TYPE_METERING_STATUS, 403 | MESSAGE_TYPE_NAME_ID_STATUS, 404 | MESSAGE_TYPE_RX_CHANNEL_RX_ERROR_STATUS, 405 | MESSAGE_TYPE_RX_ERROR_THRESHOLD_STATUS, 406 | MESSAGE_TYPE_SAMPLE_RATE_PULLUP_STATUS, 407 | MESSAGE_TYPE_SAMPLE_RATE_STATUS, 408 | MESSAGE_TYPE_SERIAL_PORT_STATUS, 409 | MESSAGE_TYPE_SWITCH_VLAN_STATUS, 410 | MESSAGE_TYPE_UNICAST_CLOCKING_STATUS, 411 | MESSAGE_TYPE_UPGRADE_STATUS, 412 | MESSAGE_TYPE_VERSIONS_STATUS, 413 | ] 414 | 415 | MESSAGE_TYPE_CONTROL = [ 416 | MESSAGE_TYPE_ACCESS_CONTROL, 417 | MESSAGE_TYPE_AES67_CONTROL, 418 | MESSAGE_TYPE_CLEAR_CONFIG_CONTROL, 419 | MESSAGE_TYPE_CLOCKING_CONTROL, 420 | MESSAGE_TYPE_CODEC_CONTROL, 421 | MESSAGE_TYPE_CONFIG_CONTROL, 422 | MESSAGE_TYPE_DDM_ENROLMENT_CONFIG_CONTROL, 423 | MESSAGE_TYPE_EDK_BOARD_CONTROL, 424 | MESSAGE_TYPE_ENCODING_CONTROL, 425 | MESSAGE_TYPE_HAREMOTE_CONTROL, 426 | MESSAGE_TYPE_IGMP_VERS_CONTROL, 427 | MESSAGE_TYPE_INTERFACE_CONTROL, 428 | MESSAGE_TYPE_METERING_CONTROL, 429 | MESSAGE_TYPE_NAME_ID_CONTROL, 430 | MESSAGE_TYPE_RX_ERROR_THRESHOLD_CONTROL, 431 | MESSAGE_TYPE_SAMPLE_RATE_CONTROL, 432 | MESSAGE_TYPE_SAMPLE_RATE_PULLUP_CONTROL, 433 | MESSAGE_TYPE_SERIAL_PORT_CONTROL, 434 | MESSAGE_TYPE_SWITCH_VLAN_CONTROL, 435 | MESSAGE_TYPE_UNICAST_CLOCKING_CONTROL, 436 | MESSAGE_TYPE_UPGRADE_CONTROL, 437 | ] 438 | 439 | MESSAGE_TYPE_QUERY = [ 440 | MESSAGE_TYPE_AUDIO_INTERFACE_QUERY, 441 | MESSAGE_TYPE_IDENTIFY_QUERY, 442 | MESSAGE_TYPE_IFSTATS_QUERY, 443 | MESSAGE_TYPE_LED_QUERY, 444 | MESSAGE_TYPE_LOCK_QUERY, 445 | MESSAGE_TYPE_MANF_VERSIONS_QUERY, 446 | MESSAGE_TYPE_MASTER_QUERY, 447 | MESSAGE_TYPE_RX_CHANNEL_RX_ERROR_QUERY, 448 | MESSAGE_TYPE_VERSIONS_QUERY, 449 | ] 450 | 451 | MESSAGE_TYPE_CHANGE = [ 452 | MESSAGE_TYPE_PROPERTY_CHANGE, 453 | MESSAGE_TYPE_ROUTING_DEVICE_CHANGE, 454 | MESSAGE_TYPE_RX_CHANNEL_CHANGE, 455 | MESSAGE_TYPE_RX_FLOW_CHANGE, 456 | MESSAGE_TYPE_TOPOLOGY_CHANGE, 457 | MESSAGE_TYPE_TX_CHANNEL_CHANGE, 458 | MESSAGE_TYPE_TX_FLOW_CHANGE, 459 | MESSAGE_TYPE_TX_LABEL_CHANGE, 460 | ] 461 | 462 | MESSAGE_TYPE_MONITORING_STRINGS = {MESSAGE_TYPE_VOLUME_LEVELS: "Volume Levels"} 463 | 464 | MESSAGE_TYPE_STRINGS = { 465 | MESSAGE_TYPE_ACCESS_CONTROL: "Access Control", 466 | MESSAGE_TYPE_ACCESS_STATUS: "Access Status", 467 | MESSAGE_TYPE_AES67_STATUS: "AES67 Status", 468 | MESSAGE_TYPE_AUDIO_INTERFACE_QUERY: "Audio Interface Query", 469 | MESSAGE_TYPE_AUDIO_INTERFACE_STATUS: "Audio Interface Status", 470 | MESSAGE_TYPE_CLEAR_CONFIG_STATUS: "Clear Config Status", 471 | MESSAGE_TYPE_CLOCKING_CONTROL: "Clocking Control", 472 | MESSAGE_TYPE_CLOCKING_STATUS: "Clocking Status", 473 | MESSAGE_TYPE_CODEC_CONTROL: "Codec Control", 474 | MESSAGE_TYPE_CODEC_STATUS: "Codec Status", 475 | MESSAGE_TYPE_CONFIG_CONTROL: "Config Control", 476 | MESSAGE_TYPE_DEVICE_REBOOT: "Device Reboot", 477 | MESSAGE_TYPE_EDK_BOARD_CONTROL: "EDK Board Control", 478 | MESSAGE_TYPE_EDK_BOARD_STATUS: "EDK Board Status", 479 | MESSAGE_TYPE_ENCODING_CONTROL: "Encoding Control", 480 | MESSAGE_TYPE_ENCODING_STATUS: "Encoding Status", 481 | MESSAGE_TYPE_IDENTIFY_QUERY: "Identify Query", 482 | MESSAGE_TYPE_IDENTIFY_STATUS: "Identify Status", 483 | MESSAGE_TYPE_IFSTATS_QUERY: "Ifstats query", 484 | MESSAGE_TYPE_IFSTATS_STATUS: "Ifstats status", 485 | MESSAGE_TYPE_IGMP_VERS_CONTROL: "IGMP Version Control", 486 | MESSAGE_TYPE_IGMP_VERS_STATUS: "IGMP Version Status", 487 | MESSAGE_TYPE_INTERFACE_CONTROL: "Interface Control", 488 | MESSAGE_TYPE_INTERFACE_STATUS: "Interface Status", 489 | MESSAGE_TYPE_LED_QUERY: "LED Query", 490 | MESSAGE_TYPE_LED_STATUS: "LED Status", 491 | MESSAGE_TYPE_LOCK_QUERY: "Lock Query", 492 | MESSAGE_TYPE_LOCK_STATUS: "Lock Status", 493 | MESSAGE_TYPE_MASTER_QUERY: "Master Query", 494 | MESSAGE_TYPE_MASTER_STATUS: "Master Status", 495 | MESSAGE_TYPE_METERING_CONTROL: "Metering Control", 496 | MESSAGE_TYPE_METERING_STATUS: "Metering Status", 497 | MESSAGE_TYPE_NAME_ID_CONTROL: "Name Id Query", 498 | MESSAGE_TYPE_NAME_ID_STATUS: "Name Id Status", 499 | MESSAGE_TYPE_PROPERTY_CHANGE: "Property change", 500 | MESSAGE_TYPE_ROUTING_DEVICE_CHANGE: "Routing Device Change", 501 | MESSAGE_TYPE_ROUTING_READY: "Routing Ready", 502 | MESSAGE_TYPE_RX_CHANNEL_CHANGE: "Rx Channel Change", 503 | MESSAGE_TYPE_RX_CHANNEL_RX_ERROR_QUERY: "Rx Channel Rx Error Control", 504 | MESSAGE_TYPE_RX_CHANNEL_RX_ERROR_STATUS: "Rx Channel Rx Error Status", 505 | MESSAGE_TYPE_RX_ERROR_THRESHOLD_CONTROL: "Rx Error Threshold Control", 506 | MESSAGE_TYPE_RX_ERROR_THRESHOLD_STATUS: "Rx Error Threshold Status", 507 | MESSAGE_TYPE_RX_FLOW_CHANGE: "Rx Flow Change", 508 | MESSAGE_TYPE_SAMPLE_RATE_CONTROL: "Sample Rate Control", 509 | MESSAGE_TYPE_SAMPLE_RATE_PULLUP_CONTROL: "Sample Rate Pullup Control", 510 | MESSAGE_TYPE_SAMPLE_RATE_PULLUP_STATUS: "Sample Rate Pullup Status", 511 | MESSAGE_TYPE_SAMPLE_RATE_STATUS: "Sample Rate Status", 512 | MESSAGE_TYPE_SERIAL_PORT_CONTROL: "Serial Port Control", 513 | MESSAGE_TYPE_SERIAL_PORT_STATUS: "Serial Port Status", 514 | MESSAGE_TYPE_SWITCH_VLAN_STATUS: "VLAN Status", 515 | MESSAGE_TYPE_SYS_RESET: "Sys Reset", 516 | MESSAGE_TYPE_TOPOLOGY_CHANGE: "Topology Change", 517 | MESSAGE_TYPE_TX_CHANNEL_CHANGE: "Tx Channel Change", 518 | MESSAGE_TYPE_TX_FLOW_CHANGE: "Tx Flow Change", 519 | MESSAGE_TYPE_TX_LABEL_CHANGE: "Tx Label Change", 520 | MESSAGE_TYPE_UNICAST_CLOCKING_CONTROL: "Unicast Clocking Control", 521 | MESSAGE_TYPE_UNICAST_CLOCKING_STATUS: "Unicast Clocking Status", 522 | MESSAGE_TYPE_UPGRADE_CONTROL: "Upgrade Control", 523 | MESSAGE_TYPE_UPGRADE_STATUS: "Upgrade Status", 524 | REQUEST_DANTE_MODEL: "Version Query", 525 | REQUEST_MAKE_MODEL: "Manf Versions Query", 526 | RESPONSE_DANTE_MODEL: "Versions Status", 527 | RESPONSE_MAKE_MODEL: "Manf Versions Status", 528 | } 529 | 530 | MESSAGE_TYPES = [ 531 | MESSAGE_TYPE_ACCESS_CONTROL, 532 | MESSAGE_TYPE_ACCESS_STATUS, 533 | MESSAGE_TYPE_AES67_CONTROL, 534 | MESSAGE_TYPE_AES67_STATUS, 535 | MESSAGE_TYPE_AUDIO_INTERFACE_QUERY, 536 | MESSAGE_TYPE_AUDIO_INTERFACE_STATUS, 537 | MESSAGE_TYPE_CLEAR_CONFIG_CONTROL, 538 | MESSAGE_TYPE_CLEAR_CONFIG_STATUS, 539 | MESSAGE_TYPE_CLOCKING_CONTROL, 540 | MESSAGE_TYPE_CLOCKING_STATUS, 541 | MESSAGE_TYPE_CODEC_CONTROL, 542 | MESSAGE_TYPE_CODEC_STATUS, 543 | MESSAGE_TYPE_CONFIG_CONTROL, 544 | MESSAGE_TYPE_DDM_ENROLMENT_CONFIG_CONTROL, 545 | MESSAGE_TYPE_DDM_ENROLMENT_CONFIG_STATUS, 546 | MESSAGE_TYPE_DEVICE_REBOOT, 547 | MESSAGE_TYPE_EDK_BOARD_CONTROL, 548 | MESSAGE_TYPE_EDK_BOARD_STATUS, 549 | MESSAGE_TYPE_ENCODING_CONTROL, 550 | MESSAGE_TYPE_ENCODING_STATUS, 551 | MESSAGE_TYPE_HAREMOTE_CONTROL, 552 | MESSAGE_TYPE_HAREMOTE_STATUS, 553 | MESSAGE_TYPE_IDENTIFY_QUERY, 554 | MESSAGE_TYPE_IDENTIFY_STATUS, 555 | MESSAGE_TYPE_IFSTATS_QUERY, 556 | MESSAGE_TYPE_IFSTATS_STATUS, 557 | MESSAGE_TYPE_IGMP_VERS_CONTROL, 558 | MESSAGE_TYPE_IGMP_VERS_STATUS, 559 | MESSAGE_TYPE_INTERFACE_CONTROL, 560 | MESSAGE_TYPE_INTERFACE_STATUS, 561 | MESSAGE_TYPE_LED_QUERY, 562 | MESSAGE_TYPE_LED_STATUS, 563 | MESSAGE_TYPE_LOCK_QUERY, 564 | MESSAGE_TYPE_LOCK_STATUS, 565 | MESSAGE_TYPE_MANF_VERSIONS_QUERY, 566 | MESSAGE_TYPE_MANF_VERSIONS_STATUS, 567 | MESSAGE_TYPE_MASTER_QUERY, 568 | MESSAGE_TYPE_MASTER_STATUS, 569 | MESSAGE_TYPE_METERING_CONTROL, 570 | MESSAGE_TYPE_METERING_STATUS, 571 | MESSAGE_TYPE_NAME_ID_CONTROL, 572 | MESSAGE_TYPE_NAME_ID_STATUS, 573 | MESSAGE_TYPE_PROPERTY_CHANGE, 574 | MESSAGE_TYPE_ROUTING_DEVICE_CHANGE, 575 | MESSAGE_TYPE_ROUTING_READY, 576 | MESSAGE_TYPE_RX_CHANNEL_CHANGE, 577 | MESSAGE_TYPE_RX_CHANNEL_RX_ERROR_QUERY, 578 | MESSAGE_TYPE_RX_CHANNEL_RX_ERROR_STATUS, 579 | MESSAGE_TYPE_RX_ERROR_THRESHOLD_CONTROL, 580 | MESSAGE_TYPE_RX_ERROR_THRESHOLD_STATUS, 581 | MESSAGE_TYPE_RX_FLOW_CHANGE, 582 | MESSAGE_TYPE_SAMPLE_RATE_CONTROL, 583 | MESSAGE_TYPE_SAMPLE_RATE_PULLUP_CONTROL, 584 | MESSAGE_TYPE_SAMPLE_RATE_PULLUP_STATUS, 585 | MESSAGE_TYPE_SAMPLE_RATE_STATUS, 586 | MESSAGE_TYPE_SERIAL_PORT_CONTROL, 587 | MESSAGE_TYPE_SERIAL_PORT_STATUS, 588 | MESSAGE_TYPE_SWITCH_VLAN_CONTROL, 589 | MESSAGE_TYPE_SWITCH_VLAN_STATUS, 590 | MESSAGE_TYPE_SYS_RESET, 591 | MESSAGE_TYPE_TOPOLOGY_CHANGE, 592 | MESSAGE_TYPE_TX_CHANNEL_CHANGE, 593 | MESSAGE_TYPE_TX_FLOW_CHANGE, 594 | MESSAGE_TYPE_TX_LABEL_CHANGE, 595 | MESSAGE_TYPE_UNICAST_CLOCKING_CONTROL, 596 | MESSAGE_TYPE_UNICAST_CLOCKING_STATUS, 597 | MESSAGE_TYPE_UPGRADE_CONTROL, 598 | MESSAGE_TYPE_UPGRADE_STATUS, 599 | MESSAGE_TYPE_VERSIONS_QUERY, 600 | MESSAGE_TYPE_VERSIONS_STATUS, 601 | ] 602 | -------------------------------------------------------------------------------- /netaudio/dante/control.py: -------------------------------------------------------------------------------- 1 | from twisted.internet.protocol import DatagramProtocol 2 | 3 | 4 | class DanteControl(DatagramProtocol): 5 | def __init__(self, host, port): 6 | self.host = host 7 | self.port = port 8 | 9 | def startProtocol(self): 10 | self.transport.connect(self.host, self.port) 11 | 12 | def sendMessage(self, data): 13 | self.transport.write(data) 14 | 15 | def datagramReceived(self, datagram, addr): 16 | pass 17 | -------------------------------------------------------------------------------- /netaudio/dante/device.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import ipaddress 3 | import logging 4 | import pprint 5 | import random 6 | import socket 7 | import traceback 8 | 9 | from netaudio.dante.channel import DanteChannel 10 | from netaudio.dante.subscription import DanteSubscription 11 | 12 | from netaudio.dante.const import ( 13 | DEVICE_CONTROL_PORT, 14 | DEVICE_SETTINGS_PORT, 15 | FEATURE_VOLUME_UNSUPPORTED, 16 | PORTS, 17 | SERVICE_ARC, 18 | SERVICE_CHAN, 19 | ) 20 | 21 | logger = logging.getLogger("netaudio") 22 | sockets = {} 23 | 24 | 25 | class DanteDevice: 26 | def __init__(self, server_name=""): 27 | self._dante_model = "" 28 | self._dante_model_id = "" 29 | self._error = None 30 | self._ipv4 = None 31 | self._latency = None 32 | self._mac_address = None 33 | self._manufacturer = "" 34 | self._model = "" 35 | self._model_id = "" 36 | self._name = "" 37 | self._rx_channels = {} 38 | self._rx_count = None 39 | self._rx_count_raw = None 40 | self._sample_rate = None 41 | self._server_name = server_name 42 | self._services = {} 43 | self._sockets = {} 44 | self._software = None 45 | self._subscriptions = [] 46 | self._tx_channels = {} 47 | self._tx_count = None 48 | self._tx_count_raw = None 49 | 50 | def __str__(self): 51 | return f"{self.name}" 52 | 53 | def dante_command_new(self, command, control): 54 | response = None 55 | 56 | binary_str = codecs.decode(command, "hex") 57 | response = control.sendMessage(binary_str) 58 | 59 | return response 60 | 61 | async def dante_send_command(self, command, service_type=None, port=None): 62 | sock = None 63 | 64 | if service_type: 65 | service = self.get_service(service_type) 66 | sock = self.sockets[service["port"]] 67 | 68 | if port: 69 | sock = self.sockets[port] 70 | 71 | binary_str = codecs.decode(command, "hex") 72 | 73 | try: 74 | sock.send(binary_str) 75 | except Exception as e: 76 | print(e) 77 | traceback.print_exc() 78 | 79 | async def dante_command(self, command, service_type=None, port=None): 80 | response = None 81 | sock = None 82 | 83 | if service_type: 84 | service = self.get_service(service_type) 85 | 86 | if service and service["port"] and service["port"] in self.sockets: 87 | sock = self.sockets[service["port"]] 88 | 89 | if port: 90 | sock = self.sockets[port] 91 | 92 | if not sock: 93 | return 94 | 95 | binary_str = codecs.decode(command, "hex") 96 | 97 | try: 98 | sock.send(binary_str) 99 | response = sock.recvfrom(2048)[0] 100 | except TimeoutError: 101 | pass 102 | 103 | return response 104 | 105 | async def set_channel_name(self, channel_type, channel_number, new_channel_name): 106 | response = await self.dante_command( 107 | *self.command_set_channel_name( 108 | channel_type, channel_number, new_channel_name 109 | ) 110 | ) 111 | 112 | return response 113 | 114 | async def identify(self): 115 | command_identify = self.command_identify() 116 | response = await self.dante_command(*command_identify) 117 | 118 | return response 119 | 120 | async def set_latency(self, latency): 121 | response = await self.dante_command(*self.command_set_latency(latency)) 122 | 123 | return response 124 | 125 | async def set_gain_level(self, channel_number, gain_level, device_type): 126 | response = await self.dante_command( 127 | *self.command_set_gain_level(channel_number, gain_level, device_type) 128 | ) 129 | 130 | return response 131 | 132 | async def enable_aes67(self, is_enabled: bool): 133 | command_enable_aes67 = self.command_enable_aes67(is_enabled=is_enabled) 134 | response = await self.dante_command(*command_enable_aes67) 135 | 136 | return response 137 | 138 | async def set_encoding(self, encoding): 139 | response = await self.dante_command(*self.command_set_encoding(encoding)) 140 | 141 | return response 142 | 143 | async def set_sample_rate(self, sample_rate): 144 | response = await self.dante_command(*self.command_set_sample_rate(sample_rate)) 145 | 146 | return response 147 | 148 | async def add_subscription(self, rx_channel, tx_channel, tx_device): 149 | response = await self.dante_command( 150 | *self.command_add_subscription( 151 | rx_channel.number, tx_channel.name, tx_device.name 152 | ) 153 | ) 154 | 155 | return response 156 | 157 | async def remove_subscription(self, rx_channel): 158 | response = await self.dante_command( 159 | *self.command_remove_subscription(rx_channel.number) 160 | ) 161 | 162 | return response 163 | 164 | async def reset_channel_name(self, channel_type, channel_number): 165 | response = await self.dante_command( 166 | *self.command_reset_channel_name(channel_type, channel_number) 167 | ) 168 | 169 | return response 170 | 171 | async def set_name(self, name): 172 | response = await self.dante_command(*self.command_set_name(name)) 173 | 174 | return response 175 | 176 | async def reset_name(self): 177 | response = await self.dante_command(*self.command_reset_name()) 178 | 179 | return response 180 | 181 | def get_service(self, service_type): 182 | service = None 183 | 184 | try: 185 | service = next( 186 | filter( 187 | lambda x: x 188 | and x[1] 189 | and "type" in x[1] 190 | and x[1]["type"] == service_type, 191 | self.services.items(), 192 | ) 193 | )[1] 194 | except Exception as e: 195 | logger.warning(f"Failed to get a service by type. {e}") 196 | self.error = e 197 | 198 | return service 199 | 200 | # @on("init") 201 | # def event_handler(self, *args, **kwargs): 202 | # task_name = kwargs["task_name"] 203 | # self.tasks.remove(task_name) 204 | 205 | # if len(self.tasks) == 0: 206 | # self.initialized = True 207 | # ee.emit("init_check") 208 | 209 | # @on("dante_model_info") 210 | # def event_handler(self, *args, **kwargs): 211 | # model = kwargs["model"] 212 | # model_id = kwargs["model_id"] 213 | 214 | # self.dante_model = model 215 | # self.dante_model_id = model_id 216 | # # self.event_emitter.emit('init', task_name=TASK_GET_DANTE_MODEL_INFO) 217 | 218 | # @on("parse_dante_model_info") 219 | # def event_handler(self, *args, **kwargs): 220 | # addr = kwargs["addr"] 221 | # data = kwargs["data"] 222 | # mac = kwargs["mac"] 223 | 224 | # ipv4 = addr[0] 225 | 226 | # model = data[88:].partition(b"\x00")[0].decode("utf-8") 227 | # model_id = data[43:].partition(b"\x00")[0].decode("utf-8").replace("\u0003", "") 228 | 229 | # self.event_emitter.emit( 230 | # "dante_model_info", model_id=model_id, model=model, ipv4=ipv4, mac=mac 231 | # ) 232 | 233 | # @on("device_make_model_info") 234 | # def event_handler(self, *args, **kwargs): 235 | # manufacturer = kwargs["manufacturer"] 236 | # model = kwargs["model"] 237 | 238 | # self.manufacturer = manufacturer 239 | # self.model = model 240 | # # self.event_emitter.emit('init', task_name=TASK_GET_MODEL_INFO) 241 | 242 | # @on("parse_device_make_model_info") 243 | # def event_handler(self, *args, **kwargs): 244 | # addr = kwargs["addr"] 245 | # data = kwargs["data"] 246 | # mac = kwargs["mac"] 247 | 248 | # ipv4 = addr[0] 249 | 250 | # manufacturer = data[76:].partition(b"\x00")[0].decode("utf-8") 251 | # model = data[204:].partition(b"\x00")[0].decode("utf-8") 252 | 253 | # self.event_emitter.emit( 254 | # "device_make_model_info", 255 | # manufacturer=manufacturer, 256 | # model=model, 257 | # ipv4=ipv4, 258 | # mac=mac, 259 | # ) 260 | 261 | async def get_controls(self): 262 | try: 263 | for _, service in self.services.items(): 264 | if service["type"] == SERVICE_CHAN: 265 | continue 266 | 267 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 268 | sock.bind(("", 0)) 269 | sock.settimeout(1) 270 | sock.connect((str(self.ipv4), service["port"])) 271 | self.sockets[service["port"]] = sock 272 | 273 | for port in PORTS: 274 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 275 | sock.bind(("", 0)) 276 | sock.settimeout(0.01) 277 | sock.connect((str(self.ipv4), port)) 278 | self.sockets[port] = sock 279 | except Exception as e: 280 | self.error = e 281 | print(e) 282 | traceback.print_exc() 283 | 284 | try: 285 | if not self.name: 286 | response = await self.dante_command(*self.command_device_name()) 287 | 288 | if response: 289 | self.name = response[10:-1].decode("ascii") 290 | else: 291 | logger.warning("Failed to get Dante device name") 292 | 293 | # get reported rx/tx channel counts 294 | if self._rx_count is None or self._tx_count is None: 295 | channel_count = await self.dante_command(*self.command_channel_count()) 296 | if channel_count: 297 | self.rx_count_raw = self.rx_count = int.from_bytes( 298 | channel_count[15:16], "big" 299 | ) 300 | self.tx_count_raw = self.tx_count = int.from_bytes( 301 | channel_count[13:14], "big" 302 | ) 303 | else: 304 | logger.warning("Failed to get Dante channel counts") 305 | 306 | if not self.tx_channels and self.tx_count: 307 | await self.get_tx_channels() 308 | 309 | if not self.rx_channels and self.rx_count: 310 | await self.get_rx_channels() 311 | 312 | self.error = None 313 | except Exception as e: 314 | self.error = e 315 | print(e) 316 | traceback.print_exc() 317 | 318 | def parse_volume(self, bytes_volume): 319 | rx_channels = bytes_volume[-1 - self.rx_count_raw : -1] 320 | tx_channels = bytes_volume[ 321 | -1 - self.rx_count_raw - self.tx_count_raw : -1 - self.rx_count_raw 322 | ] 323 | 324 | try: 325 | for _, channel in self.tx_channels.items(): 326 | channel.volume = tx_channels[channel.number - 1] 327 | 328 | for _, channel in self.rx_channels.items(): 329 | channel.volume = rx_channels[channel.number - 1] 330 | 331 | except Exception as e: 332 | print(e) 333 | traceback.print_exc() 334 | 335 | async def get_volume(self, ipv4, mac, port): 336 | try: 337 | if self.software or (self.model_id in FEATURE_VOLUME_UNSUPPORTED): 338 | return 339 | 340 | if port in sockets: 341 | sock = sockets[port] 342 | else: 343 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 344 | sock.settimeout(0.1) 345 | sock.bind((str(ipv4), port)) 346 | sockets[port] = sock 347 | 348 | volume_start = await self.dante_command( 349 | *self.command_volume_start(self.name, ipv4, mac, port) 350 | ) 351 | 352 | if volume_start[15:16] == b"\xff": 353 | logger.debug(f"Volume level command is unsupported on {self.name}") 354 | 355 | return 356 | 357 | while True: 358 | try: 359 | data, addr = sock.recvfrom(2048) 360 | 361 | if addr[0] == str(self.ipv4): 362 | await self.dante_send_command( 363 | *self.command_volume_stop(self.name, ipv4, mac, port) 364 | ) 365 | self.parse_volume(data) 366 | 367 | break 368 | except socket.timeout: 369 | break 370 | except Exception as e: 371 | print(e) 372 | traceback.print_exc() 373 | break 374 | 375 | except Exception as e: 376 | traceback.print_exc() 377 | print(e) 378 | 379 | async def get_rx_channels(self): 380 | rx_channels = {} 381 | subscriptions = [] 382 | 383 | try: 384 | for page in range(0, max(int(self.rx_count / 16), 1)): 385 | receivers = await self.dante_command(*self.command_receivers(page)) 386 | hex_rx_response = receivers.hex() 387 | 388 | for index in range(0, min(self.rx_count, 16)): 389 | n = 4 390 | str1 = hex_rx_response[(24 + (index * 40)) : (56 + (index * 40))] 391 | channel = [str1[i : i + n] for i in range(0, len(str1), n)] 392 | 393 | if channel: 394 | channel_number = int(channel[0], 16) 395 | channel_offset = channel[3] 396 | device_offset = channel[4] 397 | rx_channel_offset = channel[5] 398 | rx_channel_status_code = int(channel[6], 16) 399 | subscription_status_code = int(channel[7], 16) 400 | 401 | rx_channel_name = self.get_label( 402 | hex_rx_response, rx_channel_offset 403 | ) 404 | 405 | tx_device_name = self.get_label(hex_rx_response, device_offset) 406 | 407 | if not channel_offset == "0000": 408 | tx_channel_name = self.get_label( 409 | hex_rx_response, channel_offset 410 | ) 411 | else: 412 | tx_channel_name = rx_channel_name 413 | 414 | if index == 0 and not device_offset == "0000": 415 | o1 = (int(channel[2], 16) * 2) + 2 416 | o2 = o1 + 6 417 | sample_rate = int(hex_rx_response[o1:o2], 16) 418 | 419 | if sample_rate: 420 | self.sample_rate = sample_rate 421 | 422 | subscription = DanteSubscription() 423 | rx_channel = DanteChannel() 424 | 425 | rx_channel.channel_type = "rx" 426 | rx_channel.device = self 427 | rx_channel.name = rx_channel_name 428 | rx_channel.number = channel_number 429 | rx_channel.status_code = rx_channel_status_code 430 | 431 | rx_channels[channel_number] = rx_channel 432 | 433 | subscription.rx_channel_name = rx_channel_name 434 | subscription.rx_device_name = self.name 435 | subscription.tx_channel_name = tx_channel_name 436 | subscription.status_code = subscription_status_code 437 | subscription.rx_channel_status_code = rx_channel_status_code 438 | 439 | if tx_device_name == ".": 440 | subscription.tx_device_name = self.name 441 | else: 442 | subscription.tx_device_name = tx_device_name 443 | 444 | subscriptions.append(subscription) 445 | except Exception as e: 446 | self.error = e 447 | print(e) 448 | traceback.print_exc() 449 | 450 | self.rx_channels = rx_channels 451 | self.subscriptions = subscriptions 452 | 453 | async def get_tx_channels(self): 454 | tx_channels = {} 455 | tx_friendly_channel_names = {} 456 | 457 | try: 458 | for page in range(0, max(1, int(self.tx_count / 16)), 2): 459 | response = await self.dante_command( 460 | *self.command_transmitters(page, friendly_names=True) 461 | ) 462 | tx_friendly_names = response.hex() 463 | 464 | for index in range(0, min(self.tx_count, 32)): 465 | str1 = tx_friendly_names[(24 + (index * 12)) : (36 + (index * 12))] 466 | n = 4 467 | channel = [str1[i : i + 4] for i in range(0, len(str1), n)] 468 | # channel_index = int(channel[0], 16) 469 | channel_number = int(channel[1], 16) 470 | channel_offset = channel[2] 471 | tx_channel_friendly_name = self.get_label( 472 | tx_friendly_names, channel_offset 473 | ) 474 | 475 | if tx_channel_friendly_name: 476 | tx_friendly_channel_names[channel_number] = ( 477 | tx_channel_friendly_name 478 | ) 479 | 480 | for page in range(0, max(1, int(self.tx_count / 16)), 2): 481 | response = await self.dante_command( 482 | *self.command_transmitters(page, friendly_names=False) 483 | ) 484 | transmitters = response.hex() 485 | 486 | has_disabled_channels = False 487 | 488 | # TODO: Find the sample rate in the response instead of relying on it being already set from elsewhere 489 | if self.sample_rate: 490 | has_disabled_channels = ( 491 | transmitters.count(f"{self.sample_rate:06x}") == 2 492 | ) 493 | 494 | first_channel = [] 495 | 496 | for index in range(0, min(self.tx_count, 32)): 497 | str1 = transmitters[(24 + (index * 16)) : (40 + (index * 16))] 498 | n = 4 499 | channel = [str1[i : i + 4] for i in range(0, len(str1), n)] 500 | 501 | if index == 0: 502 | first_channel = channel 503 | 504 | if channel: 505 | o1 = (int(channel[2], 16) * 2) + 2 506 | o2 = o1 + 6 507 | sample_rate_hex = transmitters[o1:o2] 508 | 509 | if sample_rate_hex != "000000": 510 | self.sample_rate = int(sample_rate_hex, 16) 511 | 512 | channel_number = int(channel[0], 16) 513 | # channel_status = channel[1][2:] 514 | channel_group = channel[2] 515 | channel_offset = channel[3] 516 | 517 | # channel_enabled = channel_group == first_channel[2] 518 | channel_disabled = channel_group != first_channel[2] 519 | 520 | if channel_disabled: 521 | break 522 | 523 | tx_channel_name = self.get_label(transmitters, channel_offset) 524 | 525 | tx_channel = DanteChannel() 526 | tx_channel.channel_type = "tx" 527 | tx_channel.number = channel_number 528 | tx_channel.device = self 529 | tx_channel.name = tx_channel_name 530 | 531 | if channel_number in tx_friendly_channel_names: 532 | tx_channel.friendly_name = tx_friendly_channel_names[ 533 | channel_number 534 | ] 535 | 536 | tx_channels[channel_number] = tx_channel 537 | 538 | if has_disabled_channels: 539 | break 540 | 541 | except Exception as e: 542 | self.error = e 543 | print(e) 544 | traceback.print_exc() 545 | 546 | self.tx_channels = tx_channels 547 | 548 | @property 549 | def ipv4(self): 550 | return self._ipv4 551 | 552 | @ipv4.setter 553 | def ipv4(self, ipv4): 554 | self._ipv4 = ipaddress.ip_address(ipv4) 555 | 556 | @property 557 | def dante_model(self): 558 | return self._dante_model 559 | 560 | @dante_model.setter 561 | def dante_model(self, dante_model): 562 | self._dante_model = dante_model 563 | 564 | @property 565 | def dante_model_id(self): 566 | return self._dante_model_id 567 | 568 | @dante_model_id.setter 569 | def dante_model_id(self, dante_model_id): 570 | self._dante_model_id = dante_model_id 571 | 572 | @property 573 | def model(self): 574 | return self._model 575 | 576 | @model.setter 577 | def model(self, model): 578 | self._model = model 579 | 580 | @property 581 | def model_id(self): 582 | return self._model_id 583 | 584 | @model_id.setter 585 | def model_id(self, model_id): 586 | self._model_id = model_id 587 | 588 | @property 589 | def latency(self): 590 | return self._latency 591 | 592 | @latency.setter 593 | def latency(self, latency): 594 | self._latency = latency 595 | 596 | @property 597 | def mac_address(self): 598 | return self._mac_address 599 | 600 | @mac_address.setter 601 | def mac_address(self, mac_address): 602 | self._mac_address = mac_address 603 | 604 | @property 605 | def manufacturer(self): 606 | return self._manufacturer 607 | 608 | @manufacturer.setter 609 | def manufacturer(self, manufacturer): 610 | self._manufacturer = manufacturer 611 | 612 | @property 613 | def error(self): 614 | return self._error 615 | 616 | @error.setter 617 | def error(self, error): 618 | self._error = error 619 | 620 | @property 621 | def name(self): 622 | return self._name 623 | 624 | @name.setter 625 | def name(self, name): 626 | self._name = name 627 | 628 | @property 629 | def sample_rate(self): 630 | return self._sample_rate 631 | 632 | @sample_rate.setter 633 | def sample_rate(self, sample_rate): 634 | self._sample_rate = sample_rate 635 | 636 | @property 637 | def server_name(self): 638 | return self._server_name 639 | 640 | @server_name.setter 641 | def server_name(self, server_name): 642 | self._server_name = server_name 643 | 644 | @property 645 | def sockets(self): 646 | return self._sockets 647 | 648 | @sockets.setter 649 | def sockets(self, _sockets): 650 | self._sockets = _sockets 651 | 652 | @property 653 | def software(self): 654 | return self._software 655 | 656 | @software.setter 657 | def software(self, software): 658 | self._software = software 659 | 660 | @property 661 | def rx_channels(self): 662 | return self._rx_channels 663 | 664 | @rx_channels.setter 665 | def rx_channels(self, rx_channels): 666 | self._rx_channels = rx_channels 667 | 668 | @property 669 | def services(self): 670 | return self._services 671 | 672 | @services.setter 673 | def services(self, services): 674 | self._services = services 675 | 676 | @property 677 | def tx_channels(self): 678 | return self._tx_channels 679 | 680 | @tx_channels.setter 681 | def tx_channels(self, tx_channels): 682 | self._tx_channels = tx_channels 683 | 684 | @property 685 | def subscriptions(self): 686 | return self._subscriptions 687 | 688 | @subscriptions.setter 689 | def subscriptions(self, subscriptions): 690 | self._subscriptions = subscriptions 691 | 692 | @property 693 | def tx_count(self): 694 | return self._tx_count 695 | 696 | @tx_count.setter 697 | def tx_count(self, tx_count): 698 | self._tx_count = tx_count 699 | 700 | @property 701 | def rx_count(self): 702 | return self._rx_count 703 | 704 | @rx_count.setter 705 | def rx_count(self, rx_count): 706 | self._rx_count = rx_count 707 | 708 | @property 709 | def tx_count_raw(self): 710 | return self._tx_count_raw 711 | 712 | @tx_count_raw.setter 713 | def tx_count_raw(self, tx_count_raw): 714 | self._tx_count_raw = tx_count_raw 715 | 716 | @property 717 | def rx_count_raw(self): 718 | return self._rx_count_raw 719 | 720 | @rx_count_raw.setter 721 | def rx_count_raw(self, rx_count_raw): 722 | self._rx_count_raw = rx_count_raw 723 | 724 | def to_json(self): 725 | rx_channels = dict(sorted(self.rx_channels.items(), key=lambda x: x[1].number)) 726 | tx_channels = dict(sorted(self.tx_channels.items(), key=lambda x: x[1].number)) 727 | 728 | as_json = { 729 | "channels": {"receivers": rx_channels, "transmitters": tx_channels}, 730 | "ipv4": str(self.ipv4), 731 | "name": self.name, 732 | "server_name": self.server_name, 733 | "services": self.services, 734 | "subscriptions": self.subscriptions, 735 | } 736 | 737 | if self.sample_rate: 738 | as_json["sample_rate"] = self.sample_rate 739 | 740 | if self.latency: 741 | as_json["latency"] = self.latency 742 | 743 | if self.manufacturer: 744 | as_json["manufacturer"] = self.manufacturer 745 | 746 | if self.dante_model: 747 | as_json["dante_model"] = self.dante_model 748 | 749 | if self.dante_model_id: 750 | as_json["dante_model_id"] = self.dante_model_id 751 | 752 | if self.model: 753 | as_json["model"] = self.model 754 | 755 | if self.model_id: 756 | as_json["model_id"] = self.model_id 757 | 758 | if self.mac_address: 759 | as_json["mac_address"] = self.mac_address 760 | 761 | return {key: as_json[key] for key in sorted(as_json.keys())} 762 | 763 | def get_label(self, hex_str, offset): 764 | parsed_get_label = None 765 | 766 | try: 767 | hex_substring = hex_str[int(offset, 16) * 2 :] 768 | partitioned_bytes = bytes.fromhex(hex_substring).partition(b"\x00")[0] 769 | parsed_get_label = partitioned_bytes.decode("utf-8") 770 | except Exception: 771 | pass 772 | # traceback.print_exc() 773 | 774 | return parsed_get_label 775 | 776 | def command_string( 777 | self, 778 | command=None, 779 | command_str=None, 780 | command_args="0000", 781 | command_length="00", 782 | sequence1="ff", 783 | sequence2=0, 784 | ): 785 | if command == "channel_count": 786 | command_length = "0a" 787 | command_str = "1000" 788 | if command == "device_info": 789 | command_length = "0a" 790 | command_str = "1003" 791 | if command == "device_name": 792 | command_length = "0a" 793 | command_str = "1002" 794 | if command == "rx_channels": 795 | command_length = "10" 796 | command_str = "3000" 797 | if command == "reset_name": 798 | command_length = "0a" 799 | command_str = "1001" 800 | command_args = "0000" 801 | if command == "set_name": 802 | command_str = "1001" 803 | 804 | sequence2 = random.randint(0, 65535) 805 | sequence_id = f"{sequence2:04x}" 806 | 807 | command_hex = ( 808 | f"27{sequence1}00{command_length}{sequence_id}{command_str}{command_args}" 809 | ) 810 | 811 | if command == "add_subscription": 812 | command_length = f"{int(len(command_hex) / 2):02x}" 813 | command_hex = f"27{sequence1}00{command_length}{sequence_id}{command_str}{command_args}" 814 | 815 | return command_hex 816 | 817 | def get_name_lengths(self, device_name): 818 | name_len = len(device_name) 819 | offset = (name_len & 1) - 2 820 | padding = 10 - (name_len + offset) 821 | name_len1 = (len(device_name) * 2) + padding 822 | name_len2 = name_len1 + 2 823 | name_len3 = name_len2 + 4 824 | 825 | return (name_len1, name_len2, name_len3) 826 | 827 | def command_make_model(self, mac): 828 | cmd_args = "00c100000000" 829 | command_string = f"ffff00200fdb0000{mac}0000417564696e6174650731{cmd_args}" 830 | 831 | return command_string 832 | 833 | def command_dante_model(self, mac): 834 | cmd_args = "006100000000" 835 | command_string = f"ffff00200fdb0000{mac}0000417564696e6174650731{cmd_args}" 836 | 837 | return command_string 838 | 839 | def command_volume_start(self, device_name, ipv4, mac, port, timeout=True): 840 | data_len = 0 841 | device_name_hex = device_name.encode().hex() 842 | ip_hex = ipv4.packed.hex() 843 | 844 | name_len1, name_len2, name_len3 = self.get_name_lengths(device_name) 845 | 846 | if len(device_name) % 2 == 0: 847 | device_name_hex = f"{device_name_hex}00" 848 | 849 | if len(device_name) < 2: 850 | data_len = 54 851 | elif len(device_name) < 4: 852 | data_len = 56 853 | else: 854 | data_len = len(device_name) + (len(device_name) & 1) + 54 855 | 856 | unknown_arg = "16" 857 | command_string = f"120000{data_len:02x}ffff301000000000{mac}0000000400{name_len1:02x}000100{name_len2:02x}000a{device_name_hex}{unknown_arg}0001000100{name_len3:02x}0001{port:04x}{timeout:04x}0000{ip_hex}{port:04x}0000" 858 | 859 | return (command_string, None, DEVICE_CONTROL_PORT) 860 | 861 | def command_volume_stop(self, device_name, ipv4, mac, port): 862 | data_len = 0 863 | device_name_hex = device_name.encode().hex() 864 | ip_hex = ipaddress.IPv4Address(0).packed.hex() 865 | 866 | name_len1, name_len2, name_len3 = self.get_name_lengths(device_name) 867 | 868 | if len(device_name) % 2 == 0: 869 | device_name_hex = f"{device_name_hex}00" 870 | 871 | if len(device_name) < 2: 872 | data_len = 54 873 | elif len(device_name) < 4: 874 | data_len = 56 875 | else: 876 | data_len = len(device_name) + (len(device_name) & 1) + 54 877 | 878 | command_string = f"120000{data_len:02x}ffff301000000000{mac}0000000400{name_len1:02x}000100{name_len2:02x}000a{device_name_hex}010016000100{name_len3:02x}0001{port:04x}00010000{ip_hex}{0:04x}0000" 879 | 880 | return (command_string, None, DEVICE_CONTROL_PORT) 881 | 882 | def command_set_latency(self, latency): 883 | command_str = "1101" 884 | command_length = "28" 885 | latency = int(latency * 1000000) 886 | latency_hex = f"{latency:08x}" 887 | 888 | command_args = f"000005038205002002110010830100248219830183028306{latency_hex}{latency_hex}" 889 | 890 | return ( 891 | self.command_string( 892 | "set_latency", 893 | command_length=command_length, 894 | command_str=command_str, 895 | command_args=command_args, 896 | ), 897 | SERVICE_ARC, 898 | ) 899 | 900 | def command_identify(self): 901 | mac = "000000000000" 902 | data_len = 32 903 | 904 | command_string = ( 905 | f"ffff00{data_len:02x}0bc80000{mac}0000417564696e6174650731006300000064" 906 | ) 907 | 908 | return (command_string, None, DEVICE_SETTINGS_PORT) 909 | 910 | def command_set_encoding(self, encoding): 911 | data_len = 40 912 | 913 | command_string = f"ffff00{data_len}03d700005254000000000000417564696e617465072700830000006400000001000000{encoding:02x}" 914 | 915 | return (command_string, None, DEVICE_SETTINGS_PORT) 916 | 917 | def command_set_gain_level(self, channel_number, gain_level, device_type): 918 | data_len = 52 919 | 920 | if device_type == "input": 921 | target = f"ffff00{data_len:02x}034400005254000000000000417564696e6174650727100a0000000000010001000c001001020000000000" 922 | elif device_type == "output": 923 | target = f"ffff00{data_len:02x}032600005254000000000000417564696e6174650727100a0000000000010001000c001002010000000000" 924 | 925 | command_string = f"{target}{channel_number:02x}000000{gain_level:02x}" 926 | 927 | return (command_string, None, DEVICE_SETTINGS_PORT) 928 | 929 | def command_set_sample_rate(self, sample_rate): 930 | data_len = 40 931 | 932 | command_string = f"ffff00{data_len:02x}03d400005254000000000000417564696e61746507270081000000640000000100{sample_rate:06x}" 933 | 934 | return (command_string, None, DEVICE_SETTINGS_PORT) 935 | 936 | def command_add_subscription( 937 | self, rx_channel_number, tx_channel_name, tx_device_name 938 | ): 939 | rx_channel_hex = f"{int(rx_channel_number):02x}" 940 | command_str = "3010" 941 | tx_channel_name_hex = tx_channel_name.encode().hex() 942 | tx_device_name_hex = tx_device_name.encode().hex() 943 | 944 | tx_channel_name_offset = f"{52:02x}" 945 | tx_device_name_offset = f"{52 + (len(tx_channel_name) + 1):02x}" 946 | 947 | command_args = f"0000020100{rx_channel_hex}00{tx_channel_name_offset}00{tx_device_name_offset}00000000000000000000000000000000000000000000000000000000000000000000{tx_channel_name_hex}00{tx_device_name_hex}00" 948 | 949 | return ( 950 | self.command_string( 951 | "add_subscription", command_str=command_str, command_args=command_args 952 | ), 953 | SERVICE_ARC, 954 | ) 955 | 956 | def command_remove_subscription(self, rx_channel): 957 | rx_channel_hex = f"{int(rx_channel):02x}" 958 | command_str = "3014" 959 | args_length = "10" 960 | command_args = f"00000001000000{rx_channel_hex}" 961 | 962 | return ( 963 | self.command_string( 964 | "remove_subscription", 965 | command_str=command_str, 966 | command_length=args_length, 967 | command_args=command_args, 968 | ), 969 | SERVICE_ARC, 970 | ) 971 | 972 | def command_device_info(self): 973 | return (self.command_string("device_info"), SERVICE_ARC) 974 | 975 | def command_device_name(self): 976 | return (self.command_string("device_name"), SERVICE_ARC) 977 | 978 | def command_channel_count(self): 979 | return (self.command_string("channel_count"), SERVICE_ARC) 980 | 981 | def command_set_name(self, name): 982 | args_length = chr(len(name.encode("utf-8")) + 11) 983 | args_length = bytes(args_length.encode("utf-8")).hex() 984 | 985 | return ( 986 | self.command_string( 987 | "set_name", 988 | command_length=args_length, 989 | command_args=self.device_name(name), 990 | ), 991 | SERVICE_ARC, 992 | ) 993 | 994 | def command_reset_name(self): 995 | return (self.command_string("reset_name"), SERVICE_ARC) 996 | 997 | def command_reset_channel_name(self, channel_type, channel_number): 998 | channel_hex = f"{channel_number:02x}" 999 | 1000 | if channel_type == "rx": 1001 | args_length = f"{int(21):02x}" 1002 | command_args = f"0000020100{channel_hex}00140000000000" 1003 | command_str = "3001" 1004 | if channel_type == "tx": 1005 | args_length = f"{int(25):02x}" 1006 | command_args = f"00000201000000{channel_hex}001800000000000000" 1007 | command_str = "2013" 1008 | 1009 | return ( 1010 | self.command_string( 1011 | "reset_channel_name", 1012 | command_str=command_str, 1013 | command_args=command_args, 1014 | command_length=args_length, 1015 | ), 1016 | SERVICE_ARC, 1017 | ) 1018 | 1019 | def command_set_channel_name(self, channel_type, channel_number, new_channel_name): 1020 | name_hex = new_channel_name.encode().hex() 1021 | channel_hex = f"{channel_number:02x}" 1022 | 1023 | if channel_type == "rx": 1024 | command_str = "3001" 1025 | command_args = f"0000020100{channel_hex}001400000000{name_hex}00" 1026 | args_length = chr(len(new_channel_name.encode("utf-8")) + 21) 1027 | if channel_type == "tx": 1028 | command_str = "2013" 1029 | command_args = f"00000201000000{channel_hex}0018000000000000{name_hex}00" 1030 | args_length = chr(len(new_channel_name.encode("utf-8")) + 25) 1031 | 1032 | args_length = bytes(args_length.encode("utf-8")).hex() 1033 | 1034 | return ( 1035 | self.command_string( 1036 | "set_channel_name", 1037 | command_str=command_str, 1038 | command_length=args_length, 1039 | command_args=command_args, 1040 | ), 1041 | SERVICE_ARC, 1042 | ) 1043 | 1044 | def device_name(self, name): 1045 | name_hex = name.encode().hex() 1046 | return f"0000{name_hex}00" 1047 | 1048 | def channel_pagination(self, page): 1049 | page_hex = format(page, "x") 1050 | command_args = f"0000000100{page_hex}10000" 1051 | 1052 | return command_args 1053 | 1054 | def command_receivers(self, page=0): 1055 | return ( 1056 | self.command_string( 1057 | "rx_channels", command_args=self.channel_pagination(page) 1058 | ), 1059 | SERVICE_ARC, 1060 | ) 1061 | 1062 | def command_transmitters(self, page=0, friendly_names=False): 1063 | if friendly_names: 1064 | command_str = "2010" 1065 | else: 1066 | command_str = "2000" 1067 | 1068 | command_length = "10" 1069 | command_args = self.channel_pagination(page=page) 1070 | 1071 | return ( 1072 | self.command_string( 1073 | "tx_channels", 1074 | command_length=command_length, 1075 | command_str=command_str, 1076 | command_args=command_args, 1077 | ), 1078 | SERVICE_ARC, 1079 | ) 1080 | 1081 | def command_enable_aes67(self, is_enabled: bool): 1082 | data_len = "24" # == 0x24 1083 | enable = int(is_enabled) 1084 | sequence_id = 0xFF 1085 | # 22d after sequence ID is 1da for other dev, but works still 1086 | command_string = f"ffff00\ 1087 | {data_len}\ 1088 | {sequence_id:04x}22dc525400385eba0000417564696e6174650734100600000064000100\ 1089 | {enable:02x}" 1090 | # Remove whitespace in string, that comes from formatting above 1091 | command_string = "".join(command_string.split()) 1092 | return (command_string, None, DEVICE_SETTINGS_PORT) 1093 | -------------------------------------------------------------------------------- /netaudio/dante/multicast.py: -------------------------------------------------------------------------------- 1 | from twisted.internet.protocol import DatagramProtocol 2 | 3 | 4 | class DanteMulticast(DatagramProtocol): 5 | def __init__(self, group, port): 6 | self.group = group 7 | self.port = port 8 | 9 | def startProtocol(self): 10 | self.transport.joinGroup(self.group) 11 | 12 | def datagramReceived(self, datagram, address): 13 | ee.emit( 14 | "received_multicast", 15 | data=datagram, 16 | addr=address, 17 | group=self.group, 18 | port=self.port, 19 | ) 20 | -------------------------------------------------------------------------------- /netaudio/dante/subscription.py: -------------------------------------------------------------------------------- 1 | from netaudio.dante.const import ( 2 | SUBSCRIPTION_STATUS_FLAG_NO_ADVERT, 3 | SUBSCRIPTION_STATUS_LABELS, 4 | ) 5 | 6 | 7 | class DanteSubscription: 8 | def __init__(self): 9 | self._error = None 10 | self._rx_channel = None 11 | self._rx_channel_name = None 12 | self._rx_device = None 13 | self._rx_device_name = None 14 | self._status_code = None 15 | self._status_message = [] 16 | self._tx_channel = None 17 | self._tx_channel_name = None 18 | self._tx_device = None 19 | self._tx_device_name = None 20 | 21 | def __str__(self): 22 | if self.tx_channel_name and self.tx_device_name: 23 | text = f"{self.rx_channel_name}@{self.rx_device_name} <- {self.tx_channel_name}@{self.tx_device_name}" 24 | else: 25 | text = f"{self.rx_channel_name}@{self.rx_device_name}" 26 | 27 | status_text = self.status_text() 28 | 29 | if self.rx_channel_status_code in SUBSCRIPTION_STATUS_LABELS: 30 | status_text = list(status_text) 31 | status_text.extend(self.rx_channel_status_text()) 32 | status_text = ", ".join(status_text) 33 | text = f"{text} [{status_text}]" 34 | 35 | return text 36 | 37 | def status_text(self): 38 | return SUBSCRIPTION_STATUS_LABELS[self.status_code] 39 | 40 | def rx_channel_status_text(self): 41 | return SUBSCRIPTION_STATUS_LABELS[self.rx_channel_status_code] 42 | 43 | def to_json(self): 44 | as_json = { 45 | "rx_channel": self.rx_channel_name, 46 | "rx_channel_status_code": self.rx_channel_status_code, 47 | "rx_device": self.rx_device_name, 48 | "status_code": self.status_code, 49 | "status_text": self.status_text(), 50 | "tx_channel": self.tx_channel_name, 51 | "tx_device": self.tx_device_name, 52 | } 53 | 54 | if self.rx_channel_status_code in SUBSCRIPTION_STATUS_LABELS: 55 | as_json["rx_channel_status_text"] = self.rx_channel_status_text() 56 | 57 | return as_json 58 | 59 | @property 60 | def error(self): 61 | return self._error 62 | 63 | @error.setter 64 | def error(self, error): 65 | self._error = error 66 | 67 | @property 68 | def rx_channel_name(self): 69 | return self._rx_channel_name 70 | 71 | @rx_channel_name.setter 72 | def rx_channel_name(self, rx_channel_name): 73 | self._rx_channel_name = rx_channel_name 74 | 75 | @property 76 | def tx_channel_name(self): 77 | return self._tx_channel_name 78 | 79 | @tx_channel_name.setter 80 | def tx_channel_name(self, tx_channel_name): 81 | self._tx_channel_name = tx_channel_name 82 | 83 | @property 84 | def rx_device_name(self): 85 | return self._rx_device_name 86 | 87 | @rx_device_name.setter 88 | def rx_device_name(self, rx_device_name): 89 | self._rx_device_name = rx_device_name 90 | 91 | @property 92 | def rx_channel_status_code(self): 93 | return self._rx_channel_status_code 94 | 95 | @rx_channel_status_code.setter 96 | def rx_channel_status_code(self, rx_channel_status_code): 97 | self._rx_channel_status_code = rx_channel_status_code 98 | 99 | @property 100 | def status_code(self): 101 | return self._status_code 102 | 103 | @status_code.setter 104 | def status_code(self, status_code): 105 | self._status_code = status_code 106 | 107 | @property 108 | def status_message(self): 109 | return self._status_message 110 | 111 | @status_message.setter 112 | def status_message(self, status_message): 113 | self._status_message = status_message 114 | 115 | @property 116 | def tx_device_name(self): 117 | return self._tx_device_name 118 | 119 | @tx_device_name.setter 120 | def tx_device_name(self, tx_device_name): 121 | self._tx_device_name = tx_device_name 122 | 123 | @property 124 | def rx_channel(self): 125 | return self._rx_channel 126 | 127 | @rx_channel.setter 128 | def rx_channel(self, rx_channel): 129 | self._rx_channel = rx_channel 130 | 131 | @property 132 | def tx_channel(self): 133 | return self._tx_channel 134 | 135 | @tx_channel.setter 136 | def tx_channel(self, tx_channel): 137 | self._tx_channel = tx_channel 138 | 139 | @property 140 | def rx_device(self): 141 | return self._rx_device 142 | 143 | @rx_device.setter 144 | def rx_device(self, rx_device): 145 | self._rx_device = rx_device 146 | 147 | @property 148 | def tx_device(self): 149 | return self._tx_device 150 | 151 | @tx_device.setter 152 | def tx_device(self, tx_device): 153 | self._tx_device = tx_device 154 | -------------------------------------------------------------------------------- /netaudio/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | import socket 3 | 4 | from .timeout import Timeout 5 | 6 | 7 | def get_host_by_name(host): 8 | ipv4 = None 9 | 10 | try: 11 | ipv4 = ipaddress.ip_address(Timeout(socket.gethostbyname, 0.1)(host)) 12 | except socket.gaierror: 13 | pass 14 | except TimeoutError: 15 | pass 16 | 17 | return ipv4 18 | -------------------------------------------------------------------------------- /netaudio/utils/timeout.py: -------------------------------------------------------------------------------- 1 | from multiprocessing import Process, Pipe 2 | 3 | 4 | class Timeout: 5 | def __init__(self, func, timeout): 6 | self.func = func 7 | self.timeout = timeout 8 | 9 | def __call__(self, *args, **kargs): 10 | def pmain(pipe, func, args, kargs): 11 | result = None 12 | 13 | try: 14 | result = func(*args, **kargs) 15 | except Exception: 16 | pass 17 | 18 | pipe.send(result) 19 | 20 | parent_pipe, child_pipe = Pipe() 21 | 22 | p = Process(target=pmain, args=(child_pipe, self.func, args, kargs)) 23 | p.start() 24 | p.join(self.timeout) 25 | 26 | result = None 27 | 28 | if p.is_alive(): 29 | p.terminate() 30 | result = None 31 | raise TimeoutError 32 | 33 | result = parent_pipe.recv() 34 | 35 | return result 36 | -------------------------------------------------------------------------------- /netaudio/version.py: -------------------------------------------------------------------------------- 1 | version = "0.0.11" 2 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-allow-list= 7 | 8 | # A comma-separated list of package or module names from where C extensions may 9 | # be loaded. Extensions are loading into the active Python interpreter and may 10 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 11 | # for backward compatibility.) 12 | extension-pkg-whitelist= 13 | 14 | # Return non-zero exit code if any of these messages/categories are detected, 15 | # even if score is above --fail-under value. Syntax same as enable. Messages 16 | # specified are enabled, while categories only check already-enabled messages. 17 | fail-on= 18 | 19 | # Specify a score threshold to be exceeded before program exits with error. 20 | fail-under=10.0 21 | 22 | # Files or directories to be skipped. They should be base names, not paths. 23 | ignore=CVS 24 | 25 | # Add files or directories matching the regex patterns to the ignore-list. The 26 | # regex matches against paths and can be in Posix or Windows format. 27 | ignore-paths= 28 | 29 | # Files or directories matching the regex patterns are skipped. The regex 30 | # matches against base names, not paths. 31 | ignore-patterns= 32 | 33 | # Python code to execute, usually for sys.path manipulation such as 34 | # pygtk.require(). 35 | #init-hook= 36 | 37 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 38 | # number of processors available to use. 39 | jobs=1 40 | 41 | # Control the amount of potential inferred values when inferring a single 42 | # object. This can help the performance when dealing with large functions or 43 | # complex, nested conditions. 44 | limit-inference-results=100 45 | 46 | # List of plugins (as comma separated values of python module names) to load, 47 | # usually to register additional checkers. 48 | load-plugins= 49 | 50 | # Pickle collected data for later comparisons. 51 | persistent=yes 52 | 53 | # Minimum Python version to use for version dependent checks. Will default to 54 | # the version used to run pylint. 55 | py-version=3.9 56 | 57 | # When enabled, pylint would attempt to guess common misconfiguration and emit 58 | # user-friendly hints instead of false-positive error messages. 59 | suggestion-mode=yes 60 | 61 | # Allow loading of arbitrary C extensions. Extensions are imported into the 62 | # active Python interpreter and may run arbitrary code. 63 | unsafe-load-any-extension=no 64 | 65 | 66 | [MESSAGES CONTROL] 67 | 68 | # Only show warnings with the listed confidence levels. Leave empty to show 69 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 70 | confidence= 71 | 72 | # Disable the message, report, category or checker with the given id(s). You 73 | # can either give multiple identifiers separated by comma (,) or put this 74 | # option multiple times (only on the command line, not in the configuration 75 | # file where it should appear only once). You can also use "--disable=all" to 76 | # disable everything first and then reenable specific checks. For example, if 77 | # you want to run only the similarities checker, you can use "--disable=all 78 | # --enable=similarities". If you want to run only the classes checker, but have 79 | # no Warning level messages displayed, use "--disable=all --enable=classes 80 | # --disable=W". 81 | disable=bad-inline-option, 82 | broad-except, 83 | consider-using-generator, 84 | deprecated-pragma, 85 | file-ignored, 86 | function-redefined, 87 | inconsistent-return-statements, 88 | invalid-name, 89 | line-too-long, 90 | locally-disabled, 91 | logging-fstring-interpolation, 92 | missing-module-docstring, 93 | no-member, 94 | no-self-use, 95 | protected-access, 96 | raw-checker-failed, 97 | relative-beyond-top-level, 98 | suppressed-message, 99 | too-few-public-methods, 100 | too-many-arguments, 101 | too-many-branches, 102 | too-many-instance-attributes, 103 | too-many-lines, 104 | too-many-local-variables, 105 | too-many-locals, 106 | too-many-public-methods, 107 | too-many-statements, 108 | unnecessary-lambda, 109 | unused-argument, 110 | use-symbolic-message-instead, 111 | useless-suppression 112 | 113 | 114 | # Enable the message, report, category or checker with the given id(s). You can 115 | # either give multiple identifier separated by comma (,) or put this option 116 | # multiple time (only on the command line, not in the configuration file where 117 | # it should appear only once). See also the "--disable" option for examples. 118 | enable=c-extension-no-member 119 | 120 | 121 | [REPORTS] 122 | 123 | # Python expression which should return a score less than or equal to 10. You 124 | # have access to the variables 'error', 'warning', 'refactor', and 'convention' 125 | # which contain the number of messages in each category, as well as 'statement' 126 | # which is the total number of statements analyzed. This score is used by the 127 | # global evaluation report (RP0004). 128 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 129 | 130 | # Template used to display messages. This is a python new-style format string 131 | # used to format the message information. See doc for all details. 132 | #msg-template= 133 | 134 | # Set the output format. Available formats are text, parseable, colorized, json 135 | # and msvs (visual studio). You can also give a reporter class, e.g. 136 | # mypackage.mymodule.MyReporterClass. 137 | output-format=text 138 | 139 | # Tells whether to display a full report or only the messages. 140 | reports=no 141 | 142 | # Activate the evaluation score. 143 | score=yes 144 | 145 | 146 | [REFACTORING] 147 | 148 | # Maximum number of nested blocks for function / method body 149 | max-nested-blocks=5 150 | 151 | # Complete name of functions that never returns. When checking for 152 | # inconsistent-return-statements if a never returning function is called then 153 | # it will be considered as an explicit return statement and no message will be 154 | # printed. 155 | never-returning-functions=sys.exit,argparse.parse_error 156 | 157 | 158 | [BASIC] 159 | 160 | # Naming style matching correct argument names. 161 | argument-naming-style=snake_case 162 | 163 | # Regular expression matching correct argument names. Overrides argument- 164 | # naming-style. 165 | #argument-rgx= 166 | 167 | # Naming style matching correct attribute names. 168 | attr-naming-style=snake_case 169 | 170 | # Regular expression matching correct attribute names. Overrides attr-naming- 171 | # style. 172 | #attr-rgx= 173 | 174 | # Bad variable names which should always be refused, separated by a comma. 175 | bad-names=foo, 176 | bar, 177 | baz, 178 | toto, 179 | tutu, 180 | tata 181 | 182 | # Bad variable names regexes, separated by a comma. If names match any regex, 183 | # they will always be refused 184 | bad-names-rgxs= 185 | 186 | # Naming style matching correct class attribute names. 187 | class-attribute-naming-style=any 188 | 189 | # Regular expression matching correct class attribute names. Overrides class- 190 | # attribute-naming-style. 191 | #class-attribute-rgx= 192 | 193 | # Naming style matching correct class constant names. 194 | class-const-naming-style=UPPER_CASE 195 | 196 | # Regular expression matching correct class constant names. Overrides class- 197 | # const-naming-style. 198 | #class-const-rgx= 199 | 200 | # Naming style matching correct class names. 201 | class-naming-style=PascalCase 202 | 203 | # Regular expression matching correct class names. Overrides class-naming- 204 | # style. 205 | #class-rgx= 206 | 207 | # Naming style matching correct constant names. 208 | const-naming-style=UPPER_CASE 209 | 210 | # Regular expression matching correct constant names. Overrides const-naming- 211 | # style. 212 | #const-rgx= 213 | 214 | # Minimum line length for functions/classes that require docstrings, shorter 215 | # ones are exempt. 216 | docstring-min-length=-1 217 | 218 | # Naming style matching correct function names. 219 | function-naming-style=snake_case 220 | 221 | # Regular expression matching correct function names. Overrides function- 222 | # naming-style. 223 | #function-rgx= 224 | 225 | # Good variable names which should always be accepted, separated by a comma. 226 | good-names=i, 227 | j, 228 | k, 229 | ex, 230 | Run, 231 | _ 232 | 233 | # Good variable names regexes, separated by a comma. If names match any regex, 234 | # they will always be accepted 235 | good-names-rgxs=^[_a-z][_a-z0-9]?$ 236 | 237 | # Include a hint for the correct naming format with invalid-name. 238 | include-naming-hint=no 239 | 240 | # Naming style matching correct inline iteration names. 241 | inlinevar-naming-style=any 242 | 243 | # Regular expression matching correct inline iteration names. Overrides 244 | # inlinevar-naming-style. 245 | #inlinevar-rgx= 246 | 247 | # Naming style matching correct method names. 248 | method-naming-style=snake_case 249 | 250 | # Regular expression matching correct method names. Overrides method-naming- 251 | # style. 252 | #method-rgx= 253 | 254 | # Naming style matching correct module names. 255 | module-naming-style=snake_case 256 | 257 | # Regular expression matching correct module names. Overrides module-naming- 258 | # style. 259 | #module-rgx= 260 | 261 | # Colon-delimited sets of names that determine each other's naming style when 262 | # the name regexes allow several styles. 263 | name-group= 264 | 265 | # Regular expression which should only match function or class names that do 266 | # not require a docstring. 267 | no-docstring-rgx=^.*$ 268 | 269 | # List of decorators that produce properties, such as abc.abstractproperty. Add 270 | # to this list to register other decorators that produce valid properties. 271 | # These decorators are taken in consideration only for invalid-name. 272 | property-classes=abc.abstractproperty 273 | 274 | # Naming style matching correct variable names. 275 | variable-naming-style=snake_case 276 | 277 | # Regular expression matching correct variable names. Overrides variable- 278 | # naming-style. 279 | #variable-rgx= 280 | 281 | 282 | [FORMAT] 283 | 284 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 285 | expected-line-ending-format= 286 | 287 | # Regexp for a line that is allowed to be longer than the limit. 288 | # ignore-long-lines=^\s*(# )??$ 289 | ignore-long-lines=^.*$ 290 | 291 | # Number of spaces of indent required inside a hanging or continued line. 292 | indent-after-paren=4 293 | 294 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 295 | # tab). 296 | indent-string=' ' 297 | 298 | # Maximum number of characters on a single line. 299 | max-line-length=100 300 | 301 | # Maximum number of lines in a module. 302 | max-module-lines=1000 303 | 304 | # Allow the body of a class to be on the same line as the declaration if body 305 | # contains single statement. 306 | single-line-class-stmt=no 307 | 308 | # Allow the body of an if to be on the same line as the test if there is no 309 | # else. 310 | single-line-if-stmt=no 311 | 312 | 313 | [LOGGING] 314 | 315 | # The type of string formatting that logging methods do. `old` means using % 316 | # formatting, `new` is for `{}` formatting. 317 | logging-format-sytle=new 318 | 319 | # Logging modules to check that the string format arguments are in logging 320 | # function parameter format. 321 | logging-modules=logging 322 | 323 | 324 | [MISCELLANEOUS] 325 | 326 | # List of note tags to take in consideration, separated by a comma. 327 | notes=FIXME, 328 | XXX, 329 | TODO 330 | 331 | # Regular expression of note tags to take in consideration. 332 | #notes-rgx= 333 | 334 | 335 | [SIMILARITIES] 336 | 337 | # Comments are removed from the similarity computation 338 | ignore-comments=yes 339 | 340 | # Docstrings are removed from the similarity computation 341 | ignore-docstrings=yes 342 | 343 | # Imports are removed from the similarity computation 344 | ignore-imports=no 345 | 346 | # Signatures are removed from the similarity computation 347 | ignore-signatures=no 348 | 349 | # Minimum lines number of a similarity. 350 | min-similarity-lines=4 351 | 352 | 353 | [SPELLING] 354 | 355 | # Limits count of emitted suggestions for spelling mistakes. 356 | max-spelling-suggestions=4 357 | 358 | # Spelling dictionary name. Available dictionaries: none. To make it work, 359 | # install the 'python-enchant' package. 360 | spelling-dict= 361 | 362 | # List of comma separated words that should be considered directives if they 363 | # appear and the beginning of a comment and should not be checked. 364 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 365 | 366 | # List of comma separated words that should not be checked. 367 | spelling-ignore-words= 368 | 369 | # A path to a file that contains the private dictionary; one word per line. 370 | spelling-private-dict-file= 371 | 372 | # Tells whether to store unknown words to the private dictionary (see the 373 | # --spelling-private-dict-file option) instead of raising a message. 374 | spelling-store-unknown-words=no 375 | 376 | 377 | [STRING] 378 | 379 | # This flag controls whether inconsistent-quotes generates a warning when the 380 | # character used as a quote delimiter is used inconsistently within a module. 381 | check-quote-consistency=no 382 | 383 | # This flag controls whether the implicit-str-concat should generate a warning 384 | # on implicit string concatenation in sequences defined over several lines. 385 | check-str-concat-over-line-jumps=no 386 | 387 | 388 | [TYPECHECK] 389 | 390 | # List of decorators that produce context managers, such as 391 | # contextlib.contextmanager. Add to this list to register other decorators that 392 | # produce valid context managers. 393 | contextmanager-decorators=contextlib.contextmanager 394 | 395 | # List of members which are set dynamically and missed by pylint inference 396 | # system, and so shouldn't trigger E1101 when accessed. Python regular 397 | # expressions are accepted. 398 | generated-members= 399 | 400 | # Tells whether missing members accessed in mixin class should be ignored. A 401 | # class is considered mixin if its name matches the mixin-class-rgx option. 402 | ignore-mixin-members=yes 403 | 404 | # Tells whether to warn about missing members when the owner of the attribute 405 | # is inferred to be None. 406 | ignore-none=yes 407 | 408 | # This flag controls whether pylint should warn about no-member and similar 409 | # checks whenever an opaque object is returned when inferring. The inference 410 | # can return multiple potential results while evaluating a Python object, but 411 | # some branches might not be evaluated, which results in partial inference. In 412 | # that case, it might be useful to still emit no-member and other checks for 413 | # the rest of the inferred objects. 414 | ignore-on-opaque-inference=yes 415 | 416 | # List of class names for which member attributes should not be checked (useful 417 | # for classes with dynamically set attributes). This supports the use of 418 | # qualified names. 419 | ignored-classes=optparse.Values,thread._local,_thread._local 420 | 421 | # List of module names for which member attributes should not be checked 422 | # (useful for modules/projects where namespaces are manipulated during runtime 423 | # and thus existing member attributes cannot be deduced by static analysis). It 424 | # supports qualified module names, as well as Unix pattern matching. 425 | ignored-modules= 426 | 427 | # Show a hint with possible names when a member name was not found. The aspect 428 | # of finding the hint is based on edit distance. 429 | missing-member-hint=yes 430 | 431 | # The minimum edit distance a name should have in order to be considered a 432 | # similar match for a missing member name. 433 | missing-member-hint-distance=1 434 | 435 | # The total number of similar names that should be taken in consideration when 436 | # showing a hint for a missing member. 437 | missing-member-max-choices=1 438 | 439 | # Regex pattern to define which classes are considered mixins ignore-mixin- 440 | # members is set to 'yes' 441 | mixin-class-rgx=.*[Mm]ixin 442 | 443 | # List of decorators that change the signature of a decorated function. 444 | signature-mutators= 445 | 446 | 447 | [VARIABLES] 448 | 449 | # List of additional names supposed to be defined in builtins. Remember that 450 | # you should avoid defining new builtins when possible. 451 | additional-builtins= 452 | 453 | # Tells whether unused global variables should be treated as a violation. 454 | allow-global-unused-variables=yes 455 | 456 | # List of names allowed to shadow builtins 457 | allowed-redefined-builtins= 458 | 459 | # List of strings which can identify a callback function by name. A callback 460 | # name must start or end with one of those strings. 461 | callbacks=cb_, 462 | _cb 463 | 464 | # A regular expression matching the name of dummy variables (i.e. expected to 465 | # not be used). 466 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 467 | 468 | # Argument names that match this expression will be ignored. Default to name 469 | # with leading underscore. 470 | ignored-argument-names=_.*|^ignored_|^unused_ 471 | 472 | # Tells whether we should check for unused import in __init__ files. 473 | init-import=no 474 | 475 | # List of qualified module names which can have objects that can redefine 476 | # builtins. 477 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 478 | 479 | 480 | [CLASSES] 481 | 482 | # Warn about protected attribute access inside special methods 483 | check-protected-access-in-special-methods=no 484 | 485 | # List of method names used to declare (i.e. assign) instance attributes. 486 | defining-attr-methods=__init__, 487 | __new__, 488 | setUp, 489 | __post_init__ 490 | 491 | # List of member names, which should be excluded from the protected access 492 | # warning. 493 | exclude-protected=_asdict, 494 | _fields, 495 | _replace, 496 | _source, 497 | _make 498 | 499 | # List of valid names for the first argument in a class method. 500 | valid-classmethod-first-arg=cls 501 | 502 | # List of valid names for the first argument in a metaclass class method. 503 | valid-metaclass-classmethod-first-arg=cls 504 | 505 | 506 | [DESIGN] 507 | 508 | # List of regular expressions of class ancestor names to ignore when counting 509 | # public methods (see R0903) 510 | exclude-too-few-public-methods= 511 | 512 | # List of qualified class names to ignore when counting class parents (see 513 | # R0901) 514 | ignored-parents= 515 | 516 | # Maximum number of arguments for function / method. 517 | max-args=5 518 | 519 | # Maximum number of attributes for a class (see R0902). 520 | max-attributes=20 521 | 522 | # Maximum number of boolean expressions in an if statement (see R0916). 523 | max-bool-expr=20 524 | 525 | # Maximum number of branch for function / method body. 526 | max-branches=20 527 | 528 | # Maximum number of locals for function / method body. 529 | max-locals=15 530 | 531 | # Maximum number of parents for a class (see R0901). 532 | max-parents=7 533 | 534 | # Maximum number of public methods for a class (see R0904). 535 | max-public-methods=20 536 | 537 | # Maximum number of return / yield for function / method body. 538 | max-returns=6 539 | 540 | # Maximum number of statements in function / method body. 541 | max-statements=50 542 | 543 | # Minimum number of public methods for a class (see R0903). 544 | min-public-methods=2 545 | 546 | 547 | [IMPORTS] 548 | 549 | # List of modules that can be imported at any level, not just the top level 550 | # one. 551 | allow-any-import-level= 552 | 553 | # Allow wildcard imports from modules that define __all__. 554 | allow-wildcard-with-all=no 555 | 556 | # Analyse import fallback blocks. This can be used to support both Python 2 and 557 | # 3 compatible code, which means that the block might have code that exists 558 | # only in one or another interpreter, leading to false positives when analysed. 559 | analyse-fallback-blocks=no 560 | 561 | # Deprecated modules which should not be used, separated by a comma. 562 | deprecated-modules= 563 | 564 | # Output a graph (.gv or any supported image format) of external dependencies 565 | # to the given file (report RP0402 must not be disabled). 566 | ext-import-graph= 567 | 568 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 569 | # external) dependencies to the given file (report RP0402 must not be 570 | # disabled). 571 | import-graph= 572 | 573 | # Output a graph (.gv or any supported image format) of internal dependencies 574 | # to the given file (report RP0402 must not be disabled). 575 | int-import-graph= 576 | 577 | # Force import order to recognize a module as part of the standard 578 | # compatibility libraries. 579 | known-standard-library= 580 | 581 | # Force import order to recognize a module as part of a third party library. 582 | known-third-party=enchant 583 | 584 | # Couples of modules and preferred modules, separated by a comma. 585 | preferred-modules= 586 | 587 | 588 | [EXCEPTIONS] 589 | 590 | # Exceptions that will emit a warning when being caught. Defaults to 591 | # "BaseException, Exception". 592 | overgeneral-exceptions=BaseException, 593 | Exception 594 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | authors = ["Christopher Ritsen "] 3 | classifiers = ["Topic :: Multimedia :: Sound/Audio"] 4 | description = "Control Audinate Dante network audio devices without Dante Controller" 5 | documentation = "https://github.com/chris-ritsen/network-audio-controller/wiki" 6 | include = ["CHANGELOG.md", "LICENSE"] 7 | keywords = ["audinate", "audio", "cli", "dante", "network"] 8 | license = "Unlicense" 9 | name = "netaudio" 10 | readme = "README.md" 11 | repository = "https://github.com/chris-ritsen/network-audio-controller" 12 | version = "0.0.11" 13 | 14 | [tool.poetry.dependencies] 15 | cleo = "^0.8.1" 16 | python = "^3.9" 17 | twisted = "^22.1.0" 18 | zeroconf = "^0.38.3" 19 | sqlitedict = "^1.7.0" 20 | redis = "^4.1.4" 21 | fastapi = "^0.110.1" 22 | uvicorn = "^0.29.0" 23 | clikit = "^0.6.2" 24 | 25 | [tool.poetry.group.dev.dependencies] 26 | black = "^22.1.0" 27 | pipx = "^1.0.0" 28 | pylint = "^2.12.2" 29 | pytest = "^7.0.0" 30 | 31 | [build-system] 32 | requires = ["poetry-core>=1.0.0"] 33 | build-backend = "poetry.core.masonry.api" 34 | 35 | [tool.poetry.scripts] 36 | netaudio = "netaudio:main" 37 | 38 | # Recommended, see: https://docs.pytest.org/en/7.1.x/explanation/goodpractices.html#choosing-an-import-mode 39 | [tool.pytest.ini_options] 40 | addopts = [ 41 | "--import-mode=importlib", 42 | ] 43 | 44 | [tool.pytest.ini_options.exclude] 45 | patterns = [ 46 | "*/__pycache__/*", 47 | "*.pyc", 48 | "*.pyo" 49 | ] 50 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris-ritsen/network-audio-controller/f4eef6a0021d29b36b9d805d7cb0f9c29f583673/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_aes67.py: -------------------------------------------------------------------------------- 1 | import netaudio.dante.device 2 | 3 | def func(x): 4 | return x + 1 5 | 6 | 7 | def test_answer(): 8 | assert func(4) == 5 9 | 10 | def test_command_aes67(): 11 | some_dev = netaudio.dante.device.DanteDevice() 12 | got = some_dev.command_enable_aes67(is_enabled=True) 13 | want = ('ffff002400ff22dc525400385eba0000417564696e617465073410060000006400010001', None, 8700) 14 | assert got == want 15 | 16 | got = some_dev.command_enable_aes67(is_enabled=False) 17 | want = ('ffff002400ff22dc525400385eba0000417564696e617465073410060000006400010000', None, 8700) 18 | assert got == want --------------------------------------------------------------------------------