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.
@@ -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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """biamp-ntp: a dependency-free client + CLI for the Biamp Nexia/Audia Text Protocol."""
2
+ from . import protocol
3
+ from .client import BiampError, BiampNTP
4
+ from .scan import scan
5
+
6
+ __all__ = ["BiampNTP", "BiampError", "scan", "protocol"]
7
+ __version__ = "0.1.0"
@@ -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,2 @@
1
+ [console_scripts]
2
+ biamp-ntp = biamp_ntp.cli:main
@@ -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()