ultimattewire 0.1.0.dev1__tar.gz → 0.2.0.dev0__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.
Files changed (18) hide show
  1. {ultimattewire-0.1.0.dev1/ultimattewire.egg-info → ultimattewire-0.2.0.dev0}/PKG-INFO +66 -6
  2. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/README.md +65 -5
  3. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/pyproject.toml +1 -1
  4. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/ultimattewire/__init__.py +6 -0
  5. ultimattewire-0.2.0.dev0/ultimattewire/network.py +283 -0
  6. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0/ultimattewire.egg-info}/PKG-INFO +66 -6
  7. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/ultimattewire.egg-info/SOURCES.txt +1 -0
  8. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/LICENSE +0 -0
  9. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/setup.cfg +0 -0
  10. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/ultimattewire/_preamble.py +0 -0
  11. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/ultimattewire/_protocol.py +0 -0
  12. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/ultimattewire/profile/__init__.py +0 -0
  13. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/ultimattewire/profile/_ranges.py +0 -0
  14. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/ultimattewire/profile/archive.py +0 -0
  15. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/ultimattewire/profile/restore.py +0 -0
  16. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/ultimattewire.egg-info/dependency_links.txt +0 -0
  17. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/ultimattewire.egg-info/requires.txt +0 -0
  18. {ultimattewire-0.1.0.dev1 → ultimattewire-0.2.0.dev0}/ultimattewire.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ultimattewire
3
- Version: 0.1.0.dev1
3
+ Version: 0.2.0.dev0
4
4
  Summary: Blackmagic Ultimatte 12 archive and restore over its native TCP protocol (Smart Remote 4 compatible zips)
5
5
  Author: Lucas Romanenko
6
6
  License: MIT
@@ -24,10 +24,12 @@ Dynamic: license-file
24
24
 
25
25
  # ultimattewire
26
26
 
27
- Python library that archives and restores the configuration of a Blackmagic
28
- **Ultimatte 12** or **Ultimatte 12 4K** keyer over its native TCP protocol,
29
- producing and consuming the same zip archives the vendor's Smart Remote 4
30
- writes with **Archive All** and reads with **Restore**.
27
+ Python library for the Blackmagic **Ultimatte 12** and **Ultimatte 12 4K**
28
+ keyers over their native TCP protocol. It archives and restores unit
29
+ configuration — producing and consuming the same zip archives the vendor's
30
+ Smart Remote 4 writes with **Archive All** and reads with **Restore** — and
31
+ reads and sets the unit's **network interface** (address, netmask, gateway,
32
+ DNS, static or DHCP), which is what the vendor's Ultimatte Setup does.
31
33
 
