biamp-ntp 0.1.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.
- biamp_ntp-0.1.0/LICENSE +21 -0
- biamp_ntp-0.1.0/PKG-INFO +123 -0
- biamp_ntp-0.1.0/README.md +102 -0
- biamp_ntp-0.1.0/pyproject.toml +33 -0
- biamp_ntp-0.1.0/setup.cfg +4 -0
- biamp_ntp-0.1.0/src/biamp_ntp/__init__.py +7 -0
- biamp_ntp-0.1.0/src/biamp_ntp/cli.py +94 -0
- biamp_ntp-0.1.0/src/biamp_ntp/client.py +200 -0
- biamp_ntp-0.1.0/src/biamp_ntp/protocol.py +39 -0
- biamp_ntp-0.1.0/src/biamp_ntp/scan.py +29 -0
- biamp_ntp-0.1.0/src/biamp_ntp.egg-info/PKG-INFO +123 -0
- biamp_ntp-0.1.0/src/biamp_ntp.egg-info/SOURCES.txt +14 -0
- biamp_ntp-0.1.0/src/biamp_ntp.egg-info/dependency_links.txt +1 -0
- biamp_ntp-0.1.0/src/biamp_ntp.egg-info/entry_points.txt +2 -0
- biamp_ntp-0.1.0/src/biamp_ntp.egg-info/top_level.txt +1 -0
- biamp_ntp-0.1.0/tests/test_protocol.py +54 -0
biamp_ntp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matt
|
|
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.
|
biamp_ntp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: biamp-ntp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dependency-free Python client + CLI for the Biamp Nexia/Audia Text Protocol (telnet control)
|
|
5
|
+
Author: Matt
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/biamp-ntp/biamp-ntp
|
|
8
|
+
Project-URL: Issues, https://github.com/biamp-ntp/biamp-ntp/issues
|
|
9
|
+
Keywords: biamp,nexia,audia,dsp,telnet,audio,home-automation,av
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
15
|
+
Classifier: Topic :: Home Automation
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# biamp-ntp
|
|
23
|
+
|
|
24
|
+
A tiny, **dependency-free** Python client and CLI for the **Biamp Nexia / Audia Text Protocol** — the line-based control protocol those DSPs expose over telnet (TCP port 23, no authentication).
|
|
25
|
+
|
|
26
|
+
Works with the Nexia family (CS / PM / SP / VC / TC) and Audia (FLOW, …). Pure standard library, so it runs anywhere Python does — macOS, Linux, a Raspberry Pi, inside Home Assistant, wherever. No vendor software or Windows box needed for day-to-day control.
|
|
27
|
+
|
|
28
|
+
> Extracted and generalized from a working Nexia PM sub-controller. The fiddly parts — telnet IAC stripping, reply framing, and the instance-ID drift problem — are already solved here.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# from GitHub (works today):
|
|
34
|
+
pip install git+https://github.com/biamp-ntp/biamp-ntp
|
|
35
|
+
|
|
36
|
+
# or from a local checkout:
|
|
37
|
+
pip install -e .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
> The short form `pip install biamp-ntp` will work once it's published to PyPI.
|
|
41
|
+
|
|
42
|
+
## CLI
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# read the device number (needed as <dev> in every command; usually 1)
|
|
46
|
+
biamp-ntp --host 192.168.1.199 devid
|
|
47
|
+
|
|
48
|
+
# discover instance IDs — the #1 thing you need (see "Instance IDs" below)
|
|
49
|
+
biamp-ntp --host 192.168.1.199 scan OUTLVLPM # sweep output-level blocks
|
|
50
|
+
biamp-ntp --host 192.168.1.199 scan MMLVLOUT # sweep matrix outputs
|
|
51
|
+
|
|
52
|
+
# read / write an attribute: <attr> <instance> <idx...> [value]
|
|
53
|
+
biamp-ntp --host 192.168.1.199 get OUTLVLPM 8 5 # output block inst 8, ch 5
|
|
54
|
+
biamp-ntp --host 192.168.1.199 set OUTLVLPM 8 5 -6.0 # set ch 5 to -6 dB
|
|
55
|
+
biamp-ntp --host 192.168.1.199 set OUTMUTEPM 8 5 1 # mute ch 5
|
|
56
|
+
|
|
57
|
+
# escape hatch: send any raw command line
|
|
58
|
+
biamp-ntp --host 192.168.1.199 raw GET 0 DEVID
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Library
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from biamp_ntp import BiampNTP, scan, protocol as p
|
|
65
|
+
|
|
66
|
+
with BiampNTP("192.168.1.199") as dsp:
|
|
67
|
+
print(dsp.device_id()) # -> 1
|
|
68
|
+
|
|
69
|
+
# find the output block's instance ID (don't hardcode it)
|
|
70
|
+
for inst, val in scan(dsp, p.OUTPUT_LEVEL_PM):
|
|
71
|
+
print("output block at instance", inst)
|
|
72
|
+
|
|
73
|
+
level = dsp.get_float(p.OUTPUT_LEVEL_PM, 8, 5) # ch 5 level, dB
|
|
74
|
+
dsp.set(p.OUTPUT_LEVEL_PM, 8, 5, -6.0) # set ch 5 to -6 dB
|
|
75
|
+
dsp.set(p.OUTPUT_MUTE_PM, 8, 5, True) # mute (bool -> 1)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`command()` is thread-safe and reconnects automatically if the socket drops.
|
|
79
|
+
|
|
80
|
+
## Instance IDs — read this
|
|
81
|
+
|
|
82
|
+
Every block in a Biamp design has an **instance ID**, used in every GET/SET. The catch: **instance IDs are assigned at compile time and renumber whenever the design is recompiled and re-pushed** from the Windows software. So the numbers in your `.nex` file go stale, and hardcoding them will silently break after an edit.
|
|
83
|
+
|
|
84
|
+
Always discover them live:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
biamp-ntp --host <ip> scan <ATTR>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Only a real block of that type answers; everything else returns `-ERR:XACTION ERROR`. Use `--range 1-400` for large designs.
|
|
91
|
+
|
|
92
|
+
## Common attributes
|
|
93
|
+
|
|
94
|
+
| Block | Level | Mute | Other | Index |
|
|
95
|
+
|---|---|---|---|---|
|
|
96
|
+
| PM input | `INPLVLPML` | `INPMUTEPML` | `INPGAINPML` | input |
|
|
97
|
+
| PM output | `OUTLVLPM` | `OUTMUTEPM` | `OUTINVRTPM` (polarity) | output ch |
|
|
98
|
+
| Generic output | `OUTLVL` | `OUTMUTE` | | output ch |
|
|
99
|
+
| Matrix mixer | `MMLVLOUT` / `MMLVLIN` | `MMMUTEOUT` | `MMLVLXP` (crosspoint) | out / in |
|
|
100
|
+
| Level (fader) | `FDRLVL` | `FDRMUTE` | | channel |
|
|
101
|
+
| Device | | | `DEVID` (GET 0 DEVID) | — |
|
|
102
|
+
|
|
103
|
+
Levels are dB; mute/invert are `0`/`1`. Crosspoints (`MMLVLXP`/`MMMUTEXP`) take two indices: input row, output column. Any attribute string works with `get`/`set` — the table is just the common ones. See `biamp_ntp/protocol.py`.
|
|
104
|
+
|
|
105
|
+
## Protocol notes (the gotchas)
|
|
106
|
+
|
|
107
|
+
- **Grammar:** `GET <dev> <ATTR> <inst> <idx...>` → value; `SET <dev> <ATTR> <inst> <idx...> <value>` → `+OK`. Errors: `-ERR:SYNTAX`, `-ERR:XACTION ERROR`.
|
|
108
|
+
- **Telnet IAC:** the server sends IAC negotiation bytes on connect and can interleave them with replies — this library strips them.
|
|
109
|
+
- **Reply timing:** replies dribble out just after the command echo; a single immediate `recv()` catches a partial frame. The client settles briefly then drains fully (tunable via `settle=`).
|
|
110
|
+
- **One client at a time:** the telnet server is happiest single-threaded; `command()` serializes on a lock.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pip install -e .
|
|
116
|
+
python -m unittest discover -s tests -v
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The unit tests need no hardware (they cover parsing / IAC / formatting).
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# biamp-ntp
|
|
2
|
+
|
|
3
|
+
A tiny, **dependency-free** Python client and CLI for the **Biamp Nexia / Audia Text Protocol** — the line-based control protocol those DSPs expose over telnet (TCP port 23, no authentication).
|
|
4
|
+
|
|
5
|
+
Works with the Nexia family (CS / PM / SP / VC / TC) and Audia (FLOW, …). Pure standard library, so it runs anywhere Python does — macOS, Linux, a Raspberry Pi, inside Home Assistant, wherever. No vendor software or Windows box needed for day-to-day control.
|
|
6
|
+
|
|
7
|
+
> Extracted and generalized from a working Nexia PM sub-controller. The fiddly parts — telnet IAC stripping, reply framing, and the instance-ID drift problem — are already solved here.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# from GitHub (works today):
|
|
13
|
+
pip install git+https://github.com/biamp-ntp/biamp-ntp
|
|
14
|
+
|
|
15
|
+
# or from a local checkout:
|
|
16
|
+
pip install -e .
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
> The short form `pip install biamp-ntp` will work once it's published to PyPI.
|
|
20
|
+
|
|
21
|
+
## CLI
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# read the device number (needed as <dev> in every command; usually 1)
|
|
25
|
+
biamp-ntp --host 192.168.1.199 devid
|
|
26
|
+
|
|
27
|
+
# discover instance IDs — the #1 thing you need (see "Instance IDs" below)
|
|
28
|
+
biamp-ntp --host 192.168.1.199 scan OUTLVLPM # sweep output-level blocks
|
|
29
|
+
biamp-ntp --host 192.168.1.199 scan MMLVLOUT # sweep matrix outputs
|
|
30
|
+
|
|
31
|
+
# read / write an attribute: <attr> <instance> <idx...> [value]
|
|
32
|
+
biamp-ntp --host 192.168.1.199 get OUTLVLPM 8 5 # output block inst 8, ch 5
|
|
33
|
+
biamp-ntp --host 192.168.1.199 set OUTLVLPM 8 5 -6.0 # set ch 5 to -6 dB
|
|
34
|
+
biamp-ntp --host 192.168.1.199 set OUTMUTEPM 8 5 1 # mute ch 5
|
|
35
|
+
|
|
36
|
+
# escape hatch: send any raw command line
|
|
37
|
+
biamp-ntp --host 192.168.1.199 raw GET 0 DEVID
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Library
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from biamp_ntp import BiampNTP, scan, protocol as p
|
|
44
|
+
|
|
45
|
+
with BiampNTP("192.168.1.199") as dsp:
|
|
46
|
+
print(dsp.device_id()) # -> 1
|
|
47
|
+
|
|
48
|
+
# find the output block's instance ID (don't hardcode it)
|
|
49
|
+
for inst, val in scan(dsp, p.OUTPUT_LEVEL_PM):
|
|
50
|
+
print("output block at instance", inst)
|
|
51
|
+
|
|
52
|
+
level = dsp.get_float(p.OUTPUT_LEVEL_PM, 8, 5) # ch 5 level, dB
|
|
53
|
+
dsp.set(p.OUTPUT_LEVEL_PM, 8, 5, -6.0) # set ch 5 to -6 dB
|
|
54
|
+
dsp.set(p.OUTPUT_MUTE_PM, 8, 5, True) # mute (bool -> 1)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`command()` is thread-safe and reconnects automatically if the socket drops.
|
|
58
|
+
|
|
59
|
+
## Instance IDs — read this
|
|
60
|
+
|
|
61
|
+
Every block in a Biamp design has an **instance ID**, used in every GET/SET. The catch: **instance IDs are assigned at compile time and renumber whenever the design is recompiled and re-pushed** from the Windows software. So the numbers in your `.nex` file go stale, and hardcoding them will silently break after an edit.
|
|
62
|
+
|
|
63
|
+
Always discover them live:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
biamp-ntp --host <ip> scan <ATTR>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Only a real block of that type answers; everything else returns `-ERR:XACTION ERROR`. Use `--range 1-400` for large designs.
|
|
70
|
+
|
|
71
|
+
## Common attributes
|
|
72
|
+
|
|
73
|
+
| Block | Level | Mute | Other | Index |
|
|
74
|
+
|---|---|---|---|---|
|
|
75
|
+
| PM input | `INPLVLPML` | `INPMUTEPML` | `INPGAINPML` | input |
|
|
76
|
+
| PM output | `OUTLVLPM` | `OUTMUTEPM` | `OUTINVRTPM` (polarity) | output ch |
|
|
77
|
+
| Generic output | `OUTLVL` | `OUTMUTE` | | output ch |
|
|
78
|
+
| Matrix mixer | `MMLVLOUT` / `MMLVLIN` | `MMMUTEOUT` | `MMLVLXP` (crosspoint) | out / in |
|
|
79
|
+
| Level (fader) | `FDRLVL` | `FDRMUTE` | | channel |
|
|
80
|
+
| Device | | | `DEVID` (GET 0 DEVID) | — |
|
|
81
|
+
|
|
82
|
+
Levels are dB; mute/invert are `0`/`1`. Crosspoints (`MMLVLXP`/`MMMUTEXP`) take two indices: input row, output column. Any attribute string works with `get`/`set` — the table is just the common ones. See `biamp_ntp/protocol.py`.
|
|
83
|
+
|
|
84
|
+
## Protocol notes (the gotchas)
|
|
85
|
+
|
|
86
|
+
- **Grammar:** `GET <dev> <ATTR> <inst> <idx...>` → value; `SET <dev> <ATTR> <inst> <idx...> <value>` → `+OK`. Errors: `-ERR:SYNTAX`, `-ERR:XACTION ERROR`.
|
|
87
|
+
- **Telnet IAC:** the server sends IAC negotiation bytes on connect and can interleave them with replies — this library strips them.
|
|
88
|
+
- **Reply timing:** replies dribble out just after the command echo; a single immediate `recv()` catches a partial frame. The client settles briefly then drains fully (tunable via `settle=`).
|
|
89
|
+
- **One client at a time:** the telnet server is happiest single-threaded; `command()` serializes on a lock.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install -e .
|
|
95
|
+
python -m unittest discover -s tests -v
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The unit tests need no hardware (they cover parsing / IAC / formatting).
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "biamp-ntp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Dependency-free Python client + CLI for the Biamp Nexia/Audia Text Protocol (telnet control)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Matt" }]
|
|
12
|
+
requires-python = ">=3.8"
|
|
13
|
+
keywords = ["biamp", "nexia", "audia", "dsp", "telnet", "audio", "home-automation", "av"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
20
|
+
"Topic :: Home Automation",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/biamp-ntp/biamp-ntp"
|
|
27
|
+
Issues = "https://github.com/biamp-ntp/biamp-ntp/issues"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
biamp-ntp = "biamp_ntp.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Command-line interface: ``biamp-ntp``.
|
|
2
|
+
|
|
3
|
+
Examples::
|
|
4
|
+
|
|
5
|
+
biamp-ntp --host 192.168.1.199 devid
|
|
6
|
+
biamp-ntp --host 192.168.1.199 scan OUTLVLPM
|
|
7
|
+
biamp-ntp --host 192.168.1.199 get OUTLVLPM 8 5
|
|
8
|
+
biamp-ntp --host 192.168.1.199 set OUTLVLPM 8 5 -6.0
|
|
9
|
+
biamp-ntp --host 192.168.1.199 raw GET 0 DEVID
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from .client import BiampNTP, BiampError
|
|
15
|
+
from .scan import scan
|
|
16
|
+
|
|
17
|
+
__all__ = ["main", "build_parser"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_range(s):
|
|
21
|
+
lo, _, hi = s.partition("-")
|
|
22
|
+
return int(lo), int(hi or lo)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_parser():
|
|
26
|
+
p = argparse.ArgumentParser(
|
|
27
|
+
prog="biamp-ntp",
|
|
28
|
+
description="Control a Biamp Nexia/Audia DSP over the Text Protocol "
|
|
29
|
+
"(telnet, port 23, no auth).")
|
|
30
|
+
p.add_argument("--host", required=True, help="DSP IP address or hostname")
|
|
31
|
+
p.add_argument("--device", type=int, default=1,
|
|
32
|
+
help="device number (default 1; read it with the 'devid' command)")
|
|
33
|
+
p.add_argument("--port", type=int, default=23)
|
|
34
|
+
p.add_argument("--timeout", type=float, default=3.0)
|
|
35
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
36
|
+
|
|
37
|
+
sub.add_parser("devid", help="read the device number (GET 0 DEVID)")
|
|
38
|
+
|
|
39
|
+
sp = sub.add_parser("scan", help="find instance IDs that answer an attribute")
|
|
40
|
+
sp.add_argument("attr", help="attribute code, e.g. OUTLVLPM or MMLVLOUT")
|
|
41
|
+
sp.add_argument("idx", nargs="*", type=int, help="index/channel to probe (default 1)")
|
|
42
|
+
sp.add_argument("--range", default="1-200", help="instance range, e.g. 1-300")
|
|
43
|
+
|
|
44
|
+
sp = sub.add_parser("get", help="GET an attribute")
|
|
45
|
+
sp.add_argument("attr")
|
|
46
|
+
sp.add_argument("inst", type=int)
|
|
47
|
+
sp.add_argument("idx", nargs="*", type=int)
|
|
48
|
+
|
|
49
|
+
sp = sub.add_parser("set", help="SET an attribute (last arg is the value)")
|
|
50
|
+
sp.add_argument("attr")
|
|
51
|
+
sp.add_argument("inst", type=int)
|
|
52
|
+
sp.add_argument("args", nargs="+", metavar="idx...-value")
|
|
53
|
+
|
|
54
|
+
sp = sub.add_parser("raw", help="send a raw command line and print the reply")
|
|
55
|
+
sp.add_argument("line", nargs=argparse.REMAINDER)
|
|
56
|
+
|
|
57
|
+
return p
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main(argv=None):
|
|
61
|
+
args = build_parser().parse_args(argv)
|
|
62
|
+
dsp = BiampNTP(args.host, device=args.device, port=args.port, timeout=args.timeout)
|
|
63
|
+
try:
|
|
64
|
+
with dsp:
|
|
65
|
+
if args.cmd == "devid":
|
|
66
|
+
print(dsp.device_id())
|
|
67
|
+
elif args.cmd == "scan":
|
|
68
|
+
lo, hi = _parse_range(args.range)
|
|
69
|
+
idx = args.idx or [1]
|
|
70
|
+
n = 0
|
|
71
|
+
for inst, val in scan(dsp, args.attr, *idx, rng=range(lo, hi + 1)):
|
|
72
|
+
print("instance %d -> %s" % (inst, val))
|
|
73
|
+
n += 1
|
|
74
|
+
sys.stdout.flush()
|
|
75
|
+
print("(%d instance(s) answered %s)" % (n, args.attr), file=sys.stderr)
|
|
76
|
+
elif args.cmd == "get":
|
|
77
|
+
print(dsp.get(args.attr, args.inst, *args.idx))
|
|
78
|
+
elif args.cmd == "set":
|
|
79
|
+
idx, value = args.args[:-1], args.args[-1]
|
|
80
|
+
dsp.set(args.attr, args.inst, *[int(i) for i in idx], value)
|
|
81
|
+
print("+OK")
|
|
82
|
+
elif args.cmd == "raw":
|
|
83
|
+
print(dsp.command(" ".join(args.line)))
|
|
84
|
+
except BiampError as e:
|
|
85
|
+
print("error: %s" % e, file=sys.stderr)
|
|
86
|
+
return 2
|
|
87
|
+
except OSError as e:
|
|
88
|
+
print("connection error: %s" % e, file=sys.stderr)
|
|
89
|
+
return 1
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""A small, dependency-free client for the Biamp Nexia / Audia Text Protocol.
|
|
2
|
+
|
|
3
|
+
The Nexia (CS/PM/SP/VC/TC) and Audia (FLOW, ...) families expose a line-based
|
|
4
|
+
control protocol over TCP port 23 (telnet, no authentication). This module
|
|
5
|
+
speaks it with nothing but the Python standard library.
|
|
6
|
+
"""
|
|
7
|
+
import socket
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
__all__ = ["BiampNTP", "BiampError"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BiampError(Exception):
|
|
15
|
+
"""A command returned a protocol error (-ERR:...) or an unparseable reply."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _strip_iac(b):
|
|
19
|
+
"""Remove telnet IAC negotiation triples (0xFF cmd opt) from a raw buffer.
|
|
20
|
+
|
|
21
|
+
The Nexia/Audia telnet server emits IAC negotiation on connect and can
|
|
22
|
+
interleave it with replies; left in, it corrupts parsing.
|
|
23
|
+
"""
|
|
24
|
+
out = bytearray()
|
|
25
|
+
i, n = 0, len(b)
|
|
26
|
+
while i < n:
|
|
27
|
+
if b[i] == 0xFF:
|
|
28
|
+
i += 3 # skip IAC + command byte + option byte
|
|
29
|
+
continue
|
|
30
|
+
out.append(b[i])
|
|
31
|
+
i += 1
|
|
32
|
+
return bytes(out)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _fmt(v):
|
|
36
|
+
"""Format a Python value for the wire (bool -> 0/1, tidy floats)."""
|
|
37
|
+
if isinstance(v, bool):
|
|
38
|
+
return "1" if v else "0"
|
|
39
|
+
if isinstance(v, float):
|
|
40
|
+
s = ("%.4f" % v).rstrip("0").rstrip(".")
|
|
41
|
+
return s or "0"
|
|
42
|
+
return str(v)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BiampNTP:
|
|
46
|
+
"""Telnet client for one Biamp DSP.
|
|
47
|
+
|
|
48
|
+
Typical use::
|
|
49
|
+
|
|
50
|
+
from biamp_ntp import BiampNTP, protocol as p
|
|
51
|
+
|
|
52
|
+
with BiampNTP("192.168.1.199") as dsp:
|
|
53
|
+
print(dsp.device_id()) # device number
|
|
54
|
+
db = dsp.get_float(p.OUTPUT_LEVEL_PM, 8, 5) # output block inst 8, ch 5
|
|
55
|
+
dsp.set(p.OUTPUT_LEVEL_PM, 8, 5, -6.0) # set ch 5 to -6 dB
|
|
56
|
+
|
|
57
|
+
Command grammar::
|
|
58
|
+
|
|
59
|
+
GET <dev> <ATTR> <inst> <idx...> -> value
|
|
60
|
+
SET <dev> <ATTR> <inst> <idx...> <value> -> +OK
|
|
61
|
+
errors: -ERR:SYNTAX | -ERR:XACTION ERROR
|
|
62
|
+
|
|
63
|
+
Instance IDs are assigned at compile time and RENUMBER whenever the design
|
|
64
|
+
is recompiled and re-pushed, so numbers taken from a .nex file go stale.
|
|
65
|
+
Discover them live with :func:`biamp_ntp.scan.scan`.
|
|
66
|
+
|
|
67
|
+
``command()`` is thread-safe (serialized on an internal lock); the telnet
|
|
68
|
+
server is happiest with one client at a time.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, host, device=1, port=23, timeout=3.0, settle=0.25):
|
|
72
|
+
self.host = host
|
|
73
|
+
self.device = device
|
|
74
|
+
self.port = port
|
|
75
|
+
self.timeout = timeout
|
|
76
|
+
self.settle = settle
|
|
77
|
+
self._sock = None
|
|
78
|
+
self._lock = threading.Lock()
|
|
79
|
+
|
|
80
|
+
# -- connection ------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def connect(self):
|
|
83
|
+
"""Open the telnet session (idempotent). Returns self."""
|
|
84
|
+
if self._sock is None:
|
|
85
|
+
self._sock = socket.create_connection(
|
|
86
|
+
(self.host, self.port), timeout=self.timeout)
|
|
87
|
+
self._drain() # swallow banner + IAC negotiation
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def close(self):
|
|
91
|
+
"""Close the session if open."""
|
|
92
|
+
if self._sock is not None:
|
|
93
|
+
try:
|
|
94
|
+
self._sock.close()
|
|
95
|
+
finally:
|
|
96
|
+
self._sock = None
|
|
97
|
+
|
|
98
|
+
def __enter__(self):
|
|
99
|
+
return self.connect()
|
|
100
|
+
|
|
101
|
+
def __exit__(self, *exc):
|
|
102
|
+
self.close()
|
|
103
|
+
|
|
104
|
+
# -- framing ---------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def _drain(self):
|
|
107
|
+
"""Read the whole reply frame: settle briefly, then mop up non-blocking.
|
|
108
|
+
|
|
109
|
+
The device dribbles its reply just after echoing the command, so a
|
|
110
|
+
single immediate recv() catches a partial frame (and splits IAC
|
|
111
|
+
triples). A short settle plus a brief non-blocking drain gets it whole.
|
|
112
|
+
"""
|
|
113
|
+
time.sleep(self.settle)
|
|
114
|
+
chunks = bytearray()
|
|
115
|
+
self._sock.settimeout(0.15)
|
|
116
|
+
while True:
|
|
117
|
+
try:
|
|
118
|
+
b = self._sock.recv(4096)
|
|
119
|
+
if not b:
|
|
120
|
+
break
|
|
121
|
+
chunks += b
|
|
122
|
+
except socket.timeout:
|
|
123
|
+
break
|
|
124
|
+
return bytes(chunks)
|
|
125
|
+
|
|
126
|
+
def _reply(self, raw):
|
|
127
|
+
"""Extract the reply string: strip IAC, take the last non-empty line."""
|
|
128
|
+
lines = [l for l in _strip_iac(raw).replace(b"\r", b"").split(b"\n")
|
|
129
|
+
if l.strip()]
|
|
130
|
+
return lines[-1].decode("latin-1", "replace").strip() if lines else ""
|
|
131
|
+
|
|
132
|
+
# -- I/O -------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def command(self, text, retry=True):
|
|
135
|
+
"""Send one command line; return its reply string. Thread-safe.
|
|
136
|
+
|
|
137
|
+
Reconnects and retries once if the socket has dropped (including a
|
|
138
|
+
silent drop that yields an empty reply).
|
|
139
|
+
"""
|
|
140
|
+
with self._lock:
|
|
141
|
+
last = ""
|
|
142
|
+
for attempt in (1, 2):
|
|
143
|
+
try:
|
|
144
|
+
self.connect()
|
|
145
|
+
self._sock.settimeout(self.timeout)
|
|
146
|
+
self._sock.sendall((text + "\n").encode())
|
|
147
|
+
last = self._reply(self._drain())
|
|
148
|
+
if last or not retry:
|
|
149
|
+
return last
|
|
150
|
+
self.close() # empty reply -> likely dropped; retry
|
|
151
|
+
except OSError:
|
|
152
|
+
self.close()
|
|
153
|
+
if attempt == 2 or not retry:
|
|
154
|
+
raise
|
|
155
|
+
return last
|
|
156
|
+
|
|
157
|
+
# -- typed helpers ---------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def query(self, attr, inst, *idx):
|
|
160
|
+
"""Raw GET: returns the reply string (a value, or -ERR:... on failure)."""
|
|
161
|
+
parts = ["GET", str(self.device), attr, str(inst)]
|
|
162
|
+
parts += [str(i) for i in idx]
|
|
163
|
+
return self.command(" ".join(parts))
|
|
164
|
+
|
|
165
|
+
def get(self, attr, inst, *idx):
|
|
166
|
+
"""GET, raising BiampError on error/empty; returns the string value."""
|
|
167
|
+
r = self.query(attr, inst, *idx)
|
|
168
|
+
if not r or r.startswith("-ERR"):
|
|
169
|
+
raise BiampError("GET %s %s %s -> %r" % (attr, inst, list(idx), r))
|
|
170
|
+
return r
|
|
171
|
+
|
|
172
|
+
def get_float(self, attr, inst, *idx):
|
|
173
|
+
return float(self.get(attr, inst, *idx))
|
|
174
|
+
|
|
175
|
+
def get_bool(self, attr, inst, *idx):
|
|
176
|
+
return self.get(attr, inst, *idx).strip() == "1"
|
|
177
|
+
|
|
178
|
+
def set(self, attr, inst, *idx_and_value):
|
|
179
|
+
"""SET: the last positional is the value, the rest are indices.
|
|
180
|
+
|
|
181
|
+
Returns True on +OK, else raises BiampError. Booleans map to 0/1.
|
|
182
|
+
"""
|
|
183
|
+
if not idx_and_value:
|
|
184
|
+
raise TypeError("set() needs at least a value")
|
|
185
|
+
idx = idx_and_value[:-1]
|
|
186
|
+
value = idx_and_value[-1]
|
|
187
|
+
parts = ["SET", str(self.device), attr, str(inst)]
|
|
188
|
+
parts += [str(i) for i in idx] + [_fmt(value)]
|
|
189
|
+
r = self.command(" ".join(parts))
|
|
190
|
+
if r != "+OK":
|
|
191
|
+
raise BiampError("SET %s %s %s %s -> %r"
|
|
192
|
+
% (attr, inst, list(idx), value, r))
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
def device_id(self):
|
|
196
|
+
"""Read the device number (GET 0 DEVID)."""
|
|
197
|
+
r = self.command("GET 0 DEVID")
|
|
198
|
+
if not r or r.startswith("-ERR"):
|
|
199
|
+
raise BiampError("GET 0 DEVID -> %r" % r)
|
|
200
|
+
return int(r)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Biamp NTP attribute codes, grouped by block type.
|
|
2
|
+
|
|
3
|
+
These are the attribute names used in GET/SET commands. Which set applies
|
|
4
|
+
depends on the blocks in your running design; discover their instance IDs with
|
|
5
|
+
:func:`biamp_ntp.scan.scan`. This is a convenience map of the common ones, not
|
|
6
|
+
an exhaustive list -- any attribute string works with ``BiampNTP.get``/``set``.
|
|
7
|
+
|
|
8
|
+
Levels are in dB unless noted; mute/invert are 0/1; index is the channel /
|
|
9
|
+
crosspoint selector for that block.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# --- Nexia PM input block ---------------------------------------------------
|
|
13
|
+
INPUT_LEVEL_PM = "INPLVLPML" # idx = input
|
|
14
|
+
INPUT_MUTE_PM = "INPMUTEPML" # idx = input
|
|
15
|
+
INPUT_GAIN_PM = "INPGAINPML" # idx = input (analog input gain)
|
|
16
|
+
|
|
17
|
+
# --- Nexia PM output block --------------------------------------------------
|
|
18
|
+
OUTPUT_LEVEL_PM = "OUTLVLPM" # idx = output channel, -100..+12 dB
|
|
19
|
+
OUTPUT_MUTE_PM = "OUTMUTEPM" # idx = output channel, 0/1
|
|
20
|
+
OUTPUT_INVERT_PM = "OUTINVRTPM" # idx = output channel, 0/1 (polarity)
|
|
21
|
+
OUTPUT_FS_PM = "OUTFSPM" # idx = output channel (full-scale / range)
|
|
22
|
+
|
|
23
|
+
# --- Generic (non-PM) output block ------------------------------------------
|
|
24
|
+
OUTPUT_LEVEL = "OUTLVL" # idx = output channel
|
|
25
|
+
OUTPUT_MUTE = "OUTMUTE" # idx = output channel
|
|
26
|
+
|
|
27
|
+
# --- Standard matrix mixer --------------------------------------------------
|
|
28
|
+
MATRIX_IN_LEVEL = "MMLVLIN" # idx = input
|
|
29
|
+
MATRIX_OUT_LEVEL = "MMLVLOUT" # idx = output, -100..+12 dB
|
|
30
|
+
MATRIX_OUT_MUTE = "MMMUTEOUT" # idx = output, 0/1
|
|
31
|
+
MATRIX_XP_LEVEL = "MMLVLXP" # idx1 = input row, idx2 = output col, -100..0 dB
|
|
32
|
+
MATRIX_XP_MUTE = "MMMUTEXP" # idx1 = input row, idx2 = output col, 0/1
|
|
33
|
+
|
|
34
|
+
# --- Standard level (fader) block -------------------------------------------
|
|
35
|
+
FADER_LEVEL = "FDRLVL" # idx = channel
|
|
36
|
+
FADER_MUTE = "FDRMUTE" # idx = channel
|
|
37
|
+
|
|
38
|
+
# --- Device -----------------------------------------------------------------
|
|
39
|
+
DEVICE_ID = "DEVID" # GET 0 DEVID -> device number
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Instance-ID discovery for a running Biamp design.
|
|
2
|
+
|
|
3
|
+
Instance IDs change on every recompile+push, so numbers taken from a .nex file
|
|
4
|
+
go stale. Sweep a GET across candidate instance IDs; only a real block of the
|
|
5
|
+
matching type answers with a value instead of an -ERR:XACTION ERROR.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__all__ = ["scan"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def scan(client, attr, *idx, rng=range(1, 200)):
|
|
12
|
+
"""Yield ``(instance_id, value)`` for each instance that answers ``attr``.
|
|
13
|
+
|
|
14
|
+
``idx`` defaults to ``(1,)`` (the first channel). Example::
|
|
15
|
+
|
|
16
|
+
from biamp_ntp import BiampNTP, scan, protocol as p
|
|
17
|
+
with BiampNTP("192.168.1.199") as dsp:
|
|
18
|
+
for inst, val in scan(dsp, p.OUTPUT_LEVEL_PM):
|
|
19
|
+
print(inst, val)
|
|
20
|
+
|
|
21
|
+
Widen ``rng`` if nothing turns up -- large designs can push instance IDs
|
|
22
|
+
well past the default upper bound.
|
|
23
|
+
"""
|
|
24
|
+
if not idx:
|
|
25
|
+
idx = (1,)
|
|
26
|
+
for inst in rng:
|
|
27
|
+
r = client.query(attr, inst, *idx)
|
|
28
|
+
if r and not r.startswith("-ERR"):
|
|
29
|
+
yield inst, r
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: biamp-ntp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dependency-free Python client + CLI for the Biamp Nexia/Audia Text Protocol (telnet control)
|
|
5
|
+
Author: Matt
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/biamp-ntp/biamp-ntp
|
|
8
|
+
Project-URL: Issues, https://github.com/biamp-ntp/biamp-ntp/issues
|
|
9
|
+
Keywords: biamp,nexia,audia,dsp,telnet,audio,home-automation,av
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
15
|
+
Classifier: Topic :: Home Automation
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# biamp-ntp
|
|
23
|
+
|
|
24
|
+
A tiny, **dependency-free** Python client and CLI for the **Biamp Nexia / Audia Text Protocol** — the line-based control protocol those DSPs expose over telnet (TCP port 23, no authentication).
|
|
25
|
+
|
|
26
|
+
Works with the Nexia family (CS / PM / SP / VC / TC) and Audia (FLOW, …). Pure standard library, so it runs anywhere Python does — macOS, Linux, a Raspberry Pi, inside Home Assistant, wherever. No vendor software or Windows box needed for day-to-day control.
|
|
27
|
+
|
|
28
|
+
> Extracted and generalized from a working Nexia PM sub-controller. The fiddly parts — telnet IAC stripping, reply framing, and the instance-ID drift problem — are already solved here.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# from GitHub (works today):
|
|
34
|
+
pip install git+https://github.com/biamp-ntp/biamp-ntp
|
|
35
|
+
|
|
36
|
+
# or from a local checkout:
|
|
37
|
+
pip install -e .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
> The short form `pip install biamp-ntp` will work once it's published to PyPI.
|
|
41
|
+
|
|
42
|
+
## CLI
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# read the device number (needed as <dev> in every command; usually 1)
|
|
46
|
+
biamp-ntp --host 192.168.1.199 devid
|
|
47
|
+
|
|
48
|
+
# discover instance IDs — the #1 thing you need (see "Instance IDs" below)
|
|
49
|
+
biamp-ntp --host 192.168.1.199 scan OUTLVLPM # sweep output-level blocks
|
|
50
|
+
biamp-ntp --host 192.168.1.199 scan MMLVLOUT # sweep matrix outputs
|
|
51
|
+
|
|
52
|
+
# read / write an attribute: <attr> <instance> <idx...> [value]
|
|
53
|
+
biamp-ntp --host 192.168.1.199 get OUTLVLPM 8 5 # output block inst 8, ch 5
|
|
54
|
+
biamp-ntp --host 192.168.1.199 set OUTLVLPM 8 5 -6.0 # set ch 5 to -6 dB
|
|
55
|
+
biamp-ntp --host 192.168.1.199 set OUTMUTEPM 8 5 1 # mute ch 5
|
|
56
|
+
|
|
57
|
+
# escape hatch: send any raw command line
|
|
58
|
+
biamp-ntp --host 192.168.1.199 raw GET 0 DEVID
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Library
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from biamp_ntp import BiampNTP, scan, protocol as p
|
|
65
|
+
|
|
66
|
+
with BiampNTP("192.168.1.199") as dsp:
|
|
67
|
+
print(dsp.device_id()) # -> 1
|
|
68
|
+
|
|
69
|
+
# find the output block's instance ID (don't hardcode it)
|
|
70
|
+
for inst, val in scan(dsp, p.OUTPUT_LEVEL_PM):
|
|
71
|
+
print("output block at instance", inst)
|
|
72
|
+
|
|
73
|
+
level = dsp.get_float(p.OUTPUT_LEVEL_PM, 8, 5) # ch 5 level, dB
|
|
74
|
+
dsp.set(p.OUTPUT_LEVEL_PM, 8, 5, -6.0) # set ch 5 to -6 dB
|
|
75
|
+
dsp.set(p.OUTPUT_MUTE_PM, 8, 5, True) # mute (bool -> 1)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`command()` is thread-safe and reconnects automatically if the socket drops.
|
|
79
|
+
|
|
80
|
+
## Instance IDs — read this
|
|
81
|
+
|
|
82
|
+
Every block in a Biamp design has an **instance ID**, used in every GET/SET. The catch: **instance IDs are assigned at compile time and renumber whenever the design is recompiled and re-pushed** from the Windows software. So the numbers in your `.nex` file go stale, and hardcoding them will silently break after an edit.
|
|
83
|
+
|
|
84
|
+
Always discover them live:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
biamp-ntp --host <ip> scan <ATTR>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Only a real block of that type answers; everything else returns `-ERR:XACTION ERROR`. Use `--range 1-400` for large designs.
|
|
91
|
+
|
|
92
|
+
## Common attributes
|
|
93
|
+
|
|
94
|
+
| Block | Level | Mute | Other | Index |
|
|
95
|
+
|---|---|---|---|---|
|
|
96
|
+
| PM input | `INPLVLPML` | `INPMUTEPML` | `INPGAINPML` | input |
|
|
97
|
+
| PM output | `OUTLVLPM` | `OUTMUTEPM` | `OUTINVRTPM` (polarity) | output ch |
|
|
98
|
+
| Generic output | `OUTLVL` | `OUTMUTE` | | output ch |
|
|
99
|
+
| Matrix mixer | `MMLVLOUT` / `MMLVLIN` | `MMMUTEOUT` | `MMLVLXP` (crosspoint) | out / in |
|
|
100
|
+
| Level (fader) | `FDRLVL` | `FDRMUTE` | | channel |
|
|
101
|
+
| Device | | | `DEVID` (GET 0 DEVID) | — |
|
|
102
|
+
|
|
103
|
+
Levels are dB; mute/invert are `0`/`1`. Crosspoints (`MMLVLXP`/`MMMUTEXP`) take two indices: input row, output column. Any attribute string works with `get`/`set` — the table is just the common ones. See `biamp_ntp/protocol.py`.
|
|
104
|
+
|
|
105
|
+
## Protocol notes (the gotchas)
|
|
106
|
+
|
|
107
|
+
- **Grammar:** `GET <dev> <ATTR> <inst> <idx...>` → value; `SET <dev> <ATTR> <inst> <idx...> <value>` → `+OK`. Errors: `-ERR:SYNTAX`, `-ERR:XACTION ERROR`.
|
|
108
|
+
- **Telnet IAC:** the server sends IAC negotiation bytes on connect and can interleave them with replies — this library strips them.
|
|
109
|
+
- **Reply timing:** replies dribble out just after the command echo; a single immediate `recv()` catches a partial frame. The client settles briefly then drains fully (tunable via `settle=`).
|
|
110
|
+
- **One client at a time:** the telnet server is happiest single-threaded; `command()` serializes on a lock.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pip install -e .
|
|
116
|
+
python -m unittest discover -s tests -v
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The unit tests need no hardware (they cover parsing / IAC / formatting).
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/biamp_ntp/__init__.py
|
|
5
|
+
src/biamp_ntp/cli.py
|
|
6
|
+
src/biamp_ntp/client.py
|
|
7
|
+
src/biamp_ntp/protocol.py
|
|
8
|
+
src/biamp_ntp/scan.py
|
|
9
|
+
src/biamp_ntp.egg-info/PKG-INFO
|
|
10
|
+
src/biamp_ntp.egg-info/SOURCES.txt
|
|
11
|
+
src/biamp_ntp.egg-info/dependency_links.txt
|
|
12
|
+
src/biamp_ntp.egg-info/entry_points.txt
|
|
13
|
+
src/biamp_ntp.egg-info/top_level.txt
|
|
14
|
+
tests/test_protocol.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
biamp_ntp
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Unit tests that need no hardware -- parsing, IAC stripping, wire formatting."""
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
from biamp_ntp.client import BiampNTP, _fmt, _strip_iac
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StripIAC(unittest.TestCase):
|
|
8
|
+
def test_single_triple(self):
|
|
9
|
+
# IAC WILL ECHO (0xFF 0xFB 0x01) prefixing "+OK"
|
|
10
|
+
self.assertEqual(_strip_iac(b"\xff\xfb\x01+OK\r\n"), b"+OK\r\n")
|
|
11
|
+
|
|
12
|
+
def test_multiple_triples(self):
|
|
13
|
+
self.assertEqual(_strip_iac(b"\xff\xfd\x03\xff\xfb\x01-3.00\r\n"), b"-3.00\r\n")
|
|
14
|
+
|
|
15
|
+
def test_no_iac(self):
|
|
16
|
+
self.assertEqual(_strip_iac(b"-3.00\r\n"), b"-3.00\r\n")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Fmt(unittest.TestCase):
|
|
20
|
+
def test_bool(self):
|
|
21
|
+
self.assertEqual(_fmt(True), "1")
|
|
22
|
+
self.assertEqual(_fmt(False), "0")
|
|
23
|
+
|
|
24
|
+
def test_float(self):
|
|
25
|
+
self.assertEqual(_fmt(1.0), "1")
|
|
26
|
+
self.assertEqual(_fmt(-3.5), "-3.5")
|
|
27
|
+
self.assertEqual(_fmt(0.0), "0")
|
|
28
|
+
self.assertEqual(_fmt(-6.25), "-6.25")
|
|
29
|
+
|
|
30
|
+
def test_int_and_str(self):
|
|
31
|
+
self.assertEqual(_fmt(5), "5")
|
|
32
|
+
self.assertEqual(_fmt("on"), "on")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Reply(unittest.TestCase):
|
|
36
|
+
def setUp(self):
|
|
37
|
+
self.c = BiampNTP("test.invalid") # no connection is opened
|
|
38
|
+
|
|
39
|
+
def test_strips_iac_and_takes_last_line(self):
|
|
40
|
+
self.assertEqual(self.c._reply(b"\xff\xfb\x01-3.00\r\n"), "-3.00")
|
|
41
|
+
|
|
42
|
+
def test_echo_then_value(self):
|
|
43
|
+
raw = b"GET 1 OUTLVLPM 8 5\r\n-3.00\r\n"
|
|
44
|
+
self.assertEqual(self.c._reply(raw), "-3.00")
|
|
45
|
+
|
|
46
|
+
def test_ok(self):
|
|
47
|
+
self.assertEqual(self.c._reply(b"SET 1 OUTLVLPM 8 5 -3.00\r\n+OK\r\n"), "+OK")
|
|
48
|
+
|
|
49
|
+
def test_empty(self):
|
|
50
|
+
self.assertEqual(self.c._reply(b"\xff\xfb\x01"), "")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
unittest.main()
|