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.
- {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/PKG-INFO +126 -24
- ultimattewire-0.1.0.dev0/ultimattewire.egg-info/PKG-INFO → ultimattewire-0.2.0.dev0/README.md +117 -39
- {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/pyproject.toml +11 -1
- {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire/__init__.py +6 -0
- ultimattewire-0.2.0.dev0/ultimattewire/network.py +283 -0
- ultimattewire-0.1.0.dev0/README.md → ultimattewire-0.2.0.dev0/ultimattewire.egg-info/PKG-INFO +141 -23
- {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire.egg-info/SOURCES.txt +1 -0
- {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/LICENSE +0 -0
- {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/setup.cfg +0 -0
- {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire/_preamble.py +0 -0
- {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire/_protocol.py +0 -0
- {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire/profile/__init__.py +0 -0
- {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire/profile/_ranges.py +0 -0
- {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire/profile/archive.py +0 -0
- {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire/profile/restore.py +0 -0
- {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire.egg-info/dependency_links.txt +0 -0
- {ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
34
|
+
[](https://github.com/lucas-romanenko/bmdwire/actions/workflows/ci.yml) [](https://pypi.org/project/ultimattewire/) [](LICENSE)  [](https://github.com/lucas-romanenko/bmdwire/tags)
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
57
|
+
pip install ultimattewire
|
|
45
58
|
```
|
|
46
59
|
|
|
47
|
-
|
|
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 ".
|
|
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`.
|
ultimattewire-0.1.0.dev0/ultimattewire.egg-info/PKG-INFO → ultimattewire-0.2.0.dev0/README.md
RENAMED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
10
|
+
[](https://github.com/lucas-romanenko/bmdwire/actions/workflows/ci.yml) [](https://pypi.org/project/ultimattewire/) [](LICENSE)  [](https://github.com/lucas-romanenko/bmdwire/tags)
|
|
31
11
|
|
|
32
|
-
|
|
33
|
-
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
33
|
+
pip install ultimattewire
|
|
45
34
|
```
|
|
46
35
|
|
|
47
|
-
|
|
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 ".
|
|
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.
|
|
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)
|
ultimattewire-0.1.0.dev0/README.md → ultimattewire-0.2.0.dev0/ultimattewire.egg-info/PKG-INFO
RENAMED
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
34
|
+
[](https://github.com/lucas-romanenko/bmdwire/actions/workflows/ci.yml) [](https://pypi.org/project/ultimattewire/) [](LICENSE)  [](https://github.com/lucas-romanenko/bmdwire/tags)
|
|
15
35
|
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
57
|
+
pip install ultimattewire
|
|
29
58
|
```
|
|
30
59
|
|
|
31
|
-
|
|
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 ".
|
|
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`.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ultimattewire-0.1.0.dev0 → ultimattewire-0.2.0.dev0}/ultimattewire.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|