32
34
  [![CI](https://github.com/lucas-romanenko/bmdwire/actions/workflows/ci.yml/badge.svg)](https://github.com/lucas-romanenko/bmdwire/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/ultimattewire.svg)](https://pypi.org/project/ultimattewire/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg) [![Latest tag](https://img.shields.io/github/v/tag/lucas-romanenko/bmdwire?filter=ultimattewire-v*&label=release)](https://github.com/lucas-romanenko/bmdwire/tags)
33
35
 
@@ -40,7 +42,7 @@ established and what is still a guess.
40
42
 
41
43
  ## Status
42
44
 
43
- - **Pre-release** (`0.1.0.dev1`), extracted from a broadcast control
45
+ - **Pre-release** (`0.2.0.dev0`), extracted from a broadcast control
44
46
  application where operators archive and restore keyer configurations from a
45
47
  web page.
46
48
  - Verified against an Ultimatte 12 4K (the unit the captures came from). The
@@ -65,6 +67,7 @@ Python 3.10 or newer. No other dependencies.
65
67
 
66
68
  ## Features
67
69
 
70
+ - `read_network(host)` / `set_network(host, address=…, netmask=…, gateway=…, dns=…, dynamic=…)`: the unit's network interface — what Ultimatte Setup configures. `set_network` verifies by reading back the *settled* interface, not by trusting the unit's acknowledgement.
68
71
  - `archive_unit_to_bytes(host)`: pull every saved preset slot plus the `GPISettings` and `SavedSettings` resources into an in-memory zip in Smart Remote's exact layout, with a human-readable annotated state dump riding along.
69
72
  - `restore_unit_from_bytes(host, zip_bytes)`: push such a zip back, in the order the vendor app uses, aborting at the first rejected write.
70
73
  - Reads the unit's text prelude on TCP 9998 (label, firmware release, live control values, the preset FILE LIST) and does binary slot/resource reads and writes on TCP 9996.
@@ -96,6 +99,19 @@ A zip produced by this library can be opened with Smart Remote 4's Restore
96
99
  unchanged, and a Smart Remote "Archive All" zip can be passed straight to
97
100
  `restore_unit_from_bytes`.
98
101
 
102
+ ```python
103
+ from ultimattewire import read_network, set_network
104
+
105
+ iface = read_network("192.0.2.21")
106
+ print(iface.static_address, iface.static_netmask, iface.static_gateway, iface.mac)
107
+
108
+ # Widen the mask, keeping the address and gateway. Returns the settled
109
+ # interface as the unit reports it — not what was asked for.
110
+ iface = set_network("192.0.2.21", address=iface.static_address,
111
+ netmask="255.255.248.0", gateway=iface.static_gateway)
112
+ assert iface.netmask == "255.255.248.0"
113
+ ```
114
+
99
115
  ## Protocol notes
100
116
 
101
117
  This is the reverse-engineered part and the most useful thing to read before
@@ -144,6 +160,50 @@ already hit and fixed are recorded in `_preamble.py` (a `$` anchor that
144
160
  truncated multi-line FILE LISTs, and a `\s*` that let an empty section
145
161
  swallow the next one).
146
162
 
163
+ ### Setting values: the 9998 block protocol
164
+
165
+ The 9998 channel is not only a prelude. After it, the unit accepts **blocks**:
166
+ an upper-case section header ending in a colon, then `key: value` lines, then
167
+ a **blank line — and the blank line is what submits the block.** Nothing
168
+ happens until it arrives, which is why single-line probes (`IDENTITY?`,
169
+ `help`, `ping`, with either line ending) draw no reply at all and look like
170
+ the unit ignoring the client. That is the one fact the whole feature rests on.
171
+
172
+ The unit answers `ACK\n\n` followed by the section echoed back, or a bare
173
+ `NAK\n\n` for an unknown section or a value it will not take.
174
+
175
+ Three properties worth knowing:
176
+
177
+ - **An empty block is a query.** Header, blank line, no fields: sets nothing
178
+ and echoes the full section. Read and write are therefore one code path,
179
+ and reading network state needs no prelude parsing.
180
+ - **The echo after a set carries only the fields that were sent**, not the
181
+ section. A query echoes all eleven lines of `NETWORK INTERFACE 0`; a set of
182
+ two fields echoes those two. So the echo proves the unit *accepted* the
183
+ fields and says nothing about what it now holds — it must not be treated as
184
+ verification.
185
+ - **`Static Addresses` is `<ip>/<dotted-netmask>` as one field**
186
+ (`192.168.1.10/255.255.252.0`), not a prefix length and not two fields.
187
+ Sending the address alone tells the unit to drop the mask, so `set_network`
188
+ refuses an address without its mask before anything reaches the wire.
189
+
190
+ Changing an address makes the interface reapply, and for about a second the
191
+ unit answers normally while reporting `Current Addresses: 0.0.0.0/255.255.0.0`
192
+ with `Static Addresses` already holding the new value. Verifying against the
193
+ current fields in that window reads garbage; verifying against the static
194
+ fields declares success before the interface is running the value. So
195
+ `set_network` polls until the unit is live on what it was configured with —
196
+ current present, not `0.0.0.0`, and equal to static — and raises rather than
197
+ reporting a success it cannot stand behind. A set that touches no address
198
+ reapplies nothing and is not made to wait.
199
+
200
+ `exchange`, `build_block` and `parse_interface` are public, so any other
201
+ section the unit exposes can be driven the same way.
202
+
203
+ There is deliberately no policy in the library: `set_network` writes what it
204
+ is told. Whether a change is safe belongs to the caller — re-addressing a
205
+ unit over the network can put it out of reach until someone visits it.
206
+
147
207
  ### The 9996 binary channel
148
208
 
149
209
  Every request opens its own TCP connection; the unit closes it after
@@ -1,9 +1,11 @@
1
1
  # ultimattewire
2
2
 
3
- Python library that archives and restores the configuration of a Blackmagic
4
- **Ultimatte 12** or **Ultimatte 12 4K** keyer over its native TCP protocol,
5
- producing and consuming the same zip archives the vendor's Smart Remote 4
6
- writes with **Archive All** and reads with **Restore**.
3
+ Python library for the Blackmagic **Ultimatte 12** and **Ultimatte 12 4K**
4
+ keyers over their native TCP protocol. It archives and restores unit
5
+ configuration — producing and consuming the same zip archives the vendor's
6
+ Smart Remote 4 writes with **Archive All** and reads with **Restore** — and
7
+ reads and sets the unit's **network interface** (address, netmask, gateway,
8
+ DNS, static or DHCP), which is what the vendor's Ultimatte Setup does.
7
9
 
8
10
  [![CI](https://github.com/lucas-romanenko/bmdwire/actions/workflows/ci.yml/badge.svg)](https://github.com/lucas-romanenko/bmdwire/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/ultimattewire.svg)](https://pypi.org/project/ultimattewire/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg) [![Latest tag](https://img.shields.io/github/v/tag/lucas-romanenko/bmdwire?filter=ultimattewire-v*&label=release)](https://github.com/lucas-romanenko/bmdwire/tags)
9
11
 
@@ -16,7 +18,7 @@ established and what is still a guess.
16
18
 
17
19
  ## Status
18
20
 
19
- - **Pre-release** (`0.1.0.dev1`), extracted from a broadcast control
21
+ - **Pre-release** (`0.2.0.dev0`), extracted from a broadcast control
20
22
  application where operators archive and restore keyer configurations from a
21
23
  web page.
22
24
  - Verified against an Ultimatte 12 4K (the unit the captures came from). The
@@ -41,6 +43,7 @@ Python 3.10 or newer. No other dependencies.
41
43
 
42
44
  ## Features
43
45
 
46
+ - `read_network(host)` / `set_network(host, address=…, netmask=…, gateway=…, dns=…, dynamic=…)`: the unit's network interface — what Ultimatte Setup configures. `set_network` verifies by reading back the *settled* interface, not by trusting the unit's acknowledgement.
44
47
  - `archive_unit_to_bytes(host)`: pull every saved preset slot plus the `GPISettings` and `SavedSettings` resources into an in-memory zip in Smart Remote's exact layout, with a human-readable annotated state dump riding along.
45
48
  - `restore_unit_from_bytes(host, zip_bytes)`: push such a zip back, in the order the vendor app uses, aborting at the first rejected write.
46
49
  - Reads the unit's text prelude on TCP 9998 (label, firmware release, live control values, the preset FILE LIST) and does binary slot/resource reads and writes on TCP 9996.
@@ -72,6 +75,19 @@ A zip produced by this library can be opened with Smart Remote 4's Restore
72
75
  unchanged, and a Smart Remote "Archive All" zip can be passed straight to
73
76
  `restore_unit_from_bytes`.
74
77
 
78
+ ```python
79
+ from ultimattewire import read_network, set_network
80
+
81
+ iface = read_network("192.0.2.21")
82
+ print(iface.static_address, iface.static_netmask, iface.static_gateway, iface.mac)
83
+
84
+ # Widen the mask, keeping the address and gateway. Returns the settled
85
+ # interface as the unit reports it — not what was asked for.
86
+ iface = set_network("192.0.2.21", address=iface.static_address,
87
+ netmask="255.255.248.0", gateway=iface.static_gateway)
88
+ assert iface.netmask == "255.255.248.0"
89
+ ```
90
+
75
91
  ## Protocol notes
76
92
 
77
93
  This is the reverse-engineered part and the most useful thing to read before
@@ -120,6 +136,50 @@ already hit and fixed are recorded in `_preamble.py` (a `$` anchor that
120
136
  truncated multi-line FILE LISTs, and a `\s*` that let an empty section
121
137
  swallow the next one).
122
138
 
139
+ ### Setting values: the 9998 block protocol
140
+
141
+ The 9998 channel is not only a prelude. After it, the unit accepts **blocks**:
142
+ an upper-case section header ending in a colon, then `key: value` lines, then
143
+ a **blank line — and the blank line is what submits the block.** Nothing
144
+ happens until it arrives, which is why single-line probes (`IDENTITY?`,
145
+ `help`, `ping`, with either line ending) draw no reply at all and look like
146
+ the unit ignoring the client. That is the one fact the whole feature rests on.
147
+
148
+ The unit answers `ACK\n\n` followed by the section echoed back, or a bare
149
+ `NAK\n\n` for an unknown section or a value it will not take.
150
+
151
+ Three properties worth knowing:
152
+
153
+ - **An empty block is a query.** Header, blank line, no fields: sets nothing
154
+ and echoes the full section. Read and write are therefore one code path,
155
+ and reading network state needs no prelude parsing.
156
+ - **The echo after a set carries only the fields that were sent**, not the
157
+ section. A query echoes all eleven lines of `NETWORK INTERFACE 0`; a set of
158
+ two fields echoes those two. So the echo proves the unit *accepted* the
159
+ fields and says nothing about what it now holds — it must not be treated as
160
+ verification.
161
+ - **`Static Addresses` is `<ip>/<dotted-netmask>` as one field**
162
+ (`192.168.1.10/255.255.252.0`), not a prefix length and not two fields.
163
+ Sending the address alone tells the unit to drop the mask, so `set_network`
164
+ refuses an address without its mask before anything reaches the wire.
165
+
166
+ Changing an address makes the interface reapply, and for about a second the
167
+ unit answers normally while reporting `Current Addresses: 0.0.0.0/255.255.0.0`
168
+ with `Static Addresses` already holding the new value. Verifying against the
169
+ current fields in that window reads garbage; verifying against the static
170
+ fields declares success before the interface is running the value. So
171
+ `set_network` polls until the unit is live on what it was configured with —
172
+ current present, not `0.0.0.0`, and equal to static — and raises rather than
173
+ reporting a success it cannot stand behind. A set that touches no address
174
+ reapplies nothing and is not made to wait.
175
+
176
+ `exchange`, `build_block` and `parse_interface` are public, so any other
177
+ section the unit exposes can be driven the same way.
178
+
179
+ There is deliberately no policy in the library: `set_network` writes what it
180
+ is told. Whether a change is safe belongs to the caller — re-addressing a
181
+ unit over the network can put it out of reach until someone visits it.
182
+
123
183
  ### The 9996 binary channel
124
184
 
125
185
  Every request opens its own TCP connection; the unit closes it after
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ultimattewire"
7
- version = "0.1.0.dev1"
7
+ version = "0.2.0.dev0"
8
8
  description = "Blackmagic Ultimatte 12 archive and restore over its native TCP protocol (Smart Remote 4 compatible zips)"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -9,9 +9,15 @@ Remote-compatible Archive/Restore feature (the first public
9
9
  implementation of it outside BMD's own tools).
10
10
  """
11
11
 
12
+ from ultimattewire.network import (NetworkInterface, UltimatteNetworkError,
13
+ read_network, set_network)
12
14
  from ultimattewire.profile import archive_unit_to_bytes, restore_unit_from_bytes
13
15
 
14
16
  __all__ = [
15
17
  "archive_unit_to_bytes",
16
18
  "restore_unit_from_bytes",
19
+ "read_network",
20
+ "set_network",
21
+ "NetworkInterface",
22
+ "UltimatteNetworkError",
17
23
  ]
@@ -0,0 +1,283 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ Ultimatte 9998 text channel: read and set the unit's network interface.
4
+
5
+ This is the Ultimatte half of what ATEM Setup does over its REST API —
6
+ address, netmask, gateway, DNS and static-vs-DHCP — for units that are
7
+ reachable on the network but not in front of you. Reverse-engineered from
8
+ an Ultimatte 12 4K (protocol version 2.1, software 2.1) in 2026-09.
9
+
10
+ **The block protocol.** After the prelude, the 9998 channel takes blocks:
11
+ a CAPS section header ending in a colon, then ``key: value`` lines, then a
12
+ BLANK LINE which is what actually submits the block. Nothing happens until
13
+ that blank line arrives — a lone command line just sits in the unit's
14
+ buffer, which is why single-line probes look like the unit is ignoring you.
15
+
16
+ The unit answers::
17
+
18
+ ACK\\n\\n<the section, echoed back with its CURRENT values>
19
+ NAK\\n\\n (unknown section, or a value it won't take)
20
+
21
+ so a write is self-verifying: the echo is the unit telling you what it now
22
+ holds, and callers should believe the echo rather than the ACK.
23
+
24
+ An EMPTY block — header, blank line, no fields — sets nothing and returns
25
+ the same echo, so the read path and the write path are one code path.
26
+
27
+ **Addresses are one combined field.** ``Static Addresses`` carries
28
+ ``<ip>/<dotted-netmask>`` (``192.168.1.10/255.255.252.0``) — not a prefix
29
+ length, and not two fields. Sending the address without the mask, or with
30
+ a ``/21``-style suffix, is rejected.
31
+
32
+ **No policy here.** ``set_network`` writes what it is told; deciding whether
33
+ a change is safe belongs to the caller. Re-addressing a unit over the
34
+ network can put it out of reach until someone visits it with a monitor.
35
+ """
36
+
37
+ import re
38
+ import socket
39
+ import time
40
+ from dataclasses import dataclass, field
41
+ from typing import List, Optional
42
+
43
+ from ultimattewire._protocol import (CONNECT_TIMEOUT, CONTROL_PORT,
44
+ _connect_with_retry)
45
+
46
+ ACK = "ACK"
47
+ NAK = "NAK"
48
+ PRELUDE_END = b"END PRELUDE:"
49
+ REPLY_QUIET_TIMEOUT = 2.0
50
+ PRELUDE_QUIET_TIMEOUT = 2.0
51
+ READBACK_TIMEOUT = 15.0 # an interface that reapplies is briefly unreachable
52
+ READBACK_POLL = 0.5
53
+ UNCONFIGURED = "0.0.0.0" # what Current Addresses reads mid-reapply
54
+
55
+
56
+ class UltimatteNetworkError(Exception):
57
+ """The unit refused a block (NAK), or never answered one."""
58
+
59
+
60
+ @dataclass
61
+ class NetworkInterface:
62
+ """One ``NETWORK INTERFACE n`` section, as the unit reports it.
63
+
64
+ ``address`` / ``netmask`` are the CURRENT ones (what the unit is using);
65
+ ``static_address`` / ``static_netmask`` are the CONFIGURED ones. They
66
+ differ while a unit is on DHCP, or between setting a static address and
67
+ the interface reapplying.
68
+ """
69
+ index: int = 0
70
+ name: str = ""
71
+ mac: str = ""
72
+ priority: Optional[int] = None
73
+ dynamic: Optional[bool] = None
74
+ address: str = ""
75
+ netmask: str = ""
76
+ gateway: str = ""
77
+ dns: List[str] = field(default_factory=list)
78
+ static_address: str = ""
79
+ static_netmask: str = ""
80
+ static_gateway: str = ""
81
+ static_dns: List[str] = field(default_factory=list)
82
+ raw: str = ""
83
+
84
+
85
+ def section_name(index=0):
86
+ return f"NETWORK INTERFACE {int(index)}"
87
+
88
+
89
+ def _split_address(value):
90
+ """``'192.168.1.10/255.255.252.0'`` -> ``('192.168.1.10', '255.255.252.0')``.
91
+
92
+ The unit may list more than one address; the first is the one that
93
+ matters to callers. An address with no mask yields an empty mask rather
94
+ than raising — reading must never fail on an odd unit.
95
+ """
96
+ first = (value or "").replace(",", " ").split()
97
+ if not first:
98
+ return "", ""
99
+ head = first[0]
100
+ if "/" in head:
101
+ ip, _, mask = head.partition("/")
102
+ return ip.strip(), mask.strip()
103
+ return head.strip(), ""
104
+
105
+
106
+ def _split_list(value):
107
+ return [p for p in (value or "").replace(",", " ").split() if p]
108
+
109
+
110
+ def parse_interface(text, index=0):
111
+ """Parse an echoed ``NETWORK INTERFACE n`` section into a dataclass."""
112
+ def get(key):
113
+ m = re.search(rf"^{re.escape(key)}:[ \t]*(.*)$", text, re.MULTILINE)
114
+ return m.group(1).strip() if m else ""
115
+
116
+ iface = NetworkInterface(index=index, raw=text)
117
+ iface.name = get("Name")
118
+ iface.mac = get("MAC Address")
119
+ priority = get("Priority")
120
+ iface.priority = int(priority) if priority.isdigit() else None
121
+ dynamic = get("Dynamic IP").lower()
122
+ iface.dynamic = True if dynamic == "true" else False if dynamic == "false" else None
123
+ iface.address, iface.netmask = _split_address(get("Current Addresses"))
124
+ iface.gateway = get("Current Gateway")
125
+ iface.dns = _split_list(get("Current DNS Servers"))
126
+ iface.static_address, iface.static_netmask = _split_address(get("Static Addresses"))
127
+ iface.static_gateway = get("Static Gateway")
128
+ iface.static_dns = _split_list(get("Static DNS Servers"))
129
+ return iface
130
+
131
+
132
+ def build_block(index=0, **fields):
133
+ """Render a submittable block. Field ORDER is preserved; an empty
134
+ ``fields`` gives the query form (header + the submitting blank line)."""
135
+ lines = [f"{section_name(index)}:"]
136
+ lines += [f"{key}: {value}" for key, value in fields.items()]
137
+ return "\n".join(lines) + "\n\n"
138
+
139
+
140
+ def _drain_prelude(sock, quiet_timeout):
141
+ sock.settimeout(quiet_timeout)
142
+ buf = bytearray()
143
+ while True:
144
+ try:
145
+ chunk = sock.recv(65536)
146
+ except socket.timeout:
147
+ break
148
+ if not chunk:
149
+ break
150
+ buf.extend(chunk)
151
+ if PRELUDE_END in buf:
152
+ break
153
+ return bytes(buf)
154
+
155
+
156
+ def _read_reply(sock, quiet_timeout):
157
+ sock.settimeout(quiet_timeout)
158
+ buf = bytearray()
159
+ while True:
160
+ try:
161
+ chunk = sock.recv(65536)
162
+ except socket.timeout:
163
+ break
164
+ if not chunk:
165
+ break
166
+ buf.extend(chunk)
167
+ text = buf.decode("utf-8", errors="replace")
168
+ # NAK is the whole reply; ACK is followed by the echoed section,
169
+ # which ends with its own blank line.
170
+ if text.startswith(NAK):
171
+ break
172
+ if text.startswith(ACK) and text.rstrip().endswith(("\n", ":")) and text.count("\n\n") >= 2:
173
+ break
174
+ return buf.decode("utf-8", errors="replace")
175
+
176
+
177
+ def exchange(host, block, connect_timeout=CONNECT_TIMEOUT,
178
+ quiet_timeout=REPLY_QUIET_TIMEOUT,
179
+ prelude_timeout=PRELUDE_QUIET_TIMEOUT):
180
+ """Send one block on a fresh 9998 session; return the echoed body.
181
+
182
+ Raises ``UltimatteNetworkError`` on NAK or on no reply at all.
183
+ """
184
+ sock = _connect_with_retry(host, CONTROL_PORT, timeout=connect_timeout)
185
+ try:
186
+ _drain_prelude(sock, prelude_timeout)
187
+ sock.sendall(block.encode("utf-8"))
188
+ reply = _read_reply(sock, quiet_timeout)
189
+ finally:
190
+ try:
191
+ sock.close()
192
+ except Exception: # noqa: BLE001
193
+ pass
194
+ stripped = reply.lstrip()
195
+ if stripped.startswith(NAK):
196
+ raise UltimatteNetworkError(
197
+ f"unit refused the block (NAK):\n{block.strip()}")
198
+ if not stripped.startswith(ACK):
199
+ raise UltimatteNetworkError(
200
+ f"no ACK from {host} (got {reply[:80]!r})")
201
+ return stripped[len(ACK):].lstrip("\n")
202
+
203
+
204
+ def read_network(host, index=0, **kw):
205
+ """Read ``NETWORK INTERFACE <index>`` from the unit at ``host``.
206
+
207
+ Uses the query form of the block protocol — one short session, no
208
+ prelude parsing, nothing written.
209
+ """
210
+ return parse_interface(exchange(host, build_block(index), **kw), index)
211
+
212
+
213
+ def set_network(host, index=0, *, address=None, netmask=None, gateway=None,
214
+ dns=None, dynamic=None, readback_timeout=READBACK_TIMEOUT, **kw):
215
+ """Set static network parameters and return the unit's state READ BACK.
216
+
217
+ ``address`` and ``netmask`` travel together in one ``Static Addresses``
218
+ field, so supplying one without the other is an error — the unit would
219
+ be told to drop the half you left out. Pass ``dns=[]`` to clear the DNS
220
+ list; ``dns=None`` leaves it alone.
221
+
222
+ The returned interface comes from a fresh query, not from the set's own
223
+ echo (which carries only the fields that were sent — see the module
224
+ docstring). Changing an address can make the unit briefly unreachable
225
+ while the interface reapplies, so the read-back is retried until
226
+ ``readback_timeout``; if it never answers, this raises rather than
227
+ reporting a success it cannot stand behind.
228
+
229
+ Writes what it is told: the caller owns the question of whether a change
230
+ is safe.
231
+ """
232
+ if (address is None) != (netmask is None):
233
+ raise ValueError(
234
+ "address and netmask are one field on the wire — pass both or neither")
235
+ fields = {}
236
+ if dynamic is not None:
237
+ fields["Dynamic IP"] = "true" if dynamic else "false"
238
+ if address is not None:
239
+ fields["Static Addresses"] = f"{address}/{netmask}"
240
+ if gateway is not None:
241
+ fields["Static Gateway"] = gateway
242
+ if dns is not None:
243
+ fields["Static DNS Servers"] = " ".join(dns)
244
+ if not fields:
245
+ raise ValueError("nothing to set")
246
+
247
+ exchange(host, build_block(index, **fields), **kw) # raises on NAK
248
+
249
+ deadline = time.time() + readback_timeout
250
+ last = None
251
+ while True:
252
+ try:
253
+ iface = read_network(host, index, **kw)
254
+ if _settled(iface, address is not None):
255
+ return iface
256
+ last = f"interface still reapplying (Current {iface.address}/{iface.netmask})"
257
+ except (UltimatteNetworkError, OSError) as exc:
258
+ last = exc
259
+ if time.time() >= deadline:
260
+ break
261
+ time.sleep(READBACK_POLL)
262
+ raise UltimatteNetworkError(
263
+ f"{host}: the unit accepted {sorted(fields)} but did not settle "
264
+ f"within {readback_timeout:g}s — CHECK THIS UNIT ({last})")
265
+
266
+
267
+ def _settled(iface, address_was_set):
268
+ """Has the interface finished reapplying?
269
+
270
+ Measured on hardware: for about a second after an address change the unit
271
+ answers happily but reports ``Current Addresses: 0.0.0.0/255.255.0.0``
272
+ while ``Static Addresses`` already holds the new value. A caller that
273
+ verified against the CURRENT fields in that window would read garbage —
274
+ and one that verified against the STATIC fields would declare success
275
+ before the interface had actually taken them. So "settled" means the unit
276
+ is live on what it was configured with.
277
+ """
278
+ if not address_was_set:
279
+ return True
280
+ if not iface.address or iface.address == UNCONFIGURED:
281
+ return False
282
+ return (iface.address == iface.static_address
283
+ and iface.netmask == iface.static_netmask)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ultimattewire
3
- Version: 0.1.0.dev1
3
+ Version: 0.2.0.dev0
4
4
  Summary: Blackmagic Ultimatte 12 archive and restore over its native TCP protocol (Smart Remote 4 compatible zips)
5
5
  Author: Lucas Romanenko
6
6
  License: MIT
@@ -24,10 +24,12 @@ Dynamic: license-file
24
24
 
25
25
  # ultimattewire
26
26
 
27
- Python library that archives and restores the configuration of a Blackmagic
28
- **Ultimatte 12** or **Ultimatte 12 4K** keyer over its native TCP protocol,
29
- producing and consuming the same zip archives the vendor's Smart Remote 4
30
- writes with **Archive All** and reads with **Restore**.
27
+ Python library for the Blackmagic **Ultimatte 12** and **Ultimatte 12 4K**
28
+ keyers over their native TCP protocol. It archives and restores unit
29
+ configuration — producing and consuming the same zip archives the vendor's
30
+ Smart Remote 4 writes with **Archive All** and reads with **Restore** — and
31
+ reads and sets the unit's **network interface** (address, netmask, gateway,
32
+ DNS, static or DHCP), which is what the vendor's Ultimatte Setup does.
31
33
 
32
34
  [![CI](https://github.com/lucas-romanenko/bmdwire/actions/workflows/ci.yml/badge.svg)](https://github.com/lucas-romanenko/bmdwire/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/ultimattewire.svg)](https://pypi.org/project/ultimattewire/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg) [![Latest tag](https://img.shields.io/github/v/tag/lucas-romanenko/bmdwire?filter=ultimattewire-v*&label=release)](https://github.com/lucas-romanenko/bmdwire/tags)
33
35
 
@@ -40,7 +42,7 @@ established and what is still a guess.
40
42
 
41
43
  ## Status
42
44
 
43
- - **Pre-release** (`0.1.0.dev1`), extracted from a broadcast control
45
+ - **Pre-release** (`0.2.0.dev0`), extracted from a broadcast control
44
46
  application where operators archive and restore keyer configurations from a
45
47
  web page.
46
48
  - Verified against an Ultimatte 12 4K (the unit the captures came from). The
@@ -65,6 +67,7 @@ Python 3.10 or newer. No other dependencies.
65
67
 
66
68
  ## Features
67
69
 
70
+ - `read_network(host)` / `set_network(host, address=…, netmask=…, gateway=…, dns=…, dynamic=…)`: the unit's network interface — what Ultimatte Setup configures. `set_network` verifies by reading back the *settled* interface, not by trusting the unit's acknowledgement.
68
71
  - `archive_unit_to_bytes(host)`: pull every saved preset slot plus the `GPISettings` and `SavedSettings` resources into an in-memory zip in Smart Remote's exact layout, with a human-readable annotated state dump riding along.
69
72
  - `restore_unit_from_bytes(host, zip_bytes)`: push such a zip back, in the order the vendor app uses, aborting at the first rejected write.
70
73
  - Reads the unit's text prelude on TCP 9998 (label, firmware release, live control values, the preset FILE LIST) and does binary slot/resource reads and writes on TCP 9996.
@@ -96,6 +99,19 @@ A zip produced by this library can be opened with Smart Remote 4's Restore
96
99
  unchanged, and a Smart Remote "Archive All" zip can be passed straight to
97
100
  `restore_unit_from_bytes`.
98
101
 
102
+ ```python
103
+ from ultimattewire import read_network, set_network
104
+
105
+ iface = read_network("192.0.2.21")
106
+ print(iface.static_address, iface.static_netmask, iface.static_gateway, iface.mac)
107
+
108
+ # Widen the mask, keeping the address and gateway. Returns the settled
109
+ # interface as the unit reports it — not what was asked for.
110
+ iface = set_network("192.0.2.21", address=iface.static_address,
111
+ netmask="255.255.248.0", gateway=iface.static_gateway)
112
+ assert iface.netmask == "255.255.248.0"
113
+ ```
114
+
99
115
  ## Protocol notes
100
116
 
101
117
  This is the reverse-engineered part and the most useful thing to read before
@@ -144,6 +160,50 @@ already hit and fixed are recorded in `_preamble.py` (a `$` anchor that
144
160
  truncated multi-line FILE LISTs, and a `\s*` that let an empty section
145
161
  swallow the next one).
146
162
 
163
+ ### Setting values: the 9998 block protocol
164
+
165
+ The 9998 channel is not only a prelude. After it, the unit accepts **blocks**:
166
+ an upper-case section header ending in a colon, then `key: value` lines, then
167
+ a **blank line — and the blank line is what submits the block.** Nothing
168
+ happens until it arrives, which is why single-line probes (`IDENTITY?`,
169
+ `help`, `ping`, with either line ending) draw no reply at all and look like
170
+ the unit ignoring the client. That is the one fact the whole feature rests on.
171
+
172
+ The unit answers `ACK\n\n` followed by the section echoed back, or a bare
173
+ `NAK\n\n` for an unknown section or a value it will not take.
174
+
175
+ Three properties worth knowing:
176
+
177
+ - **An empty block is a query.** Header, blank line, no fields: sets nothing
178
+ and echoes the full section. Read and write are therefore one code path,
179
+ and reading network state needs no prelude parsing.
180
+ - **The echo after a set carries only the fields that were sent**, not the
181
+ section. A query echoes all eleven lines of `NETWORK INTERFACE 0`; a set of
182
+ two fields echoes those two. So the echo proves the unit *accepted* the
183
+ fields and says nothing about what it now holds — it must not be treated as
184
+ verification.
185
+ - **`Static Addresses` is `<ip>/<dotted-netmask>` as one field**
186
+ (`192.168.1.10/255.255.252.0`), not a prefix length and not two fields.
187
+ Sending the address alone tells the unit to drop the mask, so `set_network`
188
+ refuses an address without its mask before anything reaches the wire.
189
+
190
+ Changing an address makes the interface reapply, and for about a second the
191
+ unit answers normally while reporting `Current Addresses: 0.0.0.0/255.255.0.0`
192
+ with `Static Addresses` already holding the new value. Verifying against the
193
+ current fields in that window reads garbage; verifying against the static
194
+ fields declares success before the interface is running the value. So
195
+ `set_network` polls until the unit is live on what it was configured with —
196
+ current present, not `0.0.0.0`, and equal to static — and raises rather than
197
+ reporting a success it cannot stand behind. A set that touches no address
198
+ reapplies nothing and is not made to wait.
199
+
200
+ `exchange`, `build_block` and `parse_interface` are public, so any other
201
+ section the unit exposes can be driven the same way.
202
+
203
+ There is deliberately no policy in the library: `set_network` writes what it
204
+ is told. Whether a change is safe belongs to the caller — re-addressing a
205
+ unit over the network can put it out of reach until someone visits it.
206
+
147
207
  ### The 9996 binary channel
148
208
 
149
209
  Every request opens its own TCP connection; the unit closes it after
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  ultimattewire/__init__.py
5
5
  ultimattewire/_preamble.py
6
6
  ultimattewire/_protocol.py
7
+ ultimattewire/network.py
7
8
  ultimattewire.egg-info/PKG-INFO
8
9
  ultimattewire.egg-info/SOURCES.txt
9
10
  ultimattewire.egg-info/dependency_links.txt