├── .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 |

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
--------------------------------------------------------------------------------