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.
Files changed (90) hide show
  1. netaudio/__init__.py +21 -0
  2. netaudio/__main__.py +3 -0
  3. netaudio/_capture.py +174 -0
  4. netaudio/_common.py +489 -0
  5. netaudio/_exit_codes.py +6 -0
  6. netaudio/capture/__init__.py +3 -0
  7. netaudio/capture/analysis.py +87 -0
  8. netaudio/capture/daemon.py +695 -0
  9. netaudio/capture/display.py +456 -0
  10. netaudio/capture/fact.py +303 -0
  11. netaudio/capture/interfaces.py +60 -0
  12. netaudio/capture/markers.py +57 -0
  13. netaudio/capture/packets.py +173 -0
  14. netaudio/capture/provenance.py +572 -0
  15. netaudio/capture/sessions.py +21 -0
  16. netaudio/cli.py +260 -0
  17. netaudio/commands/__init__.py +0 -0
  18. netaudio/commands/capture.py +1932 -0
  19. netaudio/commands/capture_helpers.py +373 -0
  20. netaudio/commands/channel.py +149 -0
  21. netaudio/commands/config.py +420 -0
  22. netaudio/commands/device.py +1079 -0
  23. netaudio/commands/diagnose.py +119 -0
  24. netaudio/commands/fact.py +1023 -0
  25. netaudio/commands/firmware.py +1267 -0
  26. netaudio/commands/flow.py +159 -0
  27. netaudio/commands/key.py +85 -0
  28. netaudio/commands/provenance.py +1645 -0
  29. netaudio/commands/server.py +358 -0
  30. netaudio/commands/shure.py +1012 -0
  31. netaudio/commands/status.py +137 -0
  32. netaudio/commands/subscription.py +298 -0
  33. netaudio/commands/virtual.py +176 -0
  34. netaudio/common/__init__.py +0 -0
  35. netaudio/common/app_config.py +115 -0
  36. netaudio/common/config_loader.py +194 -0
  37. netaudio/common/key_extract.py +74 -0
  38. netaudio/common/mdns_cache.py +73 -0
  39. netaudio/core/__init__.py +23 -0
  40. netaudio/core/binding.py +393 -0
  41. netaudio/core/netaudio_core.dll +0 -0
  42. netaudio/daemon/__init__.py +8 -0
  43. netaudio/daemon/client.py +143 -0
  44. netaudio/daemon/dbus_interfaces.py +335 -0
  45. netaudio/daemon/dbus_service.py +304 -0
  46. netaudio/daemon/enforcement.py +707 -0
  47. netaudio/daemon/metering.py +353 -0
  48. netaudio/daemon/relay.py +757 -0
  49. netaudio/daemon/server.py +650 -0
  50. netaudio/daemon/service_install.py +336 -0
  51. netaudio/dante/__init__.py +0 -0
  52. netaudio/dante/application.py +627 -0
  53. netaudio/dante/browser.py +387 -0
  54. netaudio/dante/channel.py +133 -0
  55. netaudio/dante/clean_labels.py +269 -0
  56. netaudio/dante/const.py +146 -0
  57. netaudio/dante/debug_formatter.py +528 -0
  58. netaudio/dante/device.py +381 -0
  59. netaudio/dante/device_commands.py +212 -0
  60. netaudio/dante/device_operations.py +352 -0
  61. netaudio/dante/device_parser.py +19 -0
  62. netaudio/dante/device_serializer.py +205 -0
  63. netaudio/dante/device_xml_serializer.py +163 -0
  64. netaudio/dante/events.py +90 -0
  65. netaudio/dante/fact_store.py +503 -0
  66. netaudio/dante/flows.py +80 -0
  67. netaudio/dante/metering.py +112 -0
  68. netaudio/dante/packet_dissector.py +1276 -0
  69. netaudio/dante/packet_store.py +1192 -0
  70. netaudio/dante/protocol_verifier.py +496 -0
  71. netaudio/dante/service.py +196 -0
  72. netaudio/dante/services/__init__.py +9 -0
  73. netaudio/dante/services/cmc.py +182 -0
  74. netaudio/dante/services/heartbeat.py +112 -0
  75. netaudio/dante/services/notification.py +595 -0
  76. netaudio/dante/services/settings.py +70 -0
  77. netaudio/dante/state.py +377 -0
  78. netaudio/dante/subscription.py +155 -0
  79. netaudio/dante/transport.py +115 -0
  80. netaudio/dante/tshark_capture.py +341 -0
  81. netaudio/dante/virtual_device.py +886 -0
  82. netaudio/icons.py +93 -0
  83. netaudio/shure/__init__.py +0 -0
  84. netaudio/shure/device.py +271 -0
  85. netaudio/shure/manager.py +521 -0
  86. netaudio-0.2.5.dist-info/METADATA +101 -0
  87. netaudio-0.2.5.dist-info/RECORD +90 -0
  88. netaudio-0.2.5.dist-info/WHEEL +4 -0
  89. netaudio-0.2.5.dist-info/entry_points.txt +2 -0
  90. 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
@@ -0,0 +1,3 @@
1
+ from netaudio.cli import main
2
+
3
+ main()
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
@@ -0,0 +1,6 @@
1
+ from enum import IntEnum
2
+
3
+
4
+ class ExitCode(IntEnum):
5
+ SUCCESS = 0
6
+ ERROR = 1
@@ -0,0 +1,3 @@
1
+ from netaudio.capture.daemon import CaptureDaemon
2
+
3
+ __all__ = ["CaptureDaemon"]