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.
- pynadtcp-1.0.0/.github/workflows/release.yml +105 -0
- pynadtcp-1.0.0/.github/workflows/test.yml +21 -0
- pynadtcp-1.0.0/.gitignore +5 -0
- pynadtcp-1.0.0/LICENSE +21 -0
- pynadtcp-1.0.0/PKG-INFO +89 -0
- pynadtcp-1.0.0/README.md +66 -0
- pynadtcp-1.0.0/RELEASING.md +10 -0
- pynadtcp-1.0.0/pynadtcp/__init__.py +21 -0
- pynadtcp-1.0.0/pynadtcp/client.py +278 -0
- pynadtcp-1.0.0/pynadtcp/const.py +102 -0
- pynadtcp-1.0.0/pynadtcp/exceptions.py +15 -0
- pynadtcp-1.0.0/pynadtcp/models.py +20 -0
- pynadtcp-1.0.0/pynadtcp/protocol.py +119 -0
- pynadtcp-1.0.0/pynadtcp/py.typed +0 -0
- pynadtcp-1.0.0/pyproject.toml +44 -0
- pynadtcp-1.0.0/tests/__init__.py +1 -0
- pynadtcp-1.0.0/tests/test_client.py +240 -0
|
@@ -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
|
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.
|
pynadtcp-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
pynadtcp-1.0.0/README.md
ADDED
|
@@ -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
|