pynadtcp 1.0.0__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.
@@ -0,0 +1,105 @@
1
+ name: Release
2
+
3
+ # Publishes to PyPI whenever main has a version in pyproject.toml that isn't on PyPI yet.
4
+ # To release: bump the version in pyproject.toml and pynadtcp/__init__.py, then merge to main.
5
+
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ workflow_dispatch:
10
+
11
+ concurrency:
12
+ group: release
13
+ cancel-in-progress: false
14
+
15
+ jobs:
16
+ check:
17
+ runs-on: ubuntu-latest
18
+ outputs:
19
+ version: ${{ steps.version.outputs.version }}
20
+ publish: ${{ steps.pypi.outputs.publish }}
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.14"
26
+ - name: Read version
27
+ id: version
28
+ run: |
29
+ version=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
30
+ module=$(python -c 'import re; print(re.search(r"__version__ = \"(.+)\"", open("pynadtcp/__init__.py").read())[1])')
31
+ if [ "$version" != "$module" ]; then
32
+ echo "::error::pyproject.toml has $version but pynadtcp/__init__.py has $module"
33
+ exit 1
34
+ fi
35
+ echo "version=$version" >> "$GITHUB_OUTPUT"
36
+ - name: Check PyPI
37
+ id: pypi
38
+ run: |
39
+ status=$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/pynadtcp/${{ steps.version.outputs.version }}/json")
40
+ if [ "$status" = "404" ]; then
41
+ echo "publish=true" >> "$GITHUB_OUTPUT"
42
+ elif [ "$status" = "200" ]; then
43
+ echo "Version ${{ steps.version.outputs.version }} is already on PyPI, nothing to release."
44
+ echo "publish=false" >> "$GITHUB_OUTPUT"
45
+ else
46
+ echo "::error::Unexpected response $status from PyPI"
47
+ exit 1
48
+ fi
49
+
50
+ test:
51
+ needs: check
52
+ if: needs.check.outputs.publish == 'true'
53
+ uses: ./.github/workflows/test.yml
54
+
55
+ build:
56
+ needs: test
57
+ runs-on: ubuntu-latest
58
+ steps:
59
+ - uses: actions/checkout@v4
60
+ - uses: actions/setup-python@v5
61
+ with:
62
+ python-version: "3.14"
63
+ - run: pip install build twine
64
+ - run: python -m build
65
+ - run: twine check --strict dist/*
66
+ - uses: actions/upload-artifact@v4
67
+ with:
68
+ name: dist
69
+ path: dist/
70
+
71
+ publish:
72
+ needs: build
73
+ runs-on: ubuntu-latest
74
+ environment:
75
+ name: pypi
76
+ url: https://pypi.org/project/pynadtcp/
77
+ permissions:
78
+ id-token: write
79
+ steps:
80
+ - uses: actions/download-artifact@v4
81
+ with:
82
+ name: dist
83
+ path: dist/
84
+ - uses: pypa/gh-action-pypi-publish@release/v1
85
+
86
+ github-release:
87
+ needs: [check, publish]
88
+ runs-on: ubuntu-latest
89
+ permissions:
90
+ contents: write
91
+ steps:
92
+ - uses: actions/download-artifact@v4
93
+ with:
94
+ name: dist
95
+ path: dist/
96
+ - name: Tag and create GitHub Release
97
+ env:
98
+ GH_TOKEN: ${{ github.token }}
99
+ VERSION: ${{ needs.check.outputs.version }}
100
+ run: |
101
+ gh release create "v$VERSION" dist/* \
102
+ --repo "$GITHUB_REPOSITORY" \
103
+ --target "$GITHUB_SHA" \
104
+ --title "v$VERSION" \
105
+ --generate-notes
@@ -0,0 +1,21 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - run: pip install -e ".[test]"
21
+ - run: pytest
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ .venv/
3
+ dist/
4
+ .pytest_cache/
5
+ .ruff_cache/
pynadtcp-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Niels Bik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.5
2
+ Name: pynadtcp
3
+ Version: 1.0.0
4
+ Summary: Async client library for NAD amplifiers controllable over TCP (e.g. the C338).
5
+ Project-URL: Homepage, https://github.com/nielsrowinbik/pynadtcp
6
+ Project-URL: Source, https://github.com/nielsrowinbik/pynadtcp
7
+ Project-URL: Issues, https://github.com/nielsrowinbik/pynadtcp/issues
8
+ Author: Niels Bik
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: amplifier,asyncio,c338,home-assistant,nad
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Home Automation
18
+ Requires-Python: >=3.11
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest; extra == 'test'
21
+ Requires-Dist: pytest-asyncio; extra == 'test'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # pynadtcp
25
+
26
+ Async Python client for NAD amplifiers that expose a TCP/IP control protocol on
27
+ port 30001 — most notably the **NAD C338**. It is a modern, typed rewrite of the
28
+ abandoned [`nadtcp`](https://gitlab.com/mindig.marton/nadtcp) library and is
29
+ designed to back the Home Assistant `nad_tcp` integration, but has no Home
30
+ Assistant dependency and can be used standalone.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install pynadtcp
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ import asyncio
42
+ from pynadtcp import NadClient
43
+
44
+
45
+ async def main() -> None:
46
+ async with NadClient("192.168.1.50") as client:
47
+ print(client.state.model, client.state.version)
48
+
49
+ await client.power_on()
50
+ await client.set_volume(-40) # dB, clamped to [-80, 0]
51
+ await client.select_source("Stream")
52
+
53
+ def on_update(c: NadClient) -> None:
54
+ print("state:", c.state)
55
+
56
+ unsubscribe = client.register_state_update_callback(on_update)
57
+ await asyncio.sleep(30)
58
+ unsubscribe()
59
+
60
+
61
+ asyncio.run(main())
62
+ ```
63
+
64
+ ## API
65
+
66
+ - `NadClient(host, *, port=30001, connect_timeout=5.0, reconnect_interval=15.0)`
67
+ - `await client.connect()` / `await client.disconnect()` (or use as an async context manager)
68
+ - `client.is_connected()` → `bool`
69
+ - `client.state` → `NadState(power, volume_db, muted, source, model, version)`
70
+ - `client.sources` → list of selectable input source names
71
+ - `await client.power_on()` / `power_off()`
72
+ - `await client.set_volume(db)` / `volume_up()` / `volume_down()`
73
+ - `await client.set_mute(bool)`
74
+ - `await client.select_source(name)`
75
+ - `client.register_state_update_callback(cb)` → unsubscribe callable
76
+
77
+ After a successful `connect()`, the client keeps the connection alive and
78
+ transparently reconnects if it drops. Callbacks fire on every state change and
79
+ whenever the connection is established or lost.
80
+
81
+ ## Supported devices
82
+
83
+ Developed and tested against the **NAD C338**. Other NAD models that speak the
84
+ same port-30001 text protocol (potentially the C368 / C388 / M10) are likely to
85
+ work, but are currently untested — reports welcome.
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,66 @@
1
+ # pynadtcp
2
+
3
+ Async Python client for NAD amplifiers that expose a TCP/IP control protocol on
4
+ port 30001 — most notably the **NAD C338**. It is a modern, typed rewrite of the
5
+ abandoned [`nadtcp`](https://gitlab.com/mindig.marton/nadtcp) library and is
6
+ designed to back the Home Assistant `nad_tcp` integration, but has no Home
7
+ Assistant dependency and can be used standalone.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install pynadtcp
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```python
18
+ import asyncio
19
+ from pynadtcp import NadClient
20
+
21
+
22
+ async def main() -> None:
23
+ async with NadClient("192.168.1.50") as client:
24
+ print(client.state.model, client.state.version)
25
+
26
+ await client.power_on()
27
+ await client.set_volume(-40) # dB, clamped to [-80, 0]
28
+ await client.select_source("Stream")
29
+
30
+ def on_update(c: NadClient) -> None:
31
+ print("state:", c.state)
32
+
33
+ unsubscribe = client.register_state_update_callback(on_update)
34
+ await asyncio.sleep(30)
35
+ unsubscribe()
36
+
37
+
38
+ asyncio.run(main())
39
+ ```
40
+
41
+ ## API
42
+
43
+ - `NadClient(host, *, port=30001, connect_timeout=5.0, reconnect_interval=15.0)`
44
+ - `await client.connect()` / `await client.disconnect()` (or use as an async context manager)
45
+ - `client.is_connected()` → `bool`
46
+ - `client.state` → `NadState(power, volume_db, muted, source, model, version)`
47
+ - `client.sources` → list of selectable input source names
48
+ - `await client.power_on()` / `power_off()`
49
+ - `await client.set_volume(db)` / `volume_up()` / `volume_down()`
50
+ - `await client.set_mute(bool)`
51
+ - `await client.select_source(name)`
52
+ - `client.register_state_update_callback(cb)` → unsubscribe callable
53
+
54
+ After a successful `connect()`, the client keeps the connection alive and
55
+ transparently reconnects if it drops. Callbacks fire on every state change and
56
+ whenever the connection is established or lost.
57
+
58
+ ## Supported devices
59
+
60
+ Developed and tested against the **NAD C338**. Other NAD models that speak the
61
+ same port-30001 text protocol (potentially the C368 / C388 / M10) are likely to
62
+ work, but are currently untested — reports welcome.
63
+
64
+ ## License
65
+
66
+ MIT
@@ -0,0 +1,10 @@
1
+ # Releasing
2
+
3
+ Releases are automatic. To publish a new version:
4
+
5
+ 1. Bump `version` in `pyproject.toml` and `__version__` in `pynadtcp/__init__.py` to the same value.
6
+ 2. Merge to `main`.
7
+
8
+ The Release workflow sees that this version isn't on PyPI yet, runs the tests, builds the package, publishes it to PyPI through Trusted Publishing, and creates a `v<version>` tag and GitHub Release. Pushes that don't change the version publish nothing.
9
+
10
+ The PyPI trusted publisher is configured for workflow `release.yml` and environment `pypi`. Renaming either breaks publishing until the PyPI settings are updated to match.
@@ -0,0 +1,21 @@
1
+ """Async client library for NAD amplifiers controllable over TCP (e.g. C338)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .client import NadClient
6
+ from .const import DEFAULT_PORT, VOLUME_MAX_DB, VOLUME_MIN_DB
7
+ from .exceptions import NadConnectionError, NadException, NadTimeoutError
8
+ from .models import NadState
9
+
10
+ __all__ = [
11
+ "DEFAULT_PORT",
12
+ "VOLUME_MAX_DB",
13
+ "VOLUME_MIN_DB",
14
+ "NadClient",
15
+ "NadConnectionError",
16
+ "NadException",
17
+ "NadState",
18
+ "NadTimeoutError",
19
+ ]
20
+
21
+ __version__ = "1.0.0"
@@ -0,0 +1,278 @@
1
+ """Asyncio client for NAD amplifiers controllable over TCP (e.g. the C338)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Callable
7
+ import inspect
8
+ import logging
9
+ from types import TracebackType
10
+ from typing import Any
11
+
12
+ from .const import (
13
+ CMD_MAIN,
14
+ CMD_MODEL,
15
+ CMD_MUTE,
16
+ CMD_POWER,
17
+ CMD_SOURCE,
18
+ CMD_VERSION,
19
+ CMD_VOLUME,
20
+ C338_CMDS,
21
+ CMD_MIN_INTERVAL,
22
+ DEFAULT_PORT,
23
+ VOLUME_MAX_DB,
24
+ VOLUME_MIN_DB,
25
+ )
26
+ from .exceptions import NadConnectionError, NadException, NadTimeoutError
27
+ from .models import NadState
28
+ from .protocol import NadProtocol, make_command
29
+
30
+ _LOGGER = logging.getLogger(__name__)
31
+
32
+ StateCallback = Callable[["NadClient"], Awaitable[None] | None]
33
+
34
+
35
+ class NadClient:
36
+ """Manage a persistent control connection to a NAD amplifier."""
37
+
38
+ def __init__(
39
+ self,
40
+ host: str,
41
+ *,
42
+ port: int = DEFAULT_PORT,
43
+ connect_timeout: float = 5.0,
44
+ reconnect_interval: float = 15.0,
45
+ ) -> None:
46
+ """Initialize the client. No connection is opened until ``connect``."""
47
+ self._host = host
48
+ self._port = port
49
+ self._connect_timeout = connect_timeout
50
+ self._reconnect_interval = reconnect_interval
51
+
52
+ self._loop: asyncio.AbstractEventLoop | None = None
53
+ self._transport: asyncio.Transport | None = None
54
+ self._raw: dict[str, Any] = {}
55
+ self._state = NadState()
56
+ self._callbacks: set[StateCallback] = set()
57
+
58
+ self._closing = False
59
+ self._auto_reconnect = False
60
+ self._ready = asyncio.Event()
61
+ self._reconnect_task: asyncio.Task[None] | None = None
62
+ self._cmd_lock = asyncio.Lock()
63
+ self._last_cmd = 0.0
64
+
65
+ @property
66
+ def host(self) -> str:
67
+ """Return the configured host."""
68
+ return self._host
69
+
70
+ @property
71
+ def state(self) -> NadState:
72
+ """Return the latest known device state."""
73
+ return self._state
74
+
75
+ @property
76
+ def sources(self) -> list[str]:
77
+ """Return the list of selectable input sources."""
78
+ return list(C338_CMDS[CMD_SOURCE]["values"])
79
+
80
+ def is_connected(self) -> bool:
81
+ """Return whether a live connection to the device exists."""
82
+ return self._transport is not None and not self._closing
83
+
84
+ async def connect(self) -> None:
85
+ """Open the connection and wait until the device identifies itself.
86
+
87
+ Raises:
88
+ NadConnectionError: if the socket cannot be opened.
89
+ NadTimeoutError: if the device does not respond in time.
90
+ """
91
+ self._closing = False
92
+ self._loop = asyncio.get_running_loop()
93
+ await self._open()
94
+ self._auto_reconnect = True
95
+
96
+ async def disconnect(self) -> None:
97
+ """Close the connection and stop reconnecting."""
98
+ self._closing = True
99
+ self._auto_reconnect = False
100
+ if self._reconnect_task is not None:
101
+ self._reconnect_task.cancel()
102
+ self._reconnect_task = None
103
+ if self._transport is not None:
104
+ self._transport.close()
105
+ self._transport = None
106
+
107
+ async def __aenter__(self) -> NadClient:
108
+ """Connect on entering an async context."""
109
+ await self.connect()
110
+ return self
111
+
112
+ async def __aexit__(
113
+ self,
114
+ exc_type: type[BaseException] | None,
115
+ exc: BaseException | None,
116
+ tb: TracebackType | None,
117
+ ) -> None:
118
+ """Disconnect on leaving an async context."""
119
+ await self.disconnect()
120
+
121
+ def register_state_update_callback(
122
+ self, callback: StateCallback
123
+ ) -> Callable[[], None]:
124
+ """Register a callback invoked on every state or connection change.
125
+
126
+ Returns a function that unregisters the callback.
127
+ """
128
+ self._callbacks.add(callback)
129
+
130
+ def _unsubscribe() -> None:
131
+ self._callbacks.discard(callback)
132
+
133
+ return _unsubscribe
134
+
135
+ def unregister_state_update_callback(self, callback: StateCallback) -> None:
136
+ """Unregister a previously registered callback."""
137
+ self._callbacks.discard(callback)
138
+
139
+ async def power_on(self) -> None:
140
+ """Turn the amplifier on."""
141
+ await self._send(make_command(CMD_POWER, "=", True))
142
+
143
+ async def power_off(self) -> None:
144
+ """Turn the amplifier off."""
145
+ await self._send(make_command(CMD_POWER, "=", False))
146
+
147
+ async def set_mute(self, mute: bool) -> None:
148
+ """Mute or unmute the amplifier."""
149
+ await self._send(make_command(CMD_MUTE, "=", mute))
150
+
151
+ async def set_volume(self, volume_db: float) -> None:
152
+ """Set the volume in dB, clamped to the device's supported range."""
153
+ clamped = round(max(VOLUME_MIN_DB, min(VOLUME_MAX_DB, volume_db)))
154
+ await self._send(make_command(CMD_VOLUME, "=", clamped))
155
+
156
+ async def volume_up(self) -> None:
157
+ """Increase the volume by one device step."""
158
+ await self._send(make_command(CMD_VOLUME, "+"))
159
+
160
+ async def volume_down(self) -> None:
161
+ """Decrease the volume by one device step."""
162
+ await self._send(make_command(CMD_VOLUME, "-"))
163
+
164
+ async def select_source(self, source: str) -> None:
165
+ """Select an input source."""
166
+ await self._send(make_command(CMD_SOURCE, "=", source))
167
+
168
+ async def _open(self) -> None:
169
+ """Open a single connection and wait for the device to identify."""
170
+ assert self._loop is not None
171
+ self._ready.clear()
172
+
173
+ def _factory() -> NadProtocol:
174
+ return NadProtocol(self._handle_lines, self._handle_connection_lost)
175
+
176
+ try:
177
+ transport, _ = await asyncio.wait_for(
178
+ self._loop.create_connection(_factory, self._host, self._port),
179
+ timeout=self._connect_timeout,
180
+ )
181
+ except TimeoutError as err:
182
+ raise NadTimeoutError(
183
+ f"Timed out connecting to {self._host}:{self._port}"
184
+ ) from err
185
+ except OSError as err:
186
+ raise NadConnectionError(
187
+ f"Could not connect to {self._host}:{self._port}: {err}"
188
+ ) from err
189
+
190
+ self._transport = transport
191
+ await self._send(make_command(CMD_MAIN, "?"))
192
+ try:
193
+ async with asyncio.timeout(self._connect_timeout):
194
+ await self._ready.wait()
195
+ except TimeoutError as err:
196
+ transport.close()
197
+ raise NadTimeoutError(
198
+ f"No response from NAD device at {self._host}:{self._port}"
199
+ ) from err
200
+
201
+ async def _reconnect(self) -> None:
202
+ """Retry opening the connection until it succeeds or we close."""
203
+ while not self._closing:
204
+ await asyncio.sleep(self._reconnect_interval)
205
+ if self._closing:
206
+ return
207
+ try:
208
+ await self._open()
209
+ except NadException as err:
210
+ _LOGGER.debug("Reconnect to %s failed: %s", self._host, err)
211
+ continue
212
+ _LOGGER.debug("Reconnected to %s", self._host)
213
+ return
214
+
215
+ def _handle_lines(self, lines: list[tuple[str, Any]]) -> None:
216
+ """Apply a batch of parsed response lines to the local state."""
217
+ for key, value in lines:
218
+ self._raw[key] = value
219
+ # A volume change implicitly clears mute on the device.
220
+ if key == CMD_VOLUME and self._raw.get(CMD_MUTE) is True:
221
+ self._raw[CMD_MUTE] = False
222
+ self._rebuild_state()
223
+ if CMD_MODEL in self._raw:
224
+ self._ready.set()
225
+ self._schedule_notify()
226
+
227
+ def _handle_connection_lost(self, exc: Exception | None) -> None:
228
+ """React to the transport closing, reconnecting if appropriate."""
229
+ self._transport = None
230
+ self._raw.clear()
231
+ self._rebuild_state()
232
+ self._ready.clear()
233
+ self._schedule_notify()
234
+ if (
235
+ self._auto_reconnect
236
+ and not self._closing
237
+ and self._loop is not None
238
+ and (self._reconnect_task is None or self._reconnect_task.done())
239
+ ):
240
+ _LOGGER.debug("Connection to %s lost (%s); reconnecting", self._host, exc)
241
+ self._reconnect_task = self._loop.create_task(self._reconnect())
242
+
243
+ def _rebuild_state(self) -> None:
244
+ """Recompute the public ``NadState`` from raw device values."""
245
+ version = self._raw.get(CMD_VERSION)
246
+ self._state = NadState(
247
+ power=self._raw.get(CMD_POWER),
248
+ volume_db=self._raw.get(CMD_VOLUME),
249
+ muted=self._raw.get(CMD_MUTE),
250
+ source=self._raw.get(CMD_SOURCE),
251
+ model=self._raw.get(CMD_MODEL),
252
+ version=None if version is None else str(version),
253
+ )
254
+
255
+ def _schedule_notify(self) -> None:
256
+ """Schedule callback notification on the event loop."""
257
+ if self._loop is not None:
258
+ self._loop.create_task(self._notify())
259
+
260
+ async def _notify(self) -> None:
261
+ """Invoke every registered callback, awaiting async ones."""
262
+ for callback in list(self._callbacks):
263
+ result = callback(self)
264
+ if inspect.isawaitable(result):
265
+ await result
266
+
267
+ async def _send(self, command: str) -> None:
268
+ """Write a command to the device, throttled to the minimum interval."""
269
+ transport = self._transport
270
+ if transport is None:
271
+ raise NadConnectionError("Not connected to the device")
272
+ assert self._loop is not None
273
+ async with self._cmd_lock:
274
+ wait = (self._last_cmd + CMD_MIN_INTERVAL) - self._loop.time()
275
+ if wait > 0:
276
+ await asyncio.sleep(wait)
277
+ transport.write(command.encode("utf-8"))
278
+ self._last_cmd = self._loop.time()
@@ -0,0 +1,102 @@
1
+ """Constants and the command table for the NAD TCP protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ #: TCP port the NAD amplifier listens on for the control protocol.
8
+ DEFAULT_PORT = 30001
9
+
10
+ #: Minimum interval (seconds) between commands sent to the device.
11
+ CMD_MIN_INTERVAL = 0.15
12
+
13
+ #: Line terminator used by the protocol.
14
+ EOL = "\r\n"
15
+
16
+ # Command keys used by the protocol.
17
+ CMD_MAIN = "Main"
18
+ CMD_BRIGHTNESS = "Main.Brightness"
19
+ CMD_BASS_EQ = "Main.Bass"
20
+ CMD_CONTROL_STANDBY = "Main.ControlStandby"
21
+ CMD_AUTO_STANDBY = "Main.AutoStandby"
22
+ CMD_VERSION = "Main.Version"
23
+ CMD_MODEL = "Main.Model"
24
+ CMD_MUTE = "Main.Mute"
25
+ CMD_POWER = "Main.Power"
26
+ CMD_AUTO_SENSE = "Main.AutoSense"
27
+ CMD_SOURCE = "Main.Source"
28
+ CMD_VOLUME = "Main.Volume"
29
+
30
+ MSG_ON = "On"
31
+ MSG_OFF = "Off"
32
+
33
+ #: Description of every command the C338 supports: which operators are valid,
34
+ #: the accepted values, and the Python type each value maps to. This table
35
+ #: drives both command formatting and response parsing.
36
+ C338_CMDS: dict[str, dict[str, Any]] = {
37
+ "Main": {"supported_operators": ["?"]},
38
+ "Main.AnalogGain": {
39
+ "supported_operators": ["+", "-", "=", "?"],
40
+ "type": int,
41
+ },
42
+ "Main.Brightness": {
43
+ "supported_operators": ["+", "-", "=", "?"],
44
+ "values": range(0, 4),
45
+ "type": int,
46
+ },
47
+ "Main.Mute": {
48
+ "supported_operators": ["+", "-", "=", "?"],
49
+ "values": [MSG_OFF, MSG_ON],
50
+ "type": bool,
51
+ },
52
+ "Main.Power": {
53
+ "supported_operators": ["+", "-", "=", "?"],
54
+ "values": [MSG_OFF, MSG_ON],
55
+ "type": bool,
56
+ },
57
+ "Main.Volume": {
58
+ "supported_operators": ["+", "-", "=", "?"],
59
+ "values": range(-80, 1),
60
+ "type": float,
61
+ },
62
+ "Main.Bass": {
63
+ "supported_operators": ["+", "-", "=", "?"],
64
+ "values": [MSG_OFF, MSG_ON],
65
+ "type": bool,
66
+ },
67
+ "Main.ControlStandby": {
68
+ "supported_operators": ["+", "-", "=", "?"],
69
+ "values": [MSG_OFF, MSG_ON],
70
+ "type": bool,
71
+ },
72
+ "Main.AutoStandby": {
73
+ "supported_operators": ["+", "-", "=", "?"],
74
+ "values": [MSG_OFF, MSG_ON],
75
+ "type": bool,
76
+ },
77
+ "Main.AutoSense": {
78
+ "supported_operators": ["+", "-", "=", "?"],
79
+ "values": [MSG_OFF, MSG_ON],
80
+ "type": bool,
81
+ },
82
+ "Main.Source": {
83
+ "supported_operators": ["+", "-", "=", "?"],
84
+ "values": [
85
+ "Stream",
86
+ "Wireless",
87
+ "TV",
88
+ "Phono",
89
+ "Coax1",
90
+ "Coax2",
91
+ "Opt1",
92
+ "Opt2",
93
+ ],
94
+ },
95
+ "Main.Version": {"supported_operators": ["?"]},
96
+ "Main.Model": {"supported_operators": ["?"]},
97
+ }
98
+
99
+ #: Lowest volume the device accepts, in dB.
100
+ VOLUME_MIN_DB = -80.0
101
+ #: Highest volume the device accepts, in dB.
102
+ VOLUME_MAX_DB = 0.0
@@ -0,0 +1,15 @@
1
+ """Exceptions raised by pynadtcp."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class NadException(Exception):
7
+ """Base class for all pynadtcp errors."""
8
+
9
+
10
+ class NadConnectionError(NadException):
11
+ """Raised when the connection to the device fails or is lost."""
12
+
13
+
14
+ class NadTimeoutError(NadException):
15
+ """Raised when the device does not respond in time."""
@@ -0,0 +1,20 @@
1
+ """Data models for pynadtcp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class NadState:
10
+ """Snapshot of the amplifier's state.
11
+
12
+ Every field is ``None`` until the device has reported a value for it.
13
+ """
14
+
15
+ power: bool | None = None
16
+ volume_db: float | None = None
17
+ muted: bool | None = None
18
+ source: str | None = None
19
+ model: str | None = None
20
+ version: str | None = None
@@ -0,0 +1,119 @@
1
+ """Low-level command grammar and asyncio transport for the NAD TCP protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable
7
+ import logging
8
+ import socket
9
+ from typing import Any
10
+
11
+ from .const import C338_CMDS, EOL
12
+ from .exceptions import NadException
13
+
14
+ _LOGGER = logging.getLogger(__name__)
15
+
16
+
17
+ def make_command(key: str, operator: str, value: Any = None) -> str:
18
+ """Build a raw protocol command string such as ``Main.Volume=-40``.
19
+
20
+ Raises:
21
+ NadException: if the key/operator combination is invalid.
22
+ """
23
+ try:
24
+ cmd_desc = C338_CMDS[key]
25
+ except KeyError:
26
+ raise NadException(f"Unknown command: {key}") from None
27
+ if operator not in cmd_desc["supported_operators"]:
28
+ raise NadException(f"Operator {operator!r} is not valid for {key}")
29
+ if operator == "=" and value is None:
30
+ raise NadException(f"Operator '=' for {key} requires a value")
31
+ if operator in ("?", "+", "-") and value is not None:
32
+ raise NadException(f"Operator {operator!r} cannot take a value")
33
+ if value is None:
34
+ return f"{key}{operator}"
35
+ if cmd_desc.get("type") is bool:
36
+ value = cmd_desc["values"][int(bool(value))]
37
+ return f"{key}{operator}{value}"
38
+
39
+
40
+ def parse_line(line: str) -> tuple[str, Any] | None:
41
+ """Parse a single ``key=value`` response line.
42
+
43
+ Returns ``None`` for lines without a value or keys we do not understand,
44
+ so unexpected output never crashes the parser.
45
+ """
46
+ key, sep, raw = line.partition("=")
47
+ if not sep:
48
+ return None
49
+ cmd_desc = C338_CMDS.get(key)
50
+ if cmd_desc is None:
51
+ return None
52
+ value_type = cmd_desc.get("type")
53
+ try:
54
+ if value_type is bool:
55
+ return key, bool(cmd_desc["values"].index(raw))
56
+ if value_type is not None:
57
+ return key, value_type(raw)
58
+ except (ValueError, KeyError):
59
+ return None
60
+ return key, raw
61
+
62
+
63
+ class NadProtocol(asyncio.Protocol):
64
+ """asyncio protocol that frames the device's line-based output."""
65
+
66
+ def __init__(
67
+ self,
68
+ on_lines: Callable[[list[tuple[str, Any]]], None],
69
+ on_connection_lost: Callable[[Exception | None], None],
70
+ ) -> None:
71
+ """Initialize the protocol with line and disconnect callbacks."""
72
+ self._on_lines = on_lines
73
+ self._on_connection_lost = on_connection_lost
74
+ self._buffer = ""
75
+ self.transport: asyncio.Transport | None = None
76
+
77
+ def connection_made(self, transport: asyncio.BaseTransport) -> None:
78
+ """Store the transport and enable TCP keepalive (best effort)."""
79
+ assert isinstance(transport, asyncio.Transport)
80
+ self.transport = transport
81
+ sock: socket.socket | None = transport.get_extra_info("socket")
82
+ if sock is not None:
83
+ _enable_keepalive(sock)
84
+
85
+ def data_received(self, data: bytes) -> None:
86
+ """Split incoming bytes into complete lines and hand them off."""
87
+ self._buffer += data.decode("utf-8", "ignore").replace("\x00", "")
88
+ parsed: list[tuple[str, Any]] = []
89
+ while EOL in self._buffer:
90
+ line, self._buffer = self._buffer.split(EOL, 1)
91
+ if not line:
92
+ continue
93
+ result = parse_line(line)
94
+ if result is not None:
95
+ parsed.append(result)
96
+ if parsed:
97
+ self._on_lines(parsed)
98
+
99
+ def connection_lost(self, exc: Exception | None) -> None:
100
+ """Notify the client that the transport was closed."""
101
+ self.transport = None
102
+ self._on_connection_lost(exc)
103
+
104
+
105
+ def _enable_keepalive(sock: socket.socket) -> None:
106
+ """Turn on TCP keepalive, tuning the timers where the platform allows."""
107
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
108
+ for name, value in (
109
+ ("TCP_KEEPIDLE", 60),
110
+ ("TCP_KEEPINTVL", 10),
111
+ ("TCP_KEEPCNT", 3),
112
+ ):
113
+ option = getattr(socket, name, None)
114
+ if option is None:
115
+ continue
116
+ try:
117
+ sock.setsockopt(socket.IPPROTO_TCP, option, value)
118
+ except OSError: # noqa: PERF203 - best effort per option
119
+ _LOGGER.debug("Could not set socket option %s", name)
File without changes
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pynadtcp"
7
+ version = "1.0.0"
8
+ description = "Async client library for NAD amplifiers controllable over TCP (e.g. the C338)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Niels Bik" }]
14
+ keywords = ["nad", "amplifier", "c338", "home-assistant", "asyncio"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Framework :: AsyncIO",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Home Automation",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/nielsrowinbik/pynadtcp"
27
+ Source = "https://github.com/nielsrowinbik/pynadtcp"
28
+ Issues = "https://github.com/nielsrowinbik/pynadtcp/issues"
29
+
30
+ [project.optional-dependencies]
31
+ test = ["pytest", "pytest-asyncio"]
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["pynadtcp"]
35
+
36
+ [tool.pytest.ini_options]
37
+ asyncio_mode = "auto"
38
+
39
+ [tool.ruff]
40
+ target-version = "py311"
41
+
42
+ [tool.mypy]
43
+ python_version = "3.11"
44
+ strict = true
@@ -0,0 +1 @@
1
+ """Tests for pynadtcp."""
@@ -0,0 +1,240 @@
1
+ """Tests for the pynadtcp client and protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import socket
7
+
8
+ import pytest
9
+
10
+ from pynadtcp import NadClient, NadConnectionError, NadTimeoutError
11
+ from pynadtcp.exceptions import NadException
12
+ from pynadtcp.protocol import make_command, parse_line
13
+
14
+ STATE_DUMP = (
15
+ "Main.Model=NADC338\r\n"
16
+ "Main.Version=1.53\r\n"
17
+ "Main.Power=On\r\n"
18
+ "Main.Volume=-40\r\n"
19
+ "Main.Mute=Off\r\n"
20
+ "Main.Source=Stream\r\n"
21
+ )
22
+
23
+
24
+ class FakeNad:
25
+ """A minimal fake NAD device speaking the port-30001 protocol."""
26
+
27
+ def __init__(self) -> None:
28
+ """Initialize the fake device."""
29
+ self.received: list[str] = []
30
+ self._server: asyncio.AbstractServer | None = None
31
+ self._writers: list[asyncio.StreamWriter] = []
32
+ self.port = 0
33
+
34
+ async def start(self) -> None:
35
+ """Start listening on an ephemeral local port."""
36
+ self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0)
37
+ self.port = self._server.sockets[0].getsockname()[1]
38
+
39
+ async def _handle(
40
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
41
+ ) -> None:
42
+ self._writers.append(writer)
43
+ while not reader.at_eof():
44
+ data = await reader.read(1024)
45
+ if not data:
46
+ break
47
+ text = data.decode()
48
+ self.received.append(text)
49
+ if "Main?" in text:
50
+ writer.write(STATE_DUMP.encode())
51
+ await writer.drain()
52
+ writer.close()
53
+
54
+ async def push(self, text: str) -> None:
55
+ """Send unsolicited output to every connected client."""
56
+ for writer in self._writers:
57
+ writer.write(text.encode())
58
+ await writer.drain()
59
+
60
+ async def stop(self) -> None:
61
+ """Stop the server if it is still running."""
62
+ if self._server is not None:
63
+ self._server.close()
64
+ await self._server.wait_closed()
65
+ self._server = None
66
+
67
+
68
+ @pytest.fixture
69
+ async def fake_nad() -> FakeNad:
70
+ """Provide a running fake NAD device."""
71
+ device = FakeNad()
72
+ await device.start()
73
+ yield device
74
+ await device.stop()
75
+
76
+
77
+ async def test_connect_populates_state(fake_nad: FakeNad) -> None:
78
+ """Connecting queries the device and fills in the state."""
79
+ client = NadClient("127.0.0.1", port=fake_nad.port)
80
+ await client.connect()
81
+
82
+ assert client.is_connected()
83
+ assert client.state.model == "NADC338"
84
+ assert client.state.version == "1.53"
85
+ assert client.state.power is True
86
+ assert client.state.volume_db == -40.0
87
+ assert client.state.muted is False
88
+ assert client.state.source == "Stream"
89
+ assert client.sources[0] == "Stream"
90
+
91
+ await client.disconnect()
92
+ assert not client.is_connected()
93
+
94
+
95
+ async def test_context_manager(fake_nad: FakeNad) -> None:
96
+ """The client works as an async context manager."""
97
+ async with NadClient("127.0.0.1", port=fake_nad.port) as client:
98
+ assert client.is_connected()
99
+ assert not client.is_connected()
100
+
101
+
102
+ async def test_callbacks_fire(fake_nad: FakeNad) -> None:
103
+ """Registered callbacks receive state updates."""
104
+ client = NadClient("127.0.0.1", port=fake_nad.port)
105
+ powers: list[bool | None] = []
106
+
107
+ async def _cb(c: NadClient) -> None:
108
+ powers.append(c.state.power)
109
+
110
+ unsubscribe = client.register_state_update_callback(_cb)
111
+ await client.connect()
112
+ await asyncio.sleep(0.05)
113
+
114
+ assert True in powers
115
+ unsubscribe()
116
+ await client.disconnect()
117
+
118
+
119
+ async def test_unparseable_lines_keep_connection(fake_nad: FakeNad) -> None:
120
+ """Bad lines are skipped; later valid lines still apply on the same connection."""
121
+ client = NadClient("127.0.0.1", port=fake_nad.port)
122
+ await client.connect()
123
+
124
+ await fake_nad.push("garbage\r\nMain.Unknown=1\r\nMain.Volume=-20\r\n")
125
+ await asyncio.sleep(0.05)
126
+
127
+ assert client.state.volume_db == -20.0
128
+ assert client.is_connected()
129
+ await client.disconnect()
130
+
131
+
132
+ @pytest.mark.parametrize(
133
+ ("requested", "expected"),
134
+ [(-30, "Main.Volume=-30"), (-200, "Main.Volume=-80"), (10, "Main.Volume=0")],
135
+ )
136
+ async def test_set_volume_clamped(
137
+ fake_nad: FakeNad, requested: float, expected: str
138
+ ) -> None:
139
+ """Volume is clamped to the device range before sending."""
140
+ client = NadClient("127.0.0.1", port=fake_nad.port)
141
+ await client.connect()
142
+ fake_nad.received.clear()
143
+
144
+ await client.set_volume(requested)
145
+ await asyncio.sleep(0.05)
146
+
147
+ assert expected in "".join(fake_nad.received)
148
+ await client.disconnect()
149
+
150
+
151
+ async def test_power_and_source_commands(fake_nad: FakeNad) -> None:
152
+ """Power and source commands are encoded correctly on the wire."""
153
+ client = NadClient("127.0.0.1", port=fake_nad.port)
154
+ await client.connect()
155
+ fake_nad.received.clear()
156
+
157
+ await client.power_on()
158
+ await client.select_source("Phono")
159
+ await client.set_mute(True)
160
+ await asyncio.sleep(0.05)
161
+
162
+ sent = "".join(fake_nad.received)
163
+ assert "Main.Power=On" in sent
164
+ assert "Main.Source=Phono" in sent
165
+ assert "Main.Mute=On" in sent
166
+ await client.disconnect()
167
+
168
+
169
+ async def test_connect_refused() -> None:
170
+ """Connecting to a closed port raises NadConnectionError."""
171
+ sock = socket.socket()
172
+ sock.bind(("127.0.0.1", 0))
173
+ port = sock.getsockname()[1]
174
+ sock.close()
175
+
176
+ client = NadClient("127.0.0.1", port=port, connect_timeout=1)
177
+ with pytest.raises((NadConnectionError, NadTimeoutError)):
178
+ await client.connect()
179
+ assert not client.is_connected()
180
+
181
+
182
+ async def test_no_response_times_out() -> None:
183
+ """A device that accepts but never answers raises NadTimeoutError."""
184
+
185
+ async def _silent(
186
+ reader: asyncio.StreamReader, writer: asyncio.StreamWriter
187
+ ) -> None:
188
+ await reader.read(1024)
189
+ await asyncio.sleep(5)
190
+ # Without this, wait_closed() below hangs on Python 3.12.
191
+ writer.close()
192
+
193
+ server = await asyncio.start_server(_silent, "127.0.0.1", 0)
194
+ port = server.sockets[0].getsockname()[1]
195
+
196
+ client = NadClient("127.0.0.1", port=port, connect_timeout=0.3)
197
+ with pytest.raises(NadTimeoutError):
198
+ await client.connect()
199
+
200
+ server.close()
201
+ await server.wait_closed()
202
+
203
+
204
+ def test_make_command() -> None:
205
+ """Commands are formatted per the protocol grammar."""
206
+ assert make_command("Main", "?") == "Main?"
207
+ assert make_command("Main.Power", "=", True) == "Main.Power=On"
208
+ assert make_command("Main.Power", "=", False) == "Main.Power=Off"
209
+ assert make_command("Main.Volume", "=", -40) == "Main.Volume=-40"
210
+ assert make_command("Main.Volume", "+") == "Main.Volume+"
211
+
212
+
213
+ def test_make_command_invalid() -> None:
214
+ """Invalid keys and operator/value combinations raise."""
215
+ with pytest.raises(NadException):
216
+ make_command("Unknown.Key", "?")
217
+ with pytest.raises(NadException):
218
+ make_command("Main.Power", "!")
219
+ with pytest.raises(NadException):
220
+ make_command("Main.Volume", "?", -40)
221
+ with pytest.raises(NadException):
222
+ make_command("Main.Power", "=")
223
+
224
+
225
+ @pytest.mark.parametrize(
226
+ ("line", "expected"),
227
+ [
228
+ ("Main.Power=On", ("Main.Power", True)),
229
+ ("Main.Power=Off", ("Main.Power", False)),
230
+ ("Main.Volume=-40", ("Main.Volume", -40.0)),
231
+ ("Main.Source=Stream", ("Main.Source", "Stream")),
232
+ ("Main.Model=NADC338", ("Main.Model", "NADC338")),
233
+ ("Unknown.Key=1", None),
234
+ ("garbage", None),
235
+ ("Main.Mute=Weird", None),
236
+ ],
237
+ )
238
+ def test_parse_line(line: str, expected: tuple[str, object] | None) -> None:
239
+ """Response lines parse to typed values, bad input to None."""
240
+ assert parse_line(line) == expected