ultimattewire 0.1.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/LICENSE +21 -0
- ultimattewire-0.1.0.dev0/PKG-INFO +237 -0
- ultimattewire-0.1.0.dev0/README.md +221 -0
- ultimattewire-0.1.0.dev0/pyproject.toml +25 -0
- ultimattewire-0.1.0.dev0/setup.cfg +4 -0
- ultimattewire-0.1.0.dev0/ultimattewire/__init__.py +17 -0
- ultimattewire-0.1.0.dev0/ultimattewire/_preamble.py +76 -0
- ultimattewire-0.1.0.dev0/ultimattewire/_protocol.py +146 -0
- ultimattewire-0.1.0.dev0/ultimattewire/profile/__init__.py +16 -0
- ultimattewire-0.1.0.dev0/ultimattewire/profile/_ranges.py +282 -0
- ultimattewire-0.1.0.dev0/ultimattewire/profile/archive.py +136 -0
- ultimattewire-0.1.0.dev0/ultimattewire/profile/restore.py +115 -0
- ultimattewire-0.1.0.dev0/ultimattewire.egg-info/PKG-INFO +237 -0
- ultimattewire-0.1.0.dev0/ultimattewire.egg-info/SOURCES.txt +15 -0
- ultimattewire-0.1.0.dev0/ultimattewire.egg-info/dependency_links.txt +1 -0
- ultimattewire-0.1.0.dev0/ultimattewire.egg-info/requires.txt +3 -0
- ultimattewire-0.1.0.dev0/ultimattewire.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucas Romanenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
# ultimattewire
|
|
18
|
+
|
|
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.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
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.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pip install "git+https://github.com/lucas-romanenko/ultimattewire.git@v0.1.0.dev0"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Python 3.10 or newer. To run the tests from a checkout:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
pip install ".[test]"
|
|
51
|
+
python -m pytest
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from ultimattewire import archive_unit_to_bytes, restore_unit_from_bytes
|
|
58
|
+
|
|
59
|
+
# Archive one unit. The label comes from the unit's own Label field.
|
|
60
|
+
zip_bytes, info = archive_unit_to_bytes("192.0.2.21")
|
|
61
|
+
with open(f"{info['label']}.zip", "wb") as f:
|
|
62
|
+
f.write(zip_bytes)
|
|
63
|
+
print(info["slots"], info["warnings"])
|
|
64
|
+
|
|
65
|
+
# Restore it (or a Smart Remote "Archive All" zip) onto another unit.
|
|
66
|
+
with open("Keyer_A.zip", "rb") as f:
|
|
67
|
+
ok, report = restore_unit_from_bytes("192.0.2.22", f.read())
|
|
68
|
+
if not ok:
|
|
69
|
+
print("stopped at", report["stopped_at"])
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
A zip produced by this library can be opened with Smart Remote 4's Restore
|
|
73
|
+
unchanged, and a Smart Remote "Archive All" zip can be passed straight to
|
|
74
|
+
`restore_unit_from_bytes`.
|
|
75
|
+
|
|
76
|
+
## Protocol notes
|
|
77
|
+
|
|
78
|
+
This is the reverse-engineered part and the most useful thing to read before
|
|
79
|
+
changing anything.
|
|
80
|
+
|
|
81
|
+
### How it was determined
|
|
82
|
+
|
|
83
|
+
The wire format was recovered from packet captures of Blackmagic's Ultimatte
|
|
84
|
+
Smart Remote 4 / Ultimatte Software Control performing **Archive All** and
|
|
85
|
+
**Restore** against a real Ultimatte 12 4K. The library was then validated by
|
|
86
|
+
round-tripping archives through the vendor app: a zip produced here restores
|
|
87
|
+
from Smart Remote, and a Smart Remote zip restores through this code. The
|
|
88
|
+
display ranges come from the Ultimatte 12 Operations Manual (February 2026
|
|
89
|
+
revision) and from Smart Remote 4 panel screenshots, not from the wire.
|
|
90
|
+
|
|
91
|
+
### Two TCP channels
|
|
92
|
+
|
|
93
|
+
| Port | Role | Framing |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| 9998 | Text control channel | The unit streams a text prelude on connect |
|
|
96
|
+
| 9996 | Binary settings channel | One request per TCP connection, big-endian length-prefixed frames |
|
|
97
|
+
|
|
98
|
+
### The 9998 prelude
|
|
99
|
+
|
|
100
|
+
On connect the unit immediately sends a text prelude and, if the client does
|
|
101
|
+
not continue with the interactive greeting the vendor app uses, may close the
|
|
102
|
+
connection partway through. The library reads until the literal
|
|
103
|
+
`END PRELUDE:` marker, then drains for a further 0.3 s, and returns whatever
|
|
104
|
+
arrived (decoded as UTF-8 with replacement). The prelude is a sequence of
|
|
105
|
+
sections introduced by an upper-case header ending in a colon, each holding
|
|
106
|
+
`key: value` lines. The parts the library uses:
|
|
107
|
+
|
|
108
|
+
- `Label: <name>`: the unit's user-assigned name. Sanitised to
|
|
109
|
+
`[A-Za-z0-9._-]` for use as the archive filename root.
|
|
110
|
+
- `Software Release: <x.y>`: the firmware version. Not used by the library
|
|
111
|
+
itself, but this is the field to read if you want it.
|
|
112
|
+
- `CONTROL:` and `CONTROL DEFAULT:`: the live and default control values as
|
|
113
|
+
`Name: raw` lines, raw integers mostly in `0..10000`. Only used for the
|
|
114
|
+
annotated text dump.
|
|
115
|
+
- `FILE LIST:`: one saved preset slot name per line. This is the list of
|
|
116
|
+
slots the archive will read.
|
|
117
|
+
|
|
118
|
+
Section extraction is a regular expression anchored on the header and
|
|
119
|
+
terminated by the next all-caps header or `END PRELUDE:`; two pitfalls
|
|
120
|
+
already hit and fixed are recorded in `_preamble.py` (a `$` anchor that
|
|
121
|
+
truncated multi-line FILE LISTs, and a `\s*` that let an empty section
|
|
122
|
+
swallow the next one).
|
|
123
|
+
|
|
124
|
+
### The 9996 binary channel
|
|
125
|
+
|
|
126
|
+
Every request opens its own TCP connection; the unit closes it after
|
|
127
|
+
replying.
|
|
128
|
+
|
|
129
|
+
Read (`binary_request`):
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
send [opcode: u16 BE][len: u32 BE][payload: len bytes]
|
|
133
|
+
recv [len: u32 BE][body: len bytes]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Write (`upload_one`):
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
send [opcode: u16 BE][name_len: u32 BE][name][data_len: u32 BE][data][0x00]
|
|
140
|
+
recv 6 bytes of 0x00 (success ACK)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Known opcodes:
|
|
144
|
+
|
|
145
|
+
| Opcode | Direction | Payload / name | Meaning |
|
|
146
|
+
|---|---|---|---|
|
|
147
|
+
| `0x0000` | read | preset slot name from FILE LIST | read a saved preset blob |
|
|
148
|
+
| `0x0003` | read | `GPISettings` or `SavedSettings` | read a named resource |
|
|
149
|
+
| `0x0100` | write | `.../presets/<slot>` | write a preset slot (mirror of `0x0000`) |
|
|
150
|
+
| `0x0103` | write | `.../GPISettings` or `.../SavedSettings` | write a resource (mirror of `0x0003`) |
|
|
151
|
+
|
|
152
|
+
Details that matter and were established by experiment:
|
|
153
|
+
|
|
154
|
+
- The trailing `0x00` on a write frame is mandatory. Without it the unit
|
|
155
|
+
silently declines to ACK.
|
|
156
|
+
- The vendor app puts the full filesystem path of the extracted zip member
|
|
157
|
+
into the name field (a macOS temporary directory under
|
|
158
|
+
`Ultimatte Software Control-PYREST/...`). The unit appears to inspect only
|
|
159
|
+
the last path segment, and for slot writes the presence of a `presets/`
|
|
160
|
+
segment. The library sends the same path shape for parity.
|
|
161
|
+
- The ACK is six zero bytes, but the unit closes the socket so quickly after
|
|
162
|
+
sending it that a client can see EOF after fewer than six. The library
|
|
163
|
+
accepts any all-zero prefix as success; any non-zero byte is treated as a
|
|
164
|
+
rejection and raised as `IOError`.
|
|
165
|
+
- A short read of the 4-byte length header, including an immediate clean
|
|
166
|
+
EOF, and a short body read both raise `IOError`. An empty resource whose
|
|
167
|
+
header says length 0 returns `b""` cleanly.
|
|
168
|
+
- Blob contents are opaque. The library never parses preset or resource
|
|
169
|
+
bytes; it moves them verbatim.
|
|
170
|
+
|
|
171
|
+
### Archive layout
|
|
172
|
+
|
|
173
|
+
The zip must match the vendor app byte for byte in structure, or its Restore
|
|
174
|
+
rejects it. Members in this order, with explicit directory entries carrying
|
|
175
|
+
`0o40755` external attributes:
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
images/ (empty directory entry)
|
|
179
|
+
GPISettings (raw bytes, no length prefix)
|
|
180
|
+
SavedSettings (raw bytes, no length prefix)
|
|
181
|
+
quickfiles/ (empty directory entry)
|
|
182
|
+
presets/ (explicit directory entry; required)
|
|
183
|
+
presets/<slot> (one per FILE LIST entry)
|
|
184
|
+
<label>_config_readable.txt (this library's addition; ignored by the vendor app)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Restore order
|
|
188
|
+
|
|
189
|
+
1. Every `presets/<slot>` member, sorted by name, with opcode `0x0100`.
|
|
190
|
+
2. `GPISettings` with opcode `0x0103`, if present.
|
|
191
|
+
3. `SavedSettings` with opcode `0x0103`, if present. It overwrites the live
|
|
192
|
+
state, so it goes last.
|
|
193
|
+
|
|
194
|
+
The first failed write aborts the run; `restore_unit_from_bytes` returns
|
|
195
|
+
`(False, report)` with `report["stopped_at"]` naming the member. Members
|
|
196
|
+
that are not presets or the two resources are ignored, which is how the
|
|
197
|
+
annotated text dump rides along harmlessly.
|
|
198
|
+
|
|
199
|
+
### Timeouts and retries
|
|
200
|
+
|
|
201
|
+
Connect timeout 5 s, read timeout 30 s on writes, 2 s quiet timeout while
|
|
202
|
+
reading the prelude. Every connect is retried once after 250 ms because a
|
|
203
|
+
cold unit regularly refuses or times out the very first packet and answers
|
|
204
|
+
the second.
|
|
205
|
+
|
|
206
|
+
### Verified against
|
|
207
|
+
|
|
208
|
+
- Ultimatte 12 4K, the unit the captures were taken from and the round-trip
|
|
209
|
+
tests were run on. The docstrings also name the Ultimatte 12 (HD) as a
|
|
210
|
+
target of the same protocol.
|
|
211
|
+
- The firmware version of the bench unit was not recorded at capture time.
|
|
212
|
+
Read it from the prelude's `Software Release` line on your own unit and
|
|
213
|
+
treat any difference from the behaviour above as something to re-verify.
|
|
214
|
+
|
|
215
|
+
### Unverified or guessed
|
|
216
|
+
|
|
217
|
+
- Whether the unit reads anything in the write name field beyond the last
|
|
218
|
+
segment and the `presets/` anchor. The full vendor path is sent to be safe.
|
|
219
|
+
- Whether non-zero ACK bytes carry an error code. They are treated as a
|
|
220
|
+
generic rejection.
|
|
221
|
+
- `images/` and `quickfiles/` are always written as empty directories. A unit
|
|
222
|
+
that actually holds images or quick files would not have them archived by
|
|
223
|
+
this library, and no opcode for them is known.
|
|
224
|
+
- Other opcodes almost certainly exist (the gaps between `0x0000` and
|
|
225
|
+
`0x0003`, and between `0x0100` and `0x0103`, are suggestive). None have been
|
|
226
|
+
probed.
|
|
227
|
+
- The interactive greeting the vendor app sends on 9998 after the prelude is
|
|
228
|
+
not implemented; the library only ever reads the prelude.
|
|
229
|
+
- `read_preamble` is bounded per `recv` but has no overall deadline or size
|
|
230
|
+
cap; a unit that never stops sending would keep it reading.
|
|
231
|
+
- Ultimatte 12 HD Mini and other models were not tested.
|
|
232
|
+
- The display-range table is an interpretation of the manual and the panel
|
|
233
|
+
UI, not wire data. It affects only the readable text dump.
|
|
234
|
+
|
|
235
|
+
## License
|
|
236
|
+
|
|
237
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# ultimattewire
|
|
2
|
+
|
|
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.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
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.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pip install "git+https://github.com/lucas-romanenko/ultimattewire.git@v0.1.0.dev0"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Python 3.10 or newer. To run the tests from a checkout:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pip install ".[test]"
|
|
35
|
+
python -m pytest
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from ultimattewire import archive_unit_to_bytes, restore_unit_from_bytes
|
|
42
|
+
|
|
43
|
+
# Archive one unit. The label comes from the unit's own Label field.
|
|
44
|
+
zip_bytes, info = archive_unit_to_bytes("192.0.2.21")
|
|
45
|
+
with open(f"{info['label']}.zip", "wb") as f:
|
|
46
|
+
f.write(zip_bytes)
|
|
47
|
+
print(info["slots"], info["warnings"])
|
|
48
|
+
|
|
49
|
+
# Restore it (or a Smart Remote "Archive All" zip) onto another unit.
|
|
50
|
+
with open("Keyer_A.zip", "rb") as f:
|
|
51
|
+
ok, report = restore_unit_from_bytes("192.0.2.22", f.read())
|
|
52
|
+
if not ok:
|
|
53
|
+
print("stopped at", report["stopped_at"])
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A zip produced by this library can be opened with Smart Remote 4's Restore
|
|
57
|
+
unchanged, and a Smart Remote "Archive All" zip can be passed straight to
|
|
58
|
+
`restore_unit_from_bytes`.
|
|
59
|
+
|
|
60
|
+
## Protocol notes
|
|
61
|
+
|
|
62
|
+
This is the reverse-engineered part and the most useful thing to read before
|
|
63
|
+
changing anything.
|
|
64
|
+
|
|
65
|
+
### How it was determined
|
|
66
|
+
|
|
67
|
+
The wire format was recovered from packet captures of Blackmagic's Ultimatte
|
|
68
|
+
Smart Remote 4 / Ultimatte Software Control performing **Archive All** and
|
|
69
|
+
**Restore** against a real Ultimatte 12 4K. The library was then validated by
|
|
70
|
+
round-tripping archives through the vendor app: a zip produced here restores
|
|
71
|
+
from Smart Remote, and a Smart Remote zip restores through this code. The
|
|
72
|
+
display ranges come from the Ultimatte 12 Operations Manual (February 2026
|
|
73
|
+
revision) and from Smart Remote 4 panel screenshots, not from the wire.
|
|
74
|
+
|
|
75
|
+
### Two TCP channels
|
|
76
|
+
|
|
77
|
+
| Port | Role | Framing |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| 9998 | Text control channel | The unit streams a text prelude on connect |
|
|
80
|
+
| 9996 | Binary settings channel | One request per TCP connection, big-endian length-prefixed frames |
|
|
81
|
+
|
|
82
|
+
### The 9998 prelude
|
|
83
|
+
|
|
84
|
+
On connect the unit immediately sends a text prelude and, if the client does
|
|
85
|
+
not continue with the interactive greeting the vendor app uses, may close the
|
|
86
|
+
connection partway through. The library reads until the literal
|
|
87
|
+
`END PRELUDE:` marker, then drains for a further 0.3 s, and returns whatever
|
|
88
|
+
arrived (decoded as UTF-8 with replacement). The prelude is a sequence of
|
|
89
|
+
sections introduced by an upper-case header ending in a colon, each holding
|
|
90
|
+
`key: value` lines. The parts the library uses:
|
|
91
|
+
|
|
92
|
+
- `Label: <name>`: the unit's user-assigned name. Sanitised to
|
|
93
|
+
`[A-Za-z0-9._-]` for use as the archive filename root.
|
|
94
|
+
- `Software Release: <x.y>`: the firmware version. Not used by the library
|
|
95
|
+
itself, but this is the field to read if you want it.
|
|
96
|
+
- `CONTROL:` and `CONTROL DEFAULT:`: the live and default control values as
|
|
97
|
+
`Name: raw` lines, raw integers mostly in `0..10000`. Only used for the
|
|
98
|
+
annotated text dump.
|
|
99
|
+
- `FILE LIST:`: one saved preset slot name per line. This is the list of
|
|
100
|
+
slots the archive will read.
|
|
101
|
+
|
|
102
|
+
Section extraction is a regular expression anchored on the header and
|
|
103
|
+
terminated by the next all-caps header or `END PRELUDE:`; two pitfalls
|
|
104
|
+
already hit and fixed are recorded in `_preamble.py` (a `$` anchor that
|
|
105
|
+
truncated multi-line FILE LISTs, and a `\s*` that let an empty section
|
|
106
|
+
swallow the next one).
|
|
107
|
+
|
|
108
|
+
### The 9996 binary channel
|
|
109
|
+
|
|
110
|
+
Every request opens its own TCP connection; the unit closes it after
|
|
111
|
+
replying.
|
|
112
|
+
|
|
113
|
+
Read (`binary_request`):
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
send [opcode: u16 BE][len: u32 BE][payload: len bytes]
|
|
117
|
+
recv [len: u32 BE][body: len bytes]
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Write (`upload_one`):
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
send [opcode: u16 BE][name_len: u32 BE][name][data_len: u32 BE][data][0x00]
|
|
124
|
+
recv 6 bytes of 0x00 (success ACK)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Known opcodes:
|
|
128
|
+
|
|
129
|
+
| Opcode | Direction | Payload / name | Meaning |
|
|
130
|
+
|---|---|---|---|
|
|
131
|
+
| `0x0000` | read | preset slot name from FILE LIST | read a saved preset blob |
|
|
132
|
+
| `0x0003` | read | `GPISettings` or `SavedSettings` | read a named resource |
|
|
133
|
+
| `0x0100` | write | `.../presets/<slot>` | write a preset slot (mirror of `0x0000`) |
|
|
134
|
+
| `0x0103` | write | `.../GPISettings` or `.../SavedSettings` | write a resource (mirror of `0x0003`) |
|
|
135
|
+
|
|
136
|
+
Details that matter and were established by experiment:
|
|
137
|
+
|
|
138
|
+
- The trailing `0x00` on a write frame is mandatory. Without it the unit
|
|
139
|
+
silently declines to ACK.
|
|
140
|
+
- The vendor app puts the full filesystem path of the extracted zip member
|
|
141
|
+
into the name field (a macOS temporary directory under
|
|
142
|
+
`Ultimatte Software Control-PYREST/...`). The unit appears to inspect only
|
|
143
|
+
the last path segment, and for slot writes the presence of a `presets/`
|
|
144
|
+
segment. The library sends the same path shape for parity.
|
|
145
|
+
- The ACK is six zero bytes, but the unit closes the socket so quickly after
|
|
146
|
+
sending it that a client can see EOF after fewer than six. The library
|
|
147
|
+
accepts any all-zero prefix as success; any non-zero byte is treated as a
|
|
148
|
+
rejection and raised as `IOError`.
|
|
149
|
+
- A short read of the 4-byte length header, including an immediate clean
|
|
150
|
+
EOF, and a short body read both raise `IOError`. An empty resource whose
|
|
151
|
+
header says length 0 returns `b""` cleanly.
|
|
152
|
+
- Blob contents are opaque. The library never parses preset or resource
|
|
153
|
+
bytes; it moves them verbatim.
|
|
154
|
+
|
|
155
|
+
### Archive layout
|
|
156
|
+
|
|
157
|
+
The zip must match the vendor app byte for byte in structure, or its Restore
|
|
158
|
+
rejects it. Members in this order, with explicit directory entries carrying
|
|
159
|
+
`0o40755` external attributes:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
images/ (empty directory entry)
|
|
163
|
+
GPISettings (raw bytes, no length prefix)
|
|
164
|
+
SavedSettings (raw bytes, no length prefix)
|
|
165
|
+
quickfiles/ (empty directory entry)
|
|
166
|
+
presets/ (explicit directory entry; required)
|
|
167
|
+
presets/<slot> (one per FILE LIST entry)
|
|
168
|
+
<label>_config_readable.txt (this library's addition; ignored by the vendor app)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Restore order
|
|
172
|
+
|
|
173
|
+
1. Every `presets/<slot>` member, sorted by name, with opcode `0x0100`.
|
|
174
|
+
2. `GPISettings` with opcode `0x0103`, if present.
|
|
175
|
+
3. `SavedSettings` with opcode `0x0103`, if present. It overwrites the live
|
|
176
|
+
state, so it goes last.
|
|
177
|
+
|
|
178
|
+
The first failed write aborts the run; `restore_unit_from_bytes` returns
|
|
179
|
+
`(False, report)` with `report["stopped_at"]` naming the member. Members
|
|
180
|
+
that are not presets or the two resources are ignored, which is how the
|
|
181
|
+
annotated text dump rides along harmlessly.
|
|
182
|
+
|
|
183
|
+
### Timeouts and retries
|
|
184
|
+
|
|
185
|
+
Connect timeout 5 s, read timeout 30 s on writes, 2 s quiet timeout while
|
|
186
|
+
reading the prelude. Every connect is retried once after 250 ms because a
|
|
187
|
+
cold unit regularly refuses or times out the very first packet and answers
|
|
188
|
+
the second.
|
|
189
|
+
|
|
190
|
+
### Verified against
|
|
191
|
+
|
|
192
|
+
- Ultimatte 12 4K, the unit the captures were taken from and the round-trip
|
|
193
|
+
tests were run on. The docstrings also name the Ultimatte 12 (HD) as a
|
|
194
|
+
target of the same protocol.
|
|
195
|
+
- The firmware version of the bench unit was not recorded at capture time.
|
|
196
|
+
Read it from the prelude's `Software Release` line on your own unit and
|
|
197
|
+
treat any difference from the behaviour above as something to re-verify.
|
|
198
|
+
|
|
199
|
+
### Unverified or guessed
|
|
200
|
+
|
|
201
|
+
- Whether the unit reads anything in the write name field beyond the last
|
|
202
|
+
segment and the `presets/` anchor. The full vendor path is sent to be safe.
|
|
203
|
+
- Whether non-zero ACK bytes carry an error code. They are treated as a
|
|
204
|
+
generic rejection.
|
|
205
|
+
- `images/` and `quickfiles/` are always written as empty directories. A unit
|
|
206
|
+
that actually holds images or quick files would not have them archived by
|
|
207
|
+
this library, and no opcode for them is known.
|
|
208
|
+
- Other opcodes almost certainly exist (the gaps between `0x0000` and
|
|
209
|
+
`0x0003`, and between `0x0100` and `0x0103`, are suggestive). None have been
|
|
210
|
+
probed.
|
|
211
|
+
- The interactive greeting the vendor app sends on 9998 after the prelude is
|
|
212
|
+
not implemented; the library only ever reads the prelude.
|
|
213
|
+
- `read_preamble` is bounded per `recv` but has no overall deadline or size
|
|
214
|
+
cap; a unit that never stops sending would keep it reading.
|
|
215
|
+
- Ultimatte 12 HD Mini and other models were not tested.
|
|
216
|
+
- The display-range table is an interpretation of the manual and the panel
|
|
217
|
+
UI, not wire data. It affects only the readable text dump.
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ultimattewire"
|
|
7
|
+
version = "0.1.0.dev0"
|
|
8
|
+
description = "Blackmagic Ultimatte 12 archive and restore over its native TCP protocol (Smart Remote 4 compatible zips)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Lucas Romanenko" }]
|
|
13
|
+
dependencies = []
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
test = ["pytest"]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
include = ["ultimattewire", "ultimattewire.*"]
|
|
25
|
+
exclude = ["ultimattewire.tests"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""
|
|
3
|
+
ultimattewire — Blackmagic Ultimatte 12 / 12 4K control library.
|
|
4
|
+
|
|
5
|
+
Protocol layer reverse-engineered from packet captures against real
|
|
6
|
+
hardware; opcodes, frame format, byte order, and sequence must match
|
|
7
|
+
the unit's parser exactly. See ultimattewire.profile for the Smart
|
|
8
|
+
Remote-compatible Archive/Restore feature (the first public
|
|
9
|
+
implementation of it outside BMD's own tools).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ultimattewire.profile import archive_unit_to_bytes, restore_unit_from_bytes
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"archive_unit_to_bytes",
|
|
16
|
+
"restore_unit_from_bytes",
|
|
17
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""
|
|
3
|
+
Ultimatte 9998 text channel: connect, read the prelude, and parse named
|
|
4
|
+
sections out of it. Used by profile/archive (to build the zip) and by
|
|
5
|
+
any future feature that needs the unit's current state (label, video
|
|
6
|
+
mode, FILE LIST, etc.).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
|
|
12
|
+
from ultimattewire._protocol import CONNECT_TIMEOUT, CONTROL_PORT, _connect_with_retry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
PREAMBLE_QUIET_TIMEOUT = 2.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_preamble(host, connect_timeout=CONNECT_TIMEOUT,
|
|
19
|
+
quiet_timeout=PREAMBLE_QUIET_TIMEOUT):
|
|
20
|
+
"""Connect to 9998 and read until 'END PRELUDE:'."""
|
|
21
|
+
s = _connect_with_retry(host, CONTROL_PORT, timeout=connect_timeout)
|
|
22
|
+
s.settimeout(quiet_timeout)
|
|
23
|
+
buf = bytearray()
|
|
24
|
+
try:
|
|
25
|
+
while True:
|
|
26
|
+
try:
|
|
27
|
+
chunk = s.recv(65536)
|
|
28
|
+
except socket.timeout:
|
|
29
|
+
break
|
|
30
|
+
if not chunk:
|
|
31
|
+
break
|
|
32
|
+
buf.extend(chunk)
|
|
33
|
+
if b"END PRELUDE:" in buf:
|
|
34
|
+
s.settimeout(0.3)
|
|
35
|
+
try:
|
|
36
|
+
while True:
|
|
37
|
+
more = s.recv(65536)
|
|
38
|
+
if not more:
|
|
39
|
+
break
|
|
40
|
+
buf.extend(more)
|
|
41
|
+
except socket.timeout:
|
|
42
|
+
pass
|
|
43
|
+
break
|
|
44
|
+
finally:
|
|
45
|
+
s.close()
|
|
46
|
+
return buf.decode("utf-8", errors="replace")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_section(text, header):
|
|
50
|
+
"""Extract the body of a named CAPS-header section from a prelude."""
|
|
51
|
+
# NOTE: use \Z (end-of-string only), not $ — with re.MULTILINE, $ matches
|
|
52
|
+
# at the end of every line, which would terminate the lazy capture after
|
|
53
|
+
# the first line of the section. This bug caused multi-line FILE LIST
|
|
54
|
+
# sections (e.g. with multiple presets) to be truncated to just the first
|
|
55
|
+
# entry.
|
|
56
|
+
# Also: use [ \t]* (not \s*) after the header colon — \s* matches newlines
|
|
57
|
+
# too, which would cause an empty section to swallow the next section.
|
|
58
|
+
pattern = rf"^{re.escape(header)}:[ \t]*\n(.*?)(?=\n[A-Z][A-Z0-9 ]*:\s*\n|\nEND PRELUDE:|\Z)"
|
|
59
|
+
m = re.search(pattern, text, re.MULTILINE | re.DOTALL)
|
|
60
|
+
return m.group(1).strip() if m else ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_file_list(text):
|
|
64
|
+
"""Return the list of preset slot names from the FILE LIST section."""
|
|
65
|
+
section = parse_section(text, "FILE LIST")
|
|
66
|
+
if not section:
|
|
67
|
+
return []
|
|
68
|
+
return [ln.strip() for ln in section.splitlines() if ln.strip()]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def extract_label(text):
|
|
72
|
+
"""Return the unit's Label field, sanitized for use as a filename root."""
|
|
73
|
+
m = re.search(r"^Label:\s*(.+)$", text, re.MULTILINE)
|
|
74
|
+
if m:
|
|
75
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", m.group(1).strip())
|
|
76
|
+
return "unknown"
|