ultimattewire 0.1.0.dev0__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.dev0 → ultimattewire-0.2.0.dev0}/PKG-INFO +126 -24
  2. ultimattewire-0.1.0.dev0/ultimattewire.egg-info/PKG-INFO → ultimattewire-0.2.0.dev0/README.md +117 -39
  3. {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/pyproject.toml +11 -1
  4. {ultimattewire-0.1.0.dev0 → 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.dev0/README.md → ultimattewire-0.2.0.dev0/ultimattewire.egg-info/PKG-INFO +141 -23
  7. {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire.egg-info/SOURCES.txt +1 -0
  8. {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/LICENSE +0 -0
  9. {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/setup.cfg +0 -0
  10. {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire/_preamble.py +0 -0
  11. {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire/_protocol.py +0 -0
  12. {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire/profile/__init__.py +0 -0
  13. {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire/profile/_ranges.py +0 -0
  14. {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire/profile/archive.py +0 -0
  15. {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire/profile/restore.py +0 -0
  16. {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire.egg-info/dependency_links.txt +0 -0
  17. {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire.egg-info/requires.txt +0 -0
  18. {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire.egg-info/top_level.txt +0 -0
@@ -1,11 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ultimattewire
3
- Version: 0.1.0.dev0
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
7
+ Project-URL: Repository, https://github.com/lucas-romanenko/bmdwire/tree/main/ultimattewire
8
+ Project-URL: Issues, https://github.com/lucas-romanenko/bmdwire/issues
7
9
  Classifier: License :: OSI Approved :: MIT License
8
10
  Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Development Status :: 4 - Beta
9
17
  Classifier: Operating System :: OS Independent
10
18
  Requires-Python: >=3.10
11
19
  Description-Content-Type: text/markdown
@@ -16,41 +24,59 @@ Dynamic: license-file
16
24
 
17
25
  # ultimattewire
18
26
 
19
- ultimattewire is a small, dependency-free Python library that archives and
20
- restores the configuration of a Blackmagic **Ultimatte 12** or **Ultimatte
21
- 12 4K** keyer over its native TCP protocol. It produces and consumes the same
22
- zip archives that Blackmagic's Ultimatte Smart Remote 4 (also shipped as
23
- "Ultimatte Software Control") writes with **Archive All** and reads with
24
- **Restore**, so an archive taken with this library restores from the vendor
25
- app and vice versa. Blackmagic does not document the protocol; everything here
26
- was reverse-engineered from packet captures of the vendor app talking to real
27
- hardware, which is why the wire details in the code are marked as not to be
28
- changed without re-validating against a unit.
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.
29
33
 
30
- ## Features
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)
31
35
 
32
- - `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.
33
- - `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.
34
- - 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.
35
- - Failed reads are omitted from the archive and reported as warnings instead of being written as placeholder bytes that a later restore would push into live hardware.
36
- - Short reads raise; truncated blobs are never archived.
37
- - One-retry connects to survive cold-ARP and first-packet hiccups.
38
- - Per-parameter display ranges for about 150 control values, used to annotate the state dump (raw `0..10000` to percent, frames, pixels).
39
- - Pure standard library. No logging; results and warnings are returned to the caller.
36
+ Blackmagic does not document the protocol. Everything here was
37
+ reverse-engineered from packet captures of the vendor app talking to real
38
+ hardware, then validated by round-tripping archives through the vendor app in
39
+ both directions. The wire details in the code are marked as not to be changed
40
+ without re-validating against a unit; the Protocol notes below record what is
41
+ established and what is still a guess.
42
+
43
+ ## Status
44
+
45
+ - **Pre-release** (`0.2.0.dev0`), extracted from a broadcast control
46
+ application where operators archive and restore keyer configurations from a
47
+ web page.
48
+ - Verified against an Ultimatte 12 4K (the unit the captures came from). The
49
+ Ultimatte 12 (HD) speaks the same protocol by the vendor's own docstrings;
50
+ the HD Mini and other models were not tested.
40
51
 
41
52
  ## Install
42
53
 
54
+ From PyPI:
55
+
43
56
  ```sh
44
- pip install "git+https://github.com/lucas-romanenko/ultimattewire.git@v0.1.0.dev0"
57
+ pip install ultimattewire
45
58
  ```
46
59
 
47
- Python 3.10 or newer. To run the tests from a checkout:
60
+ Only pre-release versions exist so far (0.1.0.dev1). pip installs a pre-release when it is the only release there is, so no `--pre` is needed; pin the version in a requirements file (`ultimattewire==0.1.0.dev1`) so a later release cannot change your install under you. To install straight from a GitHub tag instead (git needed on the machine):
48
61
 
49
62
  ```sh
50
- pip install ".[test]"
51
- python -m pytest
63
+ pip install "ultimattewire @ git+https://github.com/lucas-romanenko/bmdwire.git@ultimattewire-v0.1.0.dev1#subdirectory=ultimattewire"
52
64
  ```
53
65
 
66
+ Python 3.10 or newer. No other dependencies.
67
+
68
+ ## Features
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.
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.
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.
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.
74
+ - Failed reads are omitted from the archive and reported as warnings instead of being written as placeholder bytes that a later restore would push into live hardware.
75
+ - Short reads raise; truncated blobs are never archived.
76
+ - One-retry connects to survive cold-ARP and first-packet hiccups.
77
+ - Per-parameter display ranges for about 150 control values, used to annotate the state dump (raw `0..10000` to percent, frames, pixels).
78
+ - Pure standard library. No logging; results and warnings are returned to the caller.
79
+
54
80
  ## Usage
55
81
 
56
82
  ```python
@@ -73,6 +99,19 @@ A zip produced by this library can be opened with Smart Remote 4's Restore
73
99
  unchanged, and a Smart Remote "Archive All" zip can be passed straight to
74
100
  `restore_unit_from_bytes`.
75
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
+
76
115
  ## Protocol notes
77
116
 
78
117
  This is the reverse-engineered part and the most useful thing to read before
@@ -121,6 +160,50 @@ already hit and fixed are recorded in `_preamble.py` (a `$` anchor that
121
160
  truncated multi-line FILE LISTs, and a `\s*` that let an empty section
122
161
  swallow the next one).
123
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
+
124
207
  ### The 9996 binary channel
125
208
 
126
209
  Every request opens its own TCP connection; the unit closes it after
@@ -232,6 +315,25 @@ the second.
232
315
  - The display-range table is an interpretation of the manual and the panel
233
316
  UI, not wire data. It affects only the readable text dump.
234
317
 
318
+ ## Development
319
+
320
+ ```sh
321
+ git clone https://github.com/lucas-romanenko/bmdwire.git
322
+ cd bmdwire/ultimattewire
323
+ pip install -e ".[test]"
324
+ python -m pytest
325
+ ```
326
+
327
+ The suite needs no hardware. CI runs it on Python 3.10, 3.12 and 3.14 for every push and pull request.
328
+
329
+ ## Related libraries
330
+
331
+ One library per Blackmagic device family, same shape, same author, all pure standard library except atemwire's small C extension:
332
+
333
+ - [atemwire](../atemwire/): ATEM switchers (UDP protocol, macros, profiles)
334
+ - [hyperdeckwire](../hyperdeckwire/): HyperDeck recorders (transport control, clip upload)
335
+ - [videohubwire](../videohubwire/): Videohub routers (routing, labels)
336
+
235
337
  ## License
236
338
 
237
339
  MIT. See `LICENSE`.
@@ -1,56 +1,58 @@
1
- Metadata-Version: 2.4
2
- Name: ultimattewire
3
- Version: 0.1.0.dev0
4
- Summary: Blackmagic Ultimatte 12 archive and restore over its native TCP protocol (Smart Remote 4 compatible zips)
5
- Author: Lucas Romanenko
6
- License: MIT
7
- Classifier: License :: OSI Approved :: MIT License
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: Operating System :: OS Independent
10
- Requires-Python: >=3.10
11
- Description-Content-Type: text/markdown
12
- License-File: LICENSE
13
- Provides-Extra: test
14
- Requires-Dist: pytest; extra == "test"
15
- Dynamic: license-file
16
-
17
1
  # ultimattewire
18
2
 
19
- ultimattewire is a small, dependency-free Python library that archives and
20
- restores the configuration of a Blackmagic **Ultimatte 12** or **Ultimatte
21
- 12 4K** keyer over its native TCP protocol. It produces and consumes the same
22
- zip archives that Blackmagic's Ultimatte Smart Remote 4 (also shipped as
23
- "Ultimatte Software Control") writes with **Archive All** and reads with
24
- **Restore**, so an archive taken with this library restores from the vendor
25
- app and vice versa. Blackmagic does not document the protocol; everything here
26
- was reverse-engineered from packet captures of the vendor app talking to real
27
- hardware, which is why the wire details in the code are marked as not to be
28
- changed without re-validating against a unit.
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.
29
9
 
30
- ## Features
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)
31
11
 
32
- - `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.
33
- - `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.
34
- - 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.
35
- - Failed reads are omitted from the archive and reported as warnings instead of being written as placeholder bytes that a later restore would push into live hardware.
36
- - Short reads raise; truncated blobs are never archived.
37
- - One-retry connects to survive cold-ARP and first-packet hiccups.
38
- - Per-parameter display ranges for about 150 control values, used to annotate the state dump (raw `0..10000` to percent, frames, pixels).
39
- - Pure standard library. No logging; results and warnings are returned to the caller.
12
+ Blackmagic does not document the protocol. Everything here was
13
+ reverse-engineered from packet captures of the vendor app talking to real
14
+ hardware, then validated by round-tripping archives through the vendor app in
15
+ both directions. The wire details in the code are marked as not to be changed
16
+ without re-validating against a unit; the Protocol notes below record what is
17
+ established and what is still a guess.
18
+
19
+ ## Status
20
+
21
+ - **Pre-release** (`0.2.0.dev0`), extracted from a broadcast control
22
+ application where operators archive and restore keyer configurations from a
23
+ web page.
24
+ - Verified against an Ultimatte 12 4K (the unit the captures came from). The
25
+ Ultimatte 12 (HD) speaks the same protocol by the vendor's own docstrings;
26
+ the HD Mini and other models were not tested.
40
27
 
41
28
  ## Install
42
29
 
30
+ From PyPI:
31
+
43
32
  ```sh
44
- pip install "git+https://github.com/lucas-romanenko/ultimattewire.git@v0.1.0.dev0"
33
+ pip install ultimattewire
45
34
  ```
46
35
 
47
- Python 3.10 or newer. To run the tests from a checkout:
36
+ Only pre-release versions exist so far (0.1.0.dev1). pip installs a pre-release when it is the only release there is, so no `--pre` is needed; pin the version in a requirements file (`ultimattewire==0.1.0.dev1`) so a later release cannot change your install under you. To install straight from a GitHub tag instead (git needed on the machine):
48
37
 
49
38
  ```sh
50
- pip install ".[test]"
51
- python -m pytest
39
+ pip install "ultimattewire @ git+https://github.com/lucas-romanenko/bmdwire.git@ultimattewire-v0.1.0.dev1#subdirectory=ultimattewire"
52
40
  ```
53
41
 
42
+ Python 3.10 or newer. No other dependencies.
43
+
44
+ ## Features
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.
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.
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.
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.
50
+ - Failed reads are omitted from the archive and reported as warnings instead of being written as placeholder bytes that a later restore would push into live hardware.
51
+ - Short reads raise; truncated blobs are never archived.
52
+ - One-retry connects to survive cold-ARP and first-packet hiccups.
53
+ - Per-parameter display ranges for about 150 control values, used to annotate the state dump (raw `0..10000` to percent, frames, pixels).
54
+ - Pure standard library. No logging; results and warnings are returned to the caller.
55
+
54
56
  ## Usage
55
57
 
56
58
  ```python
@@ -73,6 +75,19 @@ A zip produced by this library can be opened with Smart Remote 4's Restore
73
75
  unchanged, and a Smart Remote "Archive All" zip can be passed straight to
74
76
  `restore_unit_from_bytes`.
75
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
+
76
91
  ## Protocol notes
77
92
 
78
93
  This is the reverse-engineered part and the most useful thing to read before
@@ -121,6 +136,50 @@ already hit and fixed are recorded in `_preamble.py` (a `$` anchor that
121
136
  truncated multi-line FILE LISTs, and a `\s*` that let an empty section
122
137
  swallow the next one).
123
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
+
124
183
  ### The 9996 binary channel
125
184
 
126
185
  Every request opens its own TCP connection; the unit closes it after
@@ -232,6 +291,25 @@ the second.
232
291
  - The display-range table is an interpretation of the manual and the panel
233
292
  UI, not wire data. It affects only the readable text dump.
234
293
 
294
+ ## Development
295
+
296
+ ```sh
297
+ git clone https://github.com/lucas-romanenko/bmdwire.git
298
+ cd bmdwire/ultimattewire
299
+ pip install -e ".[test]"
300
+ python -m pytest
301
+ ```
302
+
303
+ The suite needs no hardware. CI runs it on Python 3.10, 3.12 and 3.14 for every push and pull request.
304
+
305
+ ## Related libraries
306
+
307
+ One library per Blackmagic device family, same shape, same author, all pure standard library except atemwire's small C extension:
308
+
309
+ - [atemwire](../atemwire/): ATEM switchers (UDP protocol, macros, profiles)
310
+ - [hyperdeckwire](../hyperdeckwire/): HyperDeck recorders (transport control, clip upload)
311
+ - [videohubwire](../videohubwire/): Videohub routers (routing, labels)
312
+
235
313
  ## License
236
314
 
237
315
  MIT. See `LICENSE`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ultimattewire"
7
- version = "0.1.0.dev0"
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" }
@@ -14,9 +14,19 @@ dependencies = []
14
14
  classifiers = [
15
15
  "License :: OSI Approved :: MIT License",
16
16
  "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Development Status :: 4 - Beta",
17
23
  "Operating System :: OS Independent",
18
24
  ]
19
25
 
26
+ [project.urls]
27
+ Repository = "https://github.com/lucas-romanenko/bmdwire/tree/main/ultimattewire"
28
+ Issues = "https://github.com/lucas-romanenko/bmdwire/issues"
29
+
20
30
  [project.optional-dependencies]
21
31
  test = ["pytest"]
22
32
 
@@ -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,40 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: ultimattewire
3
+ Version: 0.2.0.dev0
4
+ Summary: Blackmagic Ultimatte 12 archive and restore over its native TCP protocol (Smart Remote 4 compatible zips)
5
+ Author: Lucas Romanenko
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/lucas-romanenko/bmdwire/tree/main/ultimattewire
8
+ Project-URL: Issues, https://github.com/lucas-romanenko/bmdwire/issues
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Development Status :: 4 - Beta
17
+ Classifier: Operating System :: OS Independent
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest; extra == "test"
23
+ Dynamic: license-file
24
+
1
25
  # ultimattewire
2
26
 
3
- ultimattewire is a small, dependency-free Python library that archives and
4
- restores the configuration of a Blackmagic **Ultimatte 12** or **Ultimatte
5
- 12 4K** keyer over its native TCP protocol. It produces and consumes the same
6
- zip archives that Blackmagic's Ultimatte Smart Remote 4 (also shipped as
7
- "Ultimatte Software Control") writes with **Archive All** and reads with
8
- **Restore**, so an archive taken with this library restores from the vendor
9
- app and vice versa. Blackmagic does not document the protocol; everything here
10
- was reverse-engineered from packet captures of the vendor app talking to real
11
- hardware, which is why the wire details in the code are marked as not to be
12
- changed without re-validating against a unit.
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.
13
33
 
14
- ## Features
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)
15
35
 
16
- - `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.
17
- - `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.
18
- - 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.
19
- - Failed reads are omitted from the archive and reported as warnings instead of being written as placeholder bytes that a later restore would push into live hardware.
20
- - Short reads raise; truncated blobs are never archived.
21
- - One-retry connects to survive cold-ARP and first-packet hiccups.
22
- - Per-parameter display ranges for about 150 control values, used to annotate the state dump (raw `0..10000` to percent, frames, pixels).
23
- - Pure standard library. No logging; results and warnings are returned to the caller.
36
+ Blackmagic does not document the protocol. Everything here was
37
+ reverse-engineered from packet captures of the vendor app talking to real
38
+ hardware, then validated by round-tripping archives through the vendor app in
39
+ both directions. The wire details in the code are marked as not to be changed
40
+ without re-validating against a unit; the Protocol notes below record what is
41
+ established and what is still a guess.
42
+
43
+ ## Status
44
+
45
+ - **Pre-release** (`0.2.0.dev0`), extracted from a broadcast control
46
+ application where operators archive and restore keyer configurations from a
47
+ web page.
48
+ - Verified against an Ultimatte 12 4K (the unit the captures came from). The
49
+ Ultimatte 12 (HD) speaks the same protocol by the vendor's own docstrings;
50
+ the HD Mini and other models were not tested.
24
51
 
25
52
  ## Install
26
53
 
54
+ From PyPI:
55
+
27
56
  ```sh
28
- pip install "git+https://github.com/lucas-romanenko/ultimattewire.git@v0.1.0.dev0"
57
+ pip install ultimattewire
29
58
  ```
30
59
 
31
- Python 3.10 or newer. To run the tests from a checkout:
60
+ Only pre-release versions exist so far (0.1.0.dev1). pip installs a pre-release when it is the only release there is, so no `--pre` is needed; pin the version in a requirements file (`ultimattewire==0.1.0.dev1`) so a later release cannot change your install under you. To install straight from a GitHub tag instead (git needed on the machine):
32
61
 
33
62
  ```sh
34
- pip install ".[test]"
35
- python -m pytest
63
+ pip install "ultimattewire @ git+https://github.com/lucas-romanenko/bmdwire.git@ultimattewire-v0.1.0.dev1#subdirectory=ultimattewire"
36
64
  ```
37
65
 
66
+ Python 3.10 or newer. No other dependencies.
67
+
68
+ ## Features
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.
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.
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.
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.
74
+ - Failed reads are omitted from the archive and reported as warnings instead of being written as placeholder bytes that a later restore would push into live hardware.
75
+ - Short reads raise; truncated blobs are never archived.
76
+ - One-retry connects to survive cold-ARP and first-packet hiccups.
77
+ - Per-parameter display ranges for about 150 control values, used to annotate the state dump (raw `0..10000` to percent, frames, pixels).
78
+ - Pure standard library. No logging; results and warnings are returned to the caller.
79
+
38
80
  ## Usage
39
81
 
40
82
  ```python
@@ -57,6 +99,19 @@ A zip produced by this library can be opened with Smart Remote 4's Restore
57
99
  unchanged, and a Smart Remote "Archive All" zip can be passed straight to
58
100
  `restore_unit_from_bytes`.
59
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
+
60
115
  ## Protocol notes
61
116
 
62
117
  This is the reverse-engineered part and the most useful thing to read before
@@ -105,6 +160,50 @@ already hit and fixed are recorded in `_preamble.py` (a `$` anchor that
105
160
  truncated multi-line FILE LISTs, and a `\s*` that let an empty section
106
161
  swallow the next one).
107
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
+
108
207
  ### The 9996 binary channel
109
208
 
110
209
  Every request opens its own TCP connection; the unit closes it after
@@ -216,6 +315,25 @@ the second.
216
315
  - The display-range table is an interpretation of the manual and the panel
217
316
  UI, not wire data. It affects only the readable text dump.
218
317
 
318
+ ## Development
319
+
320
+ ```sh
321
+ git clone https://github.com/lucas-romanenko/bmdwire.git
322
+ cd bmdwire/ultimattewire
323
+ pip install -e ".[test]"
324
+ python -m pytest
325
+ ```
326
+
327
+ The suite needs no hardware. CI runs it on Python 3.10, 3.12 and 3.14 for every push and pull request.
328
+
329
+ ## Related libraries
330
+
331
+ One library per Blackmagic device family, same shape, same author, all pure standard library except atemwire's small C extension:
332
+
333
+ - [atemwire](../atemwire/): ATEM switchers (UDP protocol, macros, profiles)
334
+ - [hyperdeckwire](../hyperdeckwire/): HyperDeck recorders (transport control, clip upload)
335
+ - [videohubwire](../videohubwire/): Videohub routers (routing, labels)
336
+
219
337
  ## License
220
338
 
221
339
  MIT. See `LICENSE`.
@@ -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