netaudio-lib 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netaudio_lib-0.0.1/.gitignore +7 -0
- netaudio_lib-0.0.1/PKG-INFO +31 -0
- netaudio_lib-0.0.1/README.md +17 -0
- netaudio_lib-0.0.1/pyproject.toml +24 -0
- netaudio_lib-0.0.1/src/netaudio_lib/__init__.py +21 -0
- netaudio_lib-0.0.1/src/netaudio_lib/common/app_config.py +96 -0
- netaudio_lib-0.0.1/src/netaudio_lib/common/mdns_cache.py +73 -0
- netaudio_lib-0.0.1/src/netaudio_lib/common/socket_path.py +44 -0
- netaudio_lib-0.0.1/src/netaudio_lib/daemon/__init__.py +2 -0
- netaudio_lib-0.0.1/src/netaudio_lib/daemon/client.py +73 -0
- netaudio_lib-0.0.1/src/netaudio_lib/daemon/server.py +574 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/__init__.py +0 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/application.py +238 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/browser.py +381 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/channel.py +97 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/const.py +627 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/debug_formatter.py +488 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/device.py +233 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/device_commands.py +351 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/device_network.py +212 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/device_operations.py +127 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/device_parser.py +294 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/device_protocol.py +134 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/device_serializer.py +89 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/device_xml_serializer.py +163 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/events.py +91 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/packet_store.py +381 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/protocol.py +510 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/service.py +131 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/services/__init__.py +11 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/services/arc.py +183 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/services/cmc.py +130 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/services/notification.py +164 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/services/settings.py +68 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/subscription.py +146 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/transport.py +111 -0
- netaudio_lib-0.0.1/src/netaudio_lib/dante/tshark_capture.py +216 -0
- netaudio_lib-0.0.1/src/netaudio_lib/utils/__init__.py +17 -0
- netaudio_lib-0.0.1/src/netaudio_lib/utils/timeout.py +35 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netaudio-lib
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Python library for controlling Audinate Dante network audio devices
|
|
5
|
+
Project-URL: Repository, https://github.com/chris-ritsen/network-audio-controller
|
|
6
|
+
Author-email: Christopher Ritsen <chris.ritsen@gmail.com>
|
|
7
|
+
License-Expression: Unlicense
|
|
8
|
+
Keywords: audinate,audio,dante,network
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Requires-Dist: ifaddr>=0.2.0
|
|
11
|
+
Requires-Dist: sqlitedict>=1.7.0
|
|
12
|
+
Requires-Dist: zeroconf>=0.38.3
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# netaudio-lib
|
|
16
|
+
|
|
17
|
+
Python library for controlling Audinate Dante network audio devices.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install netaudio-lib
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from netaudio_lib import DanteBrowser
|
|
29
|
+
|
|
30
|
+
browser = DanteBrowser(mdns_timeout=5)
|
|
31
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# netaudio-lib
|
|
2
|
+
|
|
3
|
+
Python library for controlling Audinate Dante network audio devices.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install netaudio-lib
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from netaudio_lib import DanteBrowser
|
|
15
|
+
|
|
16
|
+
browser = DanteBrowser(mdns_timeout=5)
|
|
17
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "netaudio-lib"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Python library for controlling Audinate Dante network audio devices"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Unlicense"
|
|
7
|
+
requires-python = ">=3.9"
|
|
8
|
+
authors = [{ name = "Christopher Ritsen", email = "chris.ritsen@gmail.com" }]
|
|
9
|
+
keywords = ["audinate", "audio", "dante", "network"]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"zeroconf>=0.38.3",
|
|
12
|
+
"ifaddr>=0.2.0",
|
|
13
|
+
"sqlitedict>=1.7.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Repository = "https://github.com/chris-ritsen/network-audio-controller"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/netaudio_lib"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
__version__ = version("netaudio-lib")
|
|
4
|
+
|
|
5
|
+
from netaudio_lib.dante.application import DanteApplication
|
|
6
|
+
from netaudio_lib.dante.browser import DanteBrowser
|
|
7
|
+
from netaudio_lib.dante.channel import DanteChannel
|
|
8
|
+
from netaudio_lib.dante.device import DanteDevice
|
|
9
|
+
from netaudio_lib.dante.events import DanteEvent, DanteEventDispatcher, EventType
|
|
10
|
+
from netaudio_lib.dante.subscription import DanteSubscription
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"DanteApplication",
|
|
14
|
+
"DanteBrowser",
|
|
15
|
+
"DanteChannel",
|
|
16
|
+
"DanteDevice",
|
|
17
|
+
"DanteEvent",
|
|
18
|
+
"DanteEventDispatcher",
|
|
19
|
+
"EventType",
|
|
20
|
+
"DanteSubscription",
|
|
21
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
import ifaddr
|
|
4
|
+
|
|
5
|
+
DEFAULT_MDNS_TIMEOUT = 5
|
|
6
|
+
DEFAULT_INTERFACE = None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_available_interfaces():
|
|
10
|
+
interfaces = []
|
|
11
|
+
adapters = ifaddr.get_adapters()
|
|
12
|
+
|
|
13
|
+
for adapter in adapters:
|
|
14
|
+
for ip in adapter.ips:
|
|
15
|
+
if isinstance(ip.ip, str):
|
|
16
|
+
interfaces.append((adapter.nice_name, ip.ip, ip.network_prefix))
|
|
17
|
+
|
|
18
|
+
return sorted(interfaces)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AppSettings:
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self._mdns_timeout: float = DEFAULT_MDNS_TIMEOUT
|
|
24
|
+
self.dump_payloads: bool = False
|
|
25
|
+
self.debug: bool = False
|
|
26
|
+
self.no_color: bool = False
|
|
27
|
+
self._interface: str = DEFAULT_INTERFACE
|
|
28
|
+
self._interface_ip: str = None
|
|
29
|
+
self.refresh: bool = False
|
|
30
|
+
self.socket_path: str = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def mdns_timeout(self) -> float:
|
|
34
|
+
return self._mdns_timeout
|
|
35
|
+
|
|
36
|
+
@mdns_timeout.setter
|
|
37
|
+
def mdns_timeout(self, value: float) -> None:
|
|
38
|
+
if value > 0:
|
|
39
|
+
self._mdns_timeout = value
|
|
40
|
+
else:
|
|
41
|
+
print(
|
|
42
|
+
f"Warning: mDNS timeout must be positive. Received {value}. Using default {DEFAULT_MDNS_TIMEOUT}s instead.",
|
|
43
|
+
file=sys.stderr,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
self._mdns_timeout = DEFAULT_MDNS_TIMEOUT
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def interface(self) -> str:
|
|
50
|
+
return self._interface
|
|
51
|
+
|
|
52
|
+
@interface.setter
|
|
53
|
+
def interface(self, value: str) -> None:
|
|
54
|
+
self._interface = value
|
|
55
|
+
self._interface_ip = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def interface_ip(self) -> str:
|
|
59
|
+
if not self._interface:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
if self._interface_ip:
|
|
63
|
+
return self._interface_ip
|
|
64
|
+
|
|
65
|
+
adapters = ifaddr.get_adapters()
|
|
66
|
+
|
|
67
|
+
for adapter in adapters:
|
|
68
|
+
if adapter.nice_name == self._interface:
|
|
69
|
+
ipv4_addresses = [ip.ip for ip in adapter.ips if isinstance(ip.ip, str)]
|
|
70
|
+
|
|
71
|
+
if ipv4_addresses:
|
|
72
|
+
self._interface_ip = ipv4_addresses[0]
|
|
73
|
+
|
|
74
|
+
print(
|
|
75
|
+
f"Using IPv4 address {self._interface_ip} for interface {self._interface}",
|
|
76
|
+
file=sys.stderr,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return self._interface_ip
|
|
80
|
+
|
|
81
|
+
print(
|
|
82
|
+
f"No IPv4 address found for interface {self._interface}",
|
|
83
|
+
file=sys.stderr,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
print(
|
|
89
|
+
f"Warning: Could not find interface '{self._interface}'. Using default interface.",
|
|
90
|
+
file=sys.stderr,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
settings = AppSettings()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, Optional, Union
|
|
5
|
+
|
|
6
|
+
from sqlitedict import SqliteDict
|
|
7
|
+
|
|
8
|
+
DEFAULT_CACHE_TTL = 600
|
|
9
|
+
CACHE_FILENAME = "netaudio_mdns_cache.sqlite"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MdnsCache:
|
|
13
|
+
def __init__(self, ttl: int = DEFAULT_CACHE_TTL, cache_dir: Optional[str] = None):
|
|
14
|
+
self.ttl = ttl
|
|
15
|
+
if cache_dir is None:
|
|
16
|
+
cache_dir = tempfile.gettempdir()
|
|
17
|
+
|
|
18
|
+
self.cache_file_path = os.path.join(cache_dir, CACHE_FILENAME)
|
|
19
|
+
|
|
20
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
self._db = SqliteDict(self.cache_file_path, autocommit=True)
|
|
23
|
+
|
|
24
|
+
def get(self, key: str) -> Optional[Dict[str, Any]]:
|
|
25
|
+
if key not in self._db:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
entry: Union[Dict[str, Any], None] = self._db.get(key)
|
|
29
|
+
|
|
30
|
+
if (
|
|
31
|
+
entry is None
|
|
32
|
+
or not isinstance(entry, dict)
|
|
33
|
+
or "last_seen" not in entry
|
|
34
|
+
or "data" not in entry
|
|
35
|
+
):
|
|
36
|
+
self.delete(key)
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
last_seen_timestamp = entry.get("last_seen", 0)
|
|
40
|
+
if not isinstance(last_seen_timestamp, (int, float)):
|
|
41
|
+
self.delete(key)
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
if time.time() - last_seen_timestamp > self.ttl:
|
|
45
|
+
self.delete(key)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
return entry.get("data")
|
|
49
|
+
|
|
50
|
+
def set(self, key: str, value: Dict[str, Any]) -> None:
|
|
51
|
+
entry = {"data": value, "last_seen": time.time()}
|
|
52
|
+
self._db[key] = entry
|
|
53
|
+
|
|
54
|
+
def delete(self, key: str) -> None:
|
|
55
|
+
if key in self._db:
|
|
56
|
+
del self._db[key]
|
|
57
|
+
|
|
58
|
+
def clear(self) -> None:
|
|
59
|
+
self._db.clear()
|
|
60
|
+
|
|
61
|
+
def close(self) -> None:
|
|
62
|
+
if hasattr(self, "_db") and self._db is not None:
|
|
63
|
+
self._db.close()
|
|
64
|
+
|
|
65
|
+
def __del__(self):
|
|
66
|
+
self.close()
|
|
67
|
+
|
|
68
|
+
def __enter__(self):
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
72
|
+
self.close()
|
|
73
|
+
return False
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_runtime_dir() -> Path:
|
|
7
|
+
if sys.platform == "win32":
|
|
8
|
+
return Path(os.environ.get("TEMP", os.environ.get("TMP", "C:\\Temp")))
|
|
9
|
+
|
|
10
|
+
xdg = os.environ.get("XDG_RUNTIME_DIR")
|
|
11
|
+
if xdg:
|
|
12
|
+
return Path(xdg)
|
|
13
|
+
|
|
14
|
+
tmpdir = os.environ.get("TMPDIR")
|
|
15
|
+
if tmpdir:
|
|
16
|
+
return Path(tmpdir)
|
|
17
|
+
|
|
18
|
+
return Path("/tmp")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_socket_dir() -> Path:
|
|
22
|
+
from netaudio_lib.common.app_config import settings as app_settings
|
|
23
|
+
|
|
24
|
+
if app_settings.socket_path:
|
|
25
|
+
return Path(app_settings.socket_path).parent
|
|
26
|
+
|
|
27
|
+
runtime_dir = get_runtime_dir()
|
|
28
|
+
socket_dir = runtime_dir / "netaudio"
|
|
29
|
+
return socket_dir
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_socket_path() -> Path:
|
|
33
|
+
from netaudio_lib.common.app_config import settings as app_settings
|
|
34
|
+
|
|
35
|
+
if app_settings.socket_path:
|
|
36
|
+
return Path(app_settings.socket_path)
|
|
37
|
+
|
|
38
|
+
return get_socket_dir() / "netaudio.sock"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ensure_socket_dir() -> Path:
|
|
42
|
+
socket_dir = get_socket_dir()
|
|
43
|
+
socket_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
return socket_dir
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import pickle
|
|
4
|
+
import struct
|
|
5
|
+
|
|
6
|
+
from netaudio_lib.common.socket_path import get_socket_path
|
|
7
|
+
from netaudio_lib.dante.device import DanteDevice
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("netaudio")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def get_devices_from_daemon() -> dict[str, DanteDevice] | None:
|
|
13
|
+
socket_path = get_socket_path()
|
|
14
|
+
|
|
15
|
+
if not socket_path.exists():
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
reader, writer = await asyncio.wait_for(
|
|
20
|
+
asyncio.open_unix_connection(str(socket_path)),
|
|
21
|
+
timeout=1.0,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
writer.write(b'\x00')
|
|
25
|
+
await writer.drain()
|
|
26
|
+
|
|
27
|
+
length_data = await asyncio.wait_for(reader.readexactly(4), timeout=1.0)
|
|
28
|
+
length = struct.unpack(">I", length_data)[0]
|
|
29
|
+
|
|
30
|
+
data = await asyncio.wait_for(reader.readexactly(length), timeout=2.0)
|
|
31
|
+
devices = pickle.loads(data)
|
|
32
|
+
|
|
33
|
+
writer.close()
|
|
34
|
+
await writer.wait_closed()
|
|
35
|
+
|
|
36
|
+
logger.info(f"Daemon: {len(devices)} devices")
|
|
37
|
+
return devices
|
|
38
|
+
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
return None
|
|
41
|
+
except ConnectionRefusedError:
|
|
42
|
+
return None
|
|
43
|
+
except asyncio.TimeoutError:
|
|
44
|
+
logger.warning("Daemon connection timed out")
|
|
45
|
+
return None
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.debug(f"Daemon connection error: {e}")
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def report_unresponsive_device(server_name: str) -> None:
|
|
52
|
+
socket_path = get_socket_path()
|
|
53
|
+
|
|
54
|
+
if not socket_path.exists():
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
reader, writer = await asyncio.wait_for(
|
|
59
|
+
asyncio.open_unix_connection(str(socket_path)),
|
|
60
|
+
timeout=1.0,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
name_bytes = server_name.encode("utf-8")
|
|
64
|
+
writer.write(b'\x01')
|
|
65
|
+
writer.write(struct.pack(">I", len(name_bytes)))
|
|
66
|
+
writer.write(name_bytes)
|
|
67
|
+
await writer.drain()
|
|
68
|
+
|
|
69
|
+
writer.close()
|
|
70
|
+
await writer.wait_closed()
|
|
71
|
+
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.debug(f"Failed to report dead device: {e}")
|