netaudio 0.2.5__py3-none-win_amd64.whl
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/__init__.py +21 -0
- netaudio/__main__.py +3 -0
- netaudio/_capture.py +174 -0
- netaudio/_common.py +489 -0
- netaudio/_exit_codes.py +6 -0
- netaudio/capture/__init__.py +3 -0
- netaudio/capture/analysis.py +87 -0
- netaudio/capture/daemon.py +695 -0
- netaudio/capture/display.py +456 -0
- netaudio/capture/fact.py +303 -0
- netaudio/capture/interfaces.py +60 -0
- netaudio/capture/markers.py +57 -0
- netaudio/capture/packets.py +173 -0
- netaudio/capture/provenance.py +572 -0
- netaudio/capture/sessions.py +21 -0
- netaudio/cli.py +260 -0
- netaudio/commands/__init__.py +0 -0
- netaudio/commands/capture.py +1932 -0
- netaudio/commands/capture_helpers.py +373 -0
- netaudio/commands/channel.py +149 -0
- netaudio/commands/config.py +420 -0
- netaudio/commands/device.py +1079 -0
- netaudio/commands/diagnose.py +119 -0
- netaudio/commands/fact.py +1023 -0
- netaudio/commands/firmware.py +1267 -0
- netaudio/commands/flow.py +159 -0
- netaudio/commands/key.py +85 -0
- netaudio/commands/provenance.py +1645 -0
- netaudio/commands/server.py +358 -0
- netaudio/commands/shure.py +1012 -0
- netaudio/commands/status.py +137 -0
- netaudio/commands/subscription.py +298 -0
- netaudio/commands/virtual.py +176 -0
- netaudio/common/__init__.py +0 -0
- netaudio/common/app_config.py +115 -0
- netaudio/common/config_loader.py +194 -0
- netaudio/common/key_extract.py +74 -0
- netaudio/common/mdns_cache.py +73 -0
- netaudio/core/__init__.py +23 -0
- netaudio/core/binding.py +393 -0
- netaudio/core/netaudio_core.dll +0 -0
- netaudio/daemon/__init__.py +8 -0
- netaudio/daemon/client.py +143 -0
- netaudio/daemon/dbus_interfaces.py +335 -0
- netaudio/daemon/dbus_service.py +304 -0
- netaudio/daemon/enforcement.py +707 -0
- netaudio/daemon/metering.py +353 -0
- netaudio/daemon/relay.py +757 -0
- netaudio/daemon/server.py +650 -0
- netaudio/daemon/service_install.py +336 -0
- netaudio/dante/__init__.py +0 -0
- netaudio/dante/application.py +627 -0
- netaudio/dante/browser.py +387 -0
- netaudio/dante/channel.py +133 -0
- netaudio/dante/clean_labels.py +269 -0
- netaudio/dante/const.py +146 -0
- netaudio/dante/debug_formatter.py +528 -0
- netaudio/dante/device.py +381 -0
- netaudio/dante/device_commands.py +212 -0
- netaudio/dante/device_operations.py +352 -0
- netaudio/dante/device_parser.py +19 -0
- netaudio/dante/device_serializer.py +205 -0
- netaudio/dante/device_xml_serializer.py +163 -0
- netaudio/dante/events.py +90 -0
- netaudio/dante/fact_store.py +503 -0
- netaudio/dante/flows.py +80 -0
- netaudio/dante/metering.py +112 -0
- netaudio/dante/packet_dissector.py +1276 -0
- netaudio/dante/packet_store.py +1192 -0
- netaudio/dante/protocol_verifier.py +496 -0
- netaudio/dante/service.py +196 -0
- netaudio/dante/services/__init__.py +9 -0
- netaudio/dante/services/cmc.py +182 -0
- netaudio/dante/services/heartbeat.py +112 -0
- netaudio/dante/services/notification.py +595 -0
- netaudio/dante/services/settings.py +70 -0
- netaudio/dante/state.py +377 -0
- netaudio/dante/subscription.py +155 -0
- netaudio/dante/transport.py +115 -0
- netaudio/dante/tshark_capture.py +341 -0
- netaudio/dante/virtual_device.py +886 -0
- netaudio/icons.py +93 -0
- netaudio/shure/__init__.py +0 -0
- netaudio/shure/device.py +271 -0
- netaudio/shure/manager.py +521 -0
- netaudio-0.2.5.dist-info/METADATA +101 -0
- netaudio-0.2.5.dist-info/RECORD +90 -0
- netaudio-0.2.5.dist-info/WHEEL +4 -0
- netaudio-0.2.5.dist-info/entry_points.txt +2 -0
- netaudio-0.2.5.dist-info/licenses/LICENSE +19 -0
netaudio/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
__version__ = version("netaudio")
|
|
4
|
+
|
|
5
|
+
from netaudio.dante.application import DanteApplication
|
|
6
|
+
from netaudio.dante.browser import DanteBrowser
|
|
7
|
+
from netaudio.dante.channel import DanteChannel
|
|
8
|
+
from netaudio.dante.device import DanteDevice
|
|
9
|
+
from netaudio.dante.events import DanteEvent, DanteEventDispatcher, EventType
|
|
10
|
+
from netaudio.dante.subscription import DanteSubscription
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"DanteApplication",
|
|
14
|
+
"DanteBrowser",
|
|
15
|
+
"DanteChannel",
|
|
16
|
+
"DanteDevice",
|
|
17
|
+
"DanteEvent",
|
|
18
|
+
"DanteEventDispatcher",
|
|
19
|
+
"EventType",
|
|
20
|
+
"DanteSubscription",
|
|
21
|
+
]
|
netaudio/__main__.py
ADDED
netaudio/_capture.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from netaudio import core
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger("netaudio")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def open_capture_session():
|
|
10
|
+
from netaudio.cli import state
|
|
11
|
+
|
|
12
|
+
if not state.capture:
|
|
13
|
+
return None, None
|
|
14
|
+
|
|
15
|
+
from netaudio.common.config_loader import load_capture_profile, resolve_db_from_config
|
|
16
|
+
from netaudio.dante.packet_store import PacketStore
|
|
17
|
+
|
|
18
|
+
profile_cfg, _ = load_capture_profile(None, None)
|
|
19
|
+
db_path = resolve_db_from_config(None, profile_cfg)
|
|
20
|
+
store = PacketStore(db_path=db_path)
|
|
21
|
+
active = store.get_latest_session(active_only=True)
|
|
22
|
+
return store, (active["id"] if active else None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CaptureObserver:
|
|
26
|
+
def __init__(self, store, session_id, dissect):
|
|
27
|
+
self.store = store
|
|
28
|
+
self.session_id = session_id
|
|
29
|
+
self.dissect = dissect
|
|
30
|
+
self.buffer = []
|
|
31
|
+
|
|
32
|
+
def __call__(self, packet, response, device_ip, port):
|
|
33
|
+
self.buffer.append((packet, device_ip, port, "request", "netaudio_request"))
|
|
34
|
+
if self.dissect:
|
|
35
|
+
_dissect(packet, device_ip, port, "request")
|
|
36
|
+
if response is not None:
|
|
37
|
+
self.buffer.append((response, device_ip, port, "response", "netaudio_response"))
|
|
38
|
+
if self.dissect:
|
|
39
|
+
_dissect(response, device_ip, port, "response")
|
|
40
|
+
|
|
41
|
+
def flush(self):
|
|
42
|
+
if not self.store:
|
|
43
|
+
self.buffer.clear()
|
|
44
|
+
return
|
|
45
|
+
for payload, device_ip, port, direction, source_type in self.buffer:
|
|
46
|
+
_record(self.store, self.session_id, payload, device_ip, port, direction, source_type)
|
|
47
|
+
self.buffer.clear()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def make_observer(store, session_id, dissect):
|
|
51
|
+
return CaptureObserver(store, session_id, dissect)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _record(store, session_id, payload, device_ip, port, direction, source_type):
|
|
55
|
+
try:
|
|
56
|
+
store.store_packet(
|
|
57
|
+
payload=payload,
|
|
58
|
+
source_type=source_type,
|
|
59
|
+
device_ip=device_ip,
|
|
60
|
+
dst_ip=device_ip if direction == "request" else None,
|
|
61
|
+
dst_port=port if direction == "request" else None,
|
|
62
|
+
src_ip=device_ip if direction == "response" else None,
|
|
63
|
+
src_port=port if direction == "response" else None,
|
|
64
|
+
direction=direction,
|
|
65
|
+
session_id=session_id,
|
|
66
|
+
)
|
|
67
|
+
except Exception as exception:
|
|
68
|
+
logger.debug(f"PacketStore error ({direction}): {exception}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _dissect(payload, device_ip, port, direction):
|
|
72
|
+
try:
|
|
73
|
+
from netaudio.common.app_config import settings
|
|
74
|
+
from netaudio.dante.packet_dissector import dissect_and_render, format_dissect_label
|
|
75
|
+
|
|
76
|
+
color = not settings.no_color
|
|
77
|
+
label = format_dissect_label(direction, f"{device_ip}:{port}", color=color)
|
|
78
|
+
rendered = dissect_and_render(payload, indent=" ", color=color)
|
|
79
|
+
logger.info(f"Dissect [{label}] {len(payload)}B:\n{rendered}")
|
|
80
|
+
except Exception as exception:
|
|
81
|
+
logger.debug(f"Dissect error: {exception}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _query(client, spec, port, parse_kind=None, starting_channel=None):
|
|
85
|
+
packet = core.build_command(spec)
|
|
86
|
+
response = client.request(packet, port)
|
|
87
|
+
if parse_kind is None:
|
|
88
|
+
return response
|
|
89
|
+
if not response:
|
|
90
|
+
return None
|
|
91
|
+
if starting_channel is not None:
|
|
92
|
+
return core.parse_page(parse_kind, response, starting_channel)
|
|
93
|
+
return core.parse_response(parse_kind, response)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def fetch_device_name(client, arc_port):
|
|
97
|
+
return _query(client, {"command": "device_name"}, arc_port, "device_name")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fetch_rx_records(client, arc_port):
|
|
101
|
+
rx = []
|
|
102
|
+
page = 0
|
|
103
|
+
while True:
|
|
104
|
+
records = _query(client, {"command": "receivers", "page": page}, arc_port, "rx", page * 16 + 1) or []
|
|
105
|
+
rx.extend(records)
|
|
106
|
+
if len(records) < 16:
|
|
107
|
+
break
|
|
108
|
+
page += 1
|
|
109
|
+
return rx
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def fetch_tx_records(client, arc_port):
|
|
113
|
+
friendly = {}
|
|
114
|
+
page = 0
|
|
115
|
+
while True:
|
|
116
|
+
records = _query(
|
|
117
|
+
client, {"command": "transmitters", "page": page, "friendly_names": True}, arc_port, "tx_friendly", page * 32 + 1
|
|
118
|
+
) or []
|
|
119
|
+
for number, friendly_name in records:
|
|
120
|
+
if friendly_name:
|
|
121
|
+
friendly[number] = friendly_name
|
|
122
|
+
if len(records) < 32:
|
|
123
|
+
break
|
|
124
|
+
page += 1
|
|
125
|
+
|
|
126
|
+
tx = []
|
|
127
|
+
page = 0
|
|
128
|
+
while True:
|
|
129
|
+
records = _query(
|
|
130
|
+
client, {"command": "transmitters", "page": page, "friendly_names": False}, arc_port, "tx_info", page * 32 + 1
|
|
131
|
+
) or []
|
|
132
|
+
for record in records:
|
|
133
|
+
record["friendly_name"] = friendly.get(record["number"])
|
|
134
|
+
tx.extend(records)
|
|
135
|
+
if len(records) < 32:
|
|
136
|
+
break
|
|
137
|
+
page += 1
|
|
138
|
+
return tx
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _fetch_instrumented(client, arc_port):
|
|
142
|
+
name = fetch_device_name(client, arc_port)
|
|
143
|
+
counts = _query(client, {"command": "channel_count"}, arc_port, "channel_count")
|
|
144
|
+
if counts is None:
|
|
145
|
+
counts = {"tx_count": 0, "rx_count": 0, "locked": None}
|
|
146
|
+
|
|
147
|
+
rx = fetch_rx_records(client, arc_port)
|
|
148
|
+
tx = fetch_tx_records(client, arc_port)
|
|
149
|
+
|
|
150
|
+
settings_data = _query(client, {"command": "device_settings"}, arc_port, "device_settings")
|
|
151
|
+
aes67 = _query(client, {"command": "query_latency_config"}, arc_port, "aes67_configured")
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
"name": name,
|
|
155
|
+
"counts": (counts["tx_count"], counts["rx_count"], counts["locked"]),
|
|
156
|
+
"rx": rx,
|
|
157
|
+
"tx": tx,
|
|
158
|
+
"settings": settings_data,
|
|
159
|
+
"aes67": aes67,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def populate_instrumented(device, observer):
|
|
164
|
+
arc_port = device._arc_port()
|
|
165
|
+
client = core.CoreClient(str(device.ipv4), arc_port=arc_port)
|
|
166
|
+
client.observer = observer
|
|
167
|
+
mac = core.host_mac()
|
|
168
|
+
if mac:
|
|
169
|
+
client.set_host_mac(mac)
|
|
170
|
+
try:
|
|
171
|
+
data = await asyncio.to_thread(_fetch_instrumented, client, arc_port)
|
|
172
|
+
device.apply_controls(device.controls_data_from_core(data))
|
|
173
|
+
finally:
|
|
174
|
+
client.close()
|
netaudio/_common.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import csv
|
|
5
|
+
import io
|
|
6
|
+
import json as json_module
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from fnmatch import fnmatch
|
|
10
|
+
from typing import Any, Callable, Optional
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from netaudio import DanteDevice
|
|
15
|
+
from netaudio.common.app_config import settings
|
|
16
|
+
from netaudio.daemon.client import get_devices_from_daemon
|
|
17
|
+
from netaudio.dante.application import DanteApplication
|
|
18
|
+
from netaudio.dante.const import SERVICE_ARC
|
|
19
|
+
|
|
20
|
+
from netaudio._exit_codes import ExitCode
|
|
21
|
+
from netaudio.icons import icon
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ansi(code: str, text: str) -> str:
|
|
25
|
+
if settings.no_color:
|
|
26
|
+
return str(text)
|
|
27
|
+
return f"\033[{code}m{text}\033[0m"
|
|
28
|
+
|
|
29
|
+
HEADER_ICONS = {
|
|
30
|
+
"Name": "name",
|
|
31
|
+
"IP Address": "ip",
|
|
32
|
+
"IP": "ip",
|
|
33
|
+
"MAC Address": "mac",
|
|
34
|
+
"Clock MAC": "mac",
|
|
35
|
+
"Model": "model",
|
|
36
|
+
"TX": "tx",
|
|
37
|
+
"RX": "rx",
|
|
38
|
+
"Last Seen": "last_seen",
|
|
39
|
+
"Server Name": "server",
|
|
40
|
+
"Manufacturer": "manufacturer",
|
|
41
|
+
"Product Version": "version",
|
|
42
|
+
"Board": "board",
|
|
43
|
+
"Firmware": "firmware",
|
|
44
|
+
"Software": "software",
|
|
45
|
+
"Sample Rate": "sample_rate",
|
|
46
|
+
"Encoding": "encoding",
|
|
47
|
+
"Bit Depth": "bit_depth",
|
|
48
|
+
"Latency": "latency",
|
|
49
|
+
"Flows": "flow",
|
|
50
|
+
"Bluetooth": "bluetooth",
|
|
51
|
+
"Status": "status",
|
|
52
|
+
"Label": "label",
|
|
53
|
+
"Summary": "summary",
|
|
54
|
+
"Reported": "reported",
|
|
55
|
+
"Updated": "updated",
|
|
56
|
+
"Sessions": "session",
|
|
57
|
+
"Tags": "tag",
|
|
58
|
+
"Context": "context",
|
|
59
|
+
"RX Channel": "rx",
|
|
60
|
+
"RX Device": "device",
|
|
61
|
+
"TX Channel": "tx",
|
|
62
|
+
"TX Device": "device",
|
|
63
|
+
"#": "number",
|
|
64
|
+
"Friendly Name": "friendly_name",
|
|
65
|
+
"Role": "role",
|
|
66
|
+
"Grandmaster": "grandmaster",
|
|
67
|
+
"Direction": "direction",
|
|
68
|
+
"Channel": "channel",
|
|
69
|
+
"Channel Name": "channel",
|
|
70
|
+
"Level": "level",
|
|
71
|
+
"Timestamp": "wall_time",
|
|
72
|
+
"Online": "online",
|
|
73
|
+
"Receiving": "receiving",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _iconize_headers(headers: list[str]) -> list[str]:
|
|
78
|
+
return [f"{icon(HEADER_ICONS[header])}{header}" if header in HEADER_ICONS else header for header in headers]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get_state():
|
|
82
|
+
from netaudio.cli import state
|
|
83
|
+
return state
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def _discover(packet_store=None, session_id=None) -> dict[str, DanteDevice]:
|
|
87
|
+
devices = await get_devices_from_daemon()
|
|
88
|
+
|
|
89
|
+
if devices is None:
|
|
90
|
+
owns_store = False
|
|
91
|
+
if packet_store is None:
|
|
92
|
+
from netaudio._capture import open_capture_session
|
|
93
|
+
packet_store, session_id = open_capture_session()
|
|
94
|
+
owns_store = packet_store is not None
|
|
95
|
+
application = DanteApplication(packet_store=packet_store, dissect=_get_state().dissect)
|
|
96
|
+
if packet_store and session_id:
|
|
97
|
+
application.capture_session_id = session_id
|
|
98
|
+
for service in (application.settings, application.cmc, application.notifications):
|
|
99
|
+
service.session_id = session_id
|
|
100
|
+
await application.startup()
|
|
101
|
+
try:
|
|
102
|
+
devices = await application.discover_and_populate(timeout=settings.mdns_timeout)
|
|
103
|
+
finally:
|
|
104
|
+
await application.shutdown()
|
|
105
|
+
if owns_store:
|
|
106
|
+
packet_store.close()
|
|
107
|
+
|
|
108
|
+
return devices or {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def discover() -> dict[str, DanteDevice]:
|
|
112
|
+
return asyncio.run(_discover())
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _get_arc_port(device: DanteDevice) -> int:
|
|
116
|
+
if device.services:
|
|
117
|
+
for service_data in device.services.values():
|
|
118
|
+
if service_data.get("type") == SERVICE_ARC:
|
|
119
|
+
return service_data.get("port", 4440)
|
|
120
|
+
return 4440
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _resolve_one(devices: dict[str, DanteDevice]) -> tuple[str, DanteDevice]:
|
|
124
|
+
if len(devices) == 0:
|
|
125
|
+
typer.echo("Error: device not found.", err=True)
|
|
126
|
+
raise typer.Exit(code=ExitCode.ERROR)
|
|
127
|
+
|
|
128
|
+
if len(devices) > 1:
|
|
129
|
+
names = ", ".join(d.name or sn for sn, d in devices.items())
|
|
130
|
+
typer.echo(f"Error: multiple devices matched: {names}", err=True)
|
|
131
|
+
raise typer.Exit(code=ExitCode.ERROR)
|
|
132
|
+
|
|
133
|
+
return next(iter(devices.items()))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _make_core_sender(observer=None) -> Callable:
|
|
137
|
+
from netaudio import core
|
|
138
|
+
|
|
139
|
+
clients: dict[str, Any] = {}
|
|
140
|
+
mac = core.host_mac()
|
|
141
|
+
|
|
142
|
+
async def _send(packet: bytes, device_ip, port: int) -> bytes | None:
|
|
143
|
+
ip = str(device_ip)
|
|
144
|
+
client = clients.get(ip)
|
|
145
|
+
if client is None:
|
|
146
|
+
client = core.CoreClient(ip)
|
|
147
|
+
if mac:
|
|
148
|
+
client.set_host_mac(mac)
|
|
149
|
+
client.observer = observer
|
|
150
|
+
clients[ip] = client
|
|
151
|
+
return await asyncio.to_thread(client.request, packet, port, True)
|
|
152
|
+
|
|
153
|
+
return _send
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _capture_observer():
|
|
157
|
+
state = _get_state()
|
|
158
|
+
if not state.capture and not state.dissect:
|
|
159
|
+
return None, None
|
|
160
|
+
from netaudio._capture import make_observer, open_capture_session
|
|
161
|
+
|
|
162
|
+
store, session_id = open_capture_session()
|
|
163
|
+
observer = make_observer(store, session_id, state.dissect)
|
|
164
|
+
if store and session_id:
|
|
165
|
+
typer.echo(f"Capture: recording to session #{session_id}", err=True)
|
|
166
|
+
return observer, store
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@asynccontextmanager
|
|
170
|
+
async def _command_context():
|
|
171
|
+
observer, store = _capture_observer()
|
|
172
|
+
session_id = None
|
|
173
|
+
if store:
|
|
174
|
+
active = store.get_latest_session(active_only=True)
|
|
175
|
+
session_id = active["id"] if active else None
|
|
176
|
+
try:
|
|
177
|
+
devices = await get_devices_from_daemon()
|
|
178
|
+
if devices is None:
|
|
179
|
+
devices = await _discover(packet_store=store, session_id=session_id)
|
|
180
|
+
if observer is not None:
|
|
181
|
+
for device in devices.values():
|
|
182
|
+
device.rx_channels = {}
|
|
183
|
+
device.tx_channels = {}
|
|
184
|
+
await _populate_controls(devices, observer=observer)
|
|
185
|
+
|
|
186
|
+
yield devices or {}, _make_core_sender(observer=observer)
|
|
187
|
+
finally:
|
|
188
|
+
if observer is not None:
|
|
189
|
+
observer.flush()
|
|
190
|
+
if store:
|
|
191
|
+
store.close()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def _populate_controls(devices: dict[str, DanteDevice], observer=None) -> None:
|
|
195
|
+
unpopulated = [
|
|
196
|
+
device
|
|
197
|
+
for device in devices.values()
|
|
198
|
+
if not device.tx_channels and not device.rx_channels and device.ipv4
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
if not unpopulated:
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
if observer is not None:
|
|
205
|
+
from netaudio._capture import populate_instrumented
|
|
206
|
+
await asyncio.gather(
|
|
207
|
+
*(populate_instrumented(device, observer) for device in unpopulated),
|
|
208
|
+
return_exceptions=True,
|
|
209
|
+
)
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
await asyncio.gather(
|
|
213
|
+
*(device.populate_from_core() for device in unpopulated),
|
|
214
|
+
return_exceptions=True,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _normalize_mac(mac: str) -> str:
|
|
219
|
+
raw = mac.replace(":", "").replace("-", "").replace(".", "").lower()
|
|
220
|
+
if len(raw) == 16 and raw[6:10] == "fffe":
|
|
221
|
+
raw = raw[:6] + raw[10:]
|
|
222
|
+
elif len(raw) == 16 and raw.endswith("0000"):
|
|
223
|
+
raw = raw[:12]
|
|
224
|
+
return raw
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _strip_separators(mac: str) -> str:
|
|
228
|
+
return mac.replace(":", "").replace("-", "").replace(".", "").lower()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _mac_matches(device_mac: str, pattern: str) -> bool:
|
|
232
|
+
raw_device = _strip_separators(device_mac)
|
|
233
|
+
raw_pattern = _strip_separators(pattern)
|
|
234
|
+
|
|
235
|
+
if raw_device == raw_pattern:
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
return _normalize_mac(device_mac) == _normalize_mac(pattern)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def filter_devices(devices: dict[str, DanteDevice]) -> dict[str, DanteDevice]:
|
|
242
|
+
state = _get_state()
|
|
243
|
+
|
|
244
|
+
if not state.names and not state.hosts and not state.server_names and not state.macs:
|
|
245
|
+
return devices
|
|
246
|
+
|
|
247
|
+
filtered = {}
|
|
248
|
+
|
|
249
|
+
for server_name, device in devices.items():
|
|
250
|
+
if state.names and not any(fnmatch(device.name or "", pat) for pat in state.names):
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
if state.hosts and not any(str(device.ipv4) == h for h in state.hosts):
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
if state.server_names and not any(fnmatch(server_name, pat) for pat in state.server_names):
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
if state.macs and not any(_mac_matches(device.mac_address or "", pat) for pat in state.macs):
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
filtered[server_name] = device
|
|
263
|
+
|
|
264
|
+
return filtered
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def sort_devices(devices: dict[str, DanteDevice]) -> list[tuple[str, DanteDevice]]:
|
|
268
|
+
state = _get_state()
|
|
269
|
+
|
|
270
|
+
sort_keys = {
|
|
271
|
+
"mac": lambda item: item[1].mac_address or "",
|
|
272
|
+
"name": lambda item: item[1].name or "",
|
|
273
|
+
"ip": lambda item: tuple(int(part) for part in str(item[1].ipv4).split(".")) if item[1].ipv4 else (0,),
|
|
274
|
+
"model": lambda item: item[1].model_id or "",
|
|
275
|
+
"server-name": lambda item: item[0],
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return sorted(devices.items(), key=sort_keys[state.sort_field], reverse=state.sort_reverse)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def set_device_filter(device_arg: str) -> None:
|
|
283
|
+
state = _get_state()
|
|
284
|
+
state.names = [device_arg]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def parse_qualified_name(s: str) -> tuple[str, str]:
|
|
288
|
+
if "@" not in s:
|
|
289
|
+
typer.echo(f"Error: expected channel@device format, got: {s}", err=True)
|
|
290
|
+
raise typer.Exit(code=ExitCode.ERROR)
|
|
291
|
+
|
|
292
|
+
channel, device = s.rsplit("@", 1)
|
|
293
|
+
return channel, device
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _format_text(headers: list[str], rows: list[list[str]]) -> str:
|
|
297
|
+
all_rows = [headers] + [[str(value) for value in row] for row in rows]
|
|
298
|
+
widths = [max(len(row[i]) for row in all_rows) for i in range(len(headers))]
|
|
299
|
+
numeric = [
|
|
300
|
+
all(row[i].isdigit() for row in all_rows[1:] if row[i]) for i in range(len(headers))
|
|
301
|
+
]
|
|
302
|
+
lines = []
|
|
303
|
+
for row in all_rows:
|
|
304
|
+
parts = [row[i].rjust(widths[i]) if numeric[i] and row is not all_rows[0] else row[i].ljust(widths[i]) for i in range(len(row))]
|
|
305
|
+
lines.append(" ".join(parts).rstrip())
|
|
306
|
+
return "\n".join(lines)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _format_csv(headers: list[str], rows: list[list[str]]) -> str:
|
|
310
|
+
buffer = io.StringIO()
|
|
311
|
+
writer = csv.writer(buffer)
|
|
312
|
+
writer.writerow(headers)
|
|
313
|
+
writer.writerows(rows)
|
|
314
|
+
return buffer.getvalue().rstrip("\n")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _format_json(data: Any) -> str:
|
|
318
|
+
return json_module.dumps(data, indent=2, default=str)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _device_to_preset_xml(device: DanteDevice) -> ET.Element:
|
|
322
|
+
element = ET.Element("device")
|
|
323
|
+
|
|
324
|
+
_sub_text(element, "name", device.name or "")
|
|
325
|
+
_sub_text(element, "default_name", device.server_name.replace(".local.", "") if device.server_name else "")
|
|
326
|
+
|
|
327
|
+
instance_id = ET.SubElement(element, "instance_id")
|
|
328
|
+
_sub_text(instance_id, "device_id", (device.mac_address or "").upper())
|
|
329
|
+
_sub_text(instance_id, "process_id", "0")
|
|
330
|
+
|
|
331
|
+
if device.manufacturer:
|
|
332
|
+
_sub_text(element, "manufacturer_name", device.manufacturer)
|
|
333
|
+
if device.model_id:
|
|
334
|
+
_sub_text(element, "model_name", device.model_id)
|
|
335
|
+
|
|
336
|
+
_sub_text(element, "friendly_name", device.name or "")
|
|
337
|
+
|
|
338
|
+
if device.sample_rate:
|
|
339
|
+
_sub_text(element, "samplerate", str(device.sample_rate))
|
|
340
|
+
|
|
341
|
+
for channel in sorted(device.tx_channels.values(), key=lambda channel: channel.number):
|
|
342
|
+
tx_element = ET.SubElement(element, "txchannel", danteId=str(channel.number), mediaType="audio")
|
|
343
|
+
_sub_text(tx_element, "label", channel.friendly_name or channel.name)
|
|
344
|
+
|
|
345
|
+
for channel in sorted(device.rx_channels.values(), key=lambda channel: channel.number):
|
|
346
|
+
rx_element = ET.SubElement(element, "rxchannel", danteId=str(channel.number), mediaType="audio")
|
|
347
|
+
_sub_text(rx_element, "name", channel.friendly_name or channel.name)
|
|
348
|
+
|
|
349
|
+
for subscription in device.subscriptions:
|
|
350
|
+
if subscription.rx_channel_name == channel.name or subscription.rx_channel_name == channel.friendly_name:
|
|
351
|
+
if subscription.tx_channel_name:
|
|
352
|
+
_sub_text(rx_element, "subscribed_channel", subscription.tx_channel_name)
|
|
353
|
+
if subscription.tx_device_name:
|
|
354
|
+
_sub_text(rx_element, "subscribed_device", subscription.tx_device_name)
|
|
355
|
+
break
|
|
356
|
+
|
|
357
|
+
return element
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _sub_text(parent: ET.Element, tag: str, text: str) -> ET.Element:
|
|
361
|
+
child = ET.SubElement(parent, tag)
|
|
362
|
+
child.text = text
|
|
363
|
+
return child
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def format_devices_xml(devices: dict[str, DanteDevice], preset_name: str = "netaudio") -> str:
|
|
367
|
+
root = ET.Element("preset", version="2.1.0")
|
|
368
|
+
_sub_text(root, "name", preset_name)
|
|
369
|
+
_sub_text(root, "description", "Dante Controller preset")
|
|
370
|
+
|
|
371
|
+
for server_name, device in sorted(devices.items(), key=lambda item: item[1].name or item[0]):
|
|
372
|
+
root.append(_device_to_preset_xml(device))
|
|
373
|
+
|
|
374
|
+
ET.indent(root, space=" ")
|
|
375
|
+
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + ET.tostring(root, encoding="unicode")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _format_yaml(data: Any) -> str:
|
|
379
|
+
try:
|
|
380
|
+
import yaml
|
|
381
|
+
except ImportError:
|
|
382
|
+
typer.echo("Error: pyyaml not installed. Run: uv add pyyaml", err=True)
|
|
383
|
+
raise typer.Exit(code=ExitCode.ERROR)
|
|
384
|
+
|
|
385
|
+
return yaml.dump(data, default_flow_style=False, sort_keys=False).rstrip("\n")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _format_table(headers: list[str], rows: list[list[str]], title: Optional[str] = None) -> str:
|
|
389
|
+
from rich.console import Console
|
|
390
|
+
from rich.table import Table
|
|
391
|
+
from rich.text import Text
|
|
392
|
+
|
|
393
|
+
state = _get_state()
|
|
394
|
+
table = Table(title=title)
|
|
395
|
+
|
|
396
|
+
for header in headers:
|
|
397
|
+
table.add_column(header)
|
|
398
|
+
|
|
399
|
+
for row in rows:
|
|
400
|
+
table.add_row(*[Text.from_ansi(str(value)) for value in row])
|
|
401
|
+
|
|
402
|
+
console = Console(no_color=state.no_color)
|
|
403
|
+
with console.capture() as capture:
|
|
404
|
+
console.print(table)
|
|
405
|
+
return capture.get().rstrip("\n")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def output_table(
|
|
409
|
+
headers: list[str],
|
|
410
|
+
rows: list[list[str]],
|
|
411
|
+
json_data: Any = None,
|
|
412
|
+
title: Optional[str] = None,
|
|
413
|
+
devices: Optional[dict[str, DanteDevice]] = None,
|
|
414
|
+
) -> None:
|
|
415
|
+
from netaudio.cli import OutputFormat
|
|
416
|
+
state = _get_state()
|
|
417
|
+
output_format = state.output_format
|
|
418
|
+
|
|
419
|
+
if json_data is None:
|
|
420
|
+
json_data = [dict(zip(headers, row)) for row in rows]
|
|
421
|
+
|
|
422
|
+
display_headers = _iconize_headers(headers)
|
|
423
|
+
|
|
424
|
+
if output_format == OutputFormat.plain:
|
|
425
|
+
typer.echo(_format_text(display_headers, rows))
|
|
426
|
+
elif output_format == OutputFormat.table:
|
|
427
|
+
typer.echo(_format_text(display_headers, rows))
|
|
428
|
+
elif output_format == OutputFormat.pretty:
|
|
429
|
+
typer.echo(_format_table(display_headers, rows, title=title))
|
|
430
|
+
elif output_format == OutputFormat.json:
|
|
431
|
+
typer.echo(_format_json(json_data))
|
|
432
|
+
elif output_format == OutputFormat.xml:
|
|
433
|
+
if devices:
|
|
434
|
+
typer.echo(format_devices_xml(devices))
|
|
435
|
+
else:
|
|
436
|
+
typer.echo(_format_json(json_data))
|
|
437
|
+
elif output_format == OutputFormat.csv:
|
|
438
|
+
typer.echo(_format_csv(headers, rows))
|
|
439
|
+
elif output_format == OutputFormat.yaml:
|
|
440
|
+
typer.echo(_format_yaml(json_data))
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def output_single(data: Any, device: Optional[DanteDevice] = None) -> None:
|
|
444
|
+
from netaudio.cli import OutputFormat
|
|
445
|
+
state = _get_state()
|
|
446
|
+
output_format = state.output_format
|
|
447
|
+
|
|
448
|
+
if output_format == OutputFormat.json:
|
|
449
|
+
typer.echo(_format_json(data))
|
|
450
|
+
elif output_format == OutputFormat.xml:
|
|
451
|
+
if device:
|
|
452
|
+
devices = {device.server_name or "device": device}
|
|
453
|
+
typer.echo(format_devices_xml(devices))
|
|
454
|
+
else:
|
|
455
|
+
typer.echo(_format_json(data))
|
|
456
|
+
elif output_format == OutputFormat.yaml:
|
|
457
|
+
typer.echo(_format_yaml(data))
|
|
458
|
+
else:
|
|
459
|
+
typer.echo(data)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def find_device(devices: dict[str, DanteDevice], identifier: str) -> Optional[DanteDevice]:
|
|
463
|
+
for server_name, device in devices.items():
|
|
464
|
+
if device.name == identifier:
|
|
465
|
+
return device
|
|
466
|
+
if device.ipv4 and str(device.ipv4) == identifier:
|
|
467
|
+
return device
|
|
468
|
+
if server_name == identifier or server_name.startswith(identifier + "."):
|
|
469
|
+
return device
|
|
470
|
+
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def find_channel(device: DanteDevice, channel_id: str, channel_type: str):
|
|
475
|
+
channels = device.rx_channels if channel_type == "rx" else device.tx_channels
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
number = int(channel_id)
|
|
479
|
+
for channel in channels.values():
|
|
480
|
+
if channel.number == number:
|
|
481
|
+
return channel
|
|
482
|
+
except ValueError:
|
|
483
|
+
pass
|
|
484
|
+
|
|
485
|
+
for channel in channels.values():
|
|
486
|
+
if channel.name == channel_id or channel.friendly_name == channel_id:
|
|
487
|
+
return channel
|
|
488
|
+
|
|
489
|
+
return None
|
netaudio/_exit_codes.py
ADDED