ultimattewire 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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"
@@ -0,0 +1,146 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ Low-level Ultimatte protocol primitives shared by every ultimattewire
4
+ feature: ports, timeouts, and the 9996 binary-channel frame I/O.
5
+
6
+ Binary-channel frame formats (TCP 9996):
7
+ Read : send [opcode:2BE][len:4BE][payload]
8
+ recv [len:4BE][body]
9
+ Write : send [opcode:2BE][name_len:4BE][name:N][data_len:4BE][data:M][0x00]
10
+ recv 6 bytes of zeros = success ACK
11
+
12
+ The trailing 0x00 on writes and the exact framing are required — the
13
+ unit will not ACK frames that don't match. Do not change these without
14
+ re-validating against hardware.
15
+ """
16
+
17
+ import socket
18
+ import struct
19
+ import time
20
+
21
+
22
+ CONTROL_PORT = 9998 # text channel (preamble, FILE LIST, etc.)
23
+ SETTINGS_PORT = 9996 # binary channel (read/write slot blobs + resources)
24
+ CONNECT_TIMEOUT = 5.0
25
+ READ_TIMEOUT = 30.0
26
+ ACK_LEN = 6 # write-ack: 6 zero bytes
27
+ _CONNECT_RETRY_DELAY = 0.25 # seconds between connect attempts
28
+
29
+
30
+ def _recv_exact(sock, n):
31
+ """Read exactly n bytes from sock, or short-read on EOF."""
32
+ buf = b""
33
+ while len(buf) < n:
34
+ chunk = sock.recv(n - len(buf))
35
+ if not chunk:
36
+ break
37
+ buf += chunk
38
+ return buf
39
+
40
+
41
+ def _connect_with_retry(host, port, timeout=CONNECT_TIMEOUT):
42
+ """Open a TCP connection to (host, port) with one retry on transient
43
+ failure. Retries cover only the connect phase — once a socket is
44
+ returned, data-side errors are real failures and not retried.
45
+
46
+ Defeats cold-ARP and first-packet hiccups that present as connection
47
+ refused / OSError / timeout on the first attempt and resolve on the
48
+ second a few hundred ms later.
49
+ """
50
+ last_error = None
51
+ for attempt in range(2):
52
+ try:
53
+ return socket.create_connection((host, port), timeout=timeout)
54
+ except (ConnectionRefusedError, socket.timeout, OSError) as e:
55
+ last_error = e
56
+ if attempt == 0:
57
+ time.sleep(_CONNECT_RETRY_DELAY)
58
+ raise last_error
59
+
60
+
61
+ def binary_request(host, opcode, payload, timeout=CONNECT_TIMEOUT):
62
+ """Issue a binary read on 9996 and return the body bytes (may be empty).
63
+
64
+ Frame: send [opcode:2BE][len:4BE][payload]
65
+ recv [len:4BE][body]
66
+
67
+ A short read of the length header (including a clean immediate EOF;
68
+ we just sent a request, so a reply is owed) or of the body raises
69
+ IOError instead of returning empty/truncated bytes (2026-07-06).
70
+ Before that, a unit that accepted the connection but closed before or
71
+ mid reply yielded silent empty/truncated resources that the archive
72
+ path would zip and later RESTORE to live hardware. A genuinely empty resource (header
73
+ says length 0) still returns b"" cleanly.
74
+ """
75
+ if isinstance(payload, str):
76
+ payload = payload.encode()
77
+ # Cold-ARP retry like upload_one/read_preamble — the read path was the
78
+ # one caller still connecting bare, so archive reads sporadically
79
+ # failed on cold units while writes succeeded.
80
+ s = _connect_with_retry(host, SETTINGS_PORT, timeout=timeout)
81
+ s.settimeout(timeout)
82
+ try:
83
+ frame = struct.pack(">HI", opcode, len(payload)) + payload
84
+ s.sendall(frame)
85
+ length_bytes = _recv_exact(s, 4)
86
+ if len(length_bytes) < 4:
87
+ raise IOError(
88
+ f"short read on length header (got {len(length_bytes)} of 4 "
89
+ f"bytes; unit closed before replying)"
90
+ )
91
+ (length,) = struct.unpack(">I", length_bytes)
92
+ if not length:
93
+ return b""
94
+ body = _recv_exact(s, length)
95
+ if len(body) != length:
96
+ raise IOError(
97
+ f"short read on body (got {len(body)} of {length} bytes)"
98
+ )
99
+ return body
100
+ finally:
101
+ s.close()
102
+
103
+
104
+ def upload_one(host, opcode, name, payload, connect_timeout=CONNECT_TIMEOUT,
105
+ read_timeout=READ_TIMEOUT):
106
+ """Issue a binary write on 9996. Raises IOError if the unit rejects.
107
+
108
+ Frame: send [opcode:2BE][name_len:4BE][name:N][data_len:4BE][data:M][0x00]
109
+ recv 6 bytes of zeros = success ACK
110
+
111
+ The trailing 0x00 is required — the unit will not ACK without it.
112
+ """
113
+ name_bytes = name.encode("utf-8")
114
+ frame = (
115
+ struct.pack(">H", opcode)
116
+ + struct.pack(">I", len(name_bytes))
117
+ + name_bytes
118
+ + struct.pack(">I", len(payload))
119
+ + payload
120
+ + b"\x00"
121
+ )
122
+ s = _connect_with_retry(host, SETTINGS_PORT, timeout=connect_timeout)
123
+ try:
124
+ s.settimeout(read_timeout)
125
+ s.sendall(frame)
126
+ # The unit sends the ACK then closes the connection quickly, so recv()
127
+ # can surface EOF before all ACK_LEN bytes arrive depending on TCP
128
+ # timing. Tolerate short reads as long as we get something that looks
129
+ # like a success ACK (all zeros).
130
+ ack = b""
131
+ while len(ack) < ACK_LEN:
132
+ try:
133
+ chunk = s.recv(ACK_LEN - len(ack))
134
+ except socket.timeout:
135
+ raise IOError(
136
+ f"timeout waiting for ACK (got {len(ack)} of {ACK_LEN} bytes)"
137
+ )
138
+ if not chunk:
139
+ break
140
+ ack += chunk
141
+ if len(ack) == 0:
142
+ raise IOError("connection closed with no ACK bytes (frame rejected)")
143
+ if any(b != 0 for b in ack):
144
+ raise IOError(f"non-zero ACK from unit (likely error): {ack.hex()}")
145
+ finally:
146
+ s.close()
@@ -0,0 +1,16 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ ultimattewire.profile — Smart Remote-compatible Archive / Restore.
4
+
5
+ Parallel to BMD Smart Remote 4's "Archive All" / "Restore" — produces
6
+ and consumes zip files that round-trip through Smart Remote unchanged.
7
+ First public implementation of this feature outside BMD's own tools.
8
+ """
9
+
10
+ from ultimattewire.profile.archive import archive_unit_to_bytes
11
+ from ultimattewire.profile.restore import restore_unit_from_bytes
12
+
13
+ __all__ = [
14
+ "archive_unit_to_bytes",
15
+ "restore_unit_from_bytes",
16
+ ]
@@ -0,0 +1,282 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ Per-parameter display ranges and the annotated-preamble helper. Used by
4
+ profile/archive.py to emit the bonus human-readable .txt dump alongside
5
+ the binary zip. Source: Ultimatte 12 Operations Manual (Feb 2026) +
6
+ Smart Remote 4 panel screenshots.
7
+ """
8
+
9
+
10
+ # Maps protocol parameter name -> (raw_min, raw_max, display_min,
11
+ # display_default, display_max, unit_label).
12
+ # If display_min is None, the value is shown as-is (e.g. pixel counts,
13
+ # frame counts).
14
+ RANGES = {
15
+ # Matte
16
+ "Matte Density": (0, 10000, -100, 0, 300, "%"),
17
+ "Black Gloss": (0, 10000, 0, 0, 100, "%"),
18
+ "Blue Density": (0, 10000, 0, 0, 100, "%"),
19
+ "Green Density": (0, 10000, 0, 0, 100, "%"),
20
+ "Red Density": (0, 10000, 0, 0, 100, "%"),
21
+ "Shadow Level": (0, 10000, 0, 0, 100, "%"),
22
+ "Shadow Threshold": (0, 10000, 0, 0, 100, "%"),
23
+ "Matte Correct Horizontal Size": (0, 6, None, None, None, "px"),
24
+ "Matte Correct Vertical Size": (0, 3, None, None, None, "lines"),
25
+
26
+ # Cursor positions
27
+ "Cursor X": (0, 10000, 0, 0, 100, "%"),
28
+ "Cursor Y": (0, 10000, 0, 0, 100, "%"),
29
+ "Cursor 2 X": (0, 10000, 0, 0, 100, "%"),
30
+ "Cursor 2 Y": (0, 10000, 0, 0, 100, "%"),
31
+
32
+ # Veil
33
+ "Veil Master": (0, 10000, 0, 0, 100, "%"),
34
+ "Veil Red": (0, 10000, 0, 0, 100, "%"),
35
+ "Veil Green": (0, 10000, 0, 0, 100, "%"),
36
+ "Veil Blue": (0, 10000, 0, 0, 100, "%"),
37
+ "Veil Correct Horizontal Size": (0, 6, None, None, None, "px"),
38
+ "Veil Correct Vertical Size": (0, 6, None, None, None, "lines"),
39
+
40
+ # Wall/floor sample colors
41
+ "Wall Color Red": (0, 10000, 0, 0, 100, "%"),
42
+ "Wall Color Green": (0, 10000, 0, 0, 100, "%"),
43
+ "Wall Color Blue": (0, 10000, 0, 0, 100, "%"),
44
+ "Floor Color Red": (0, 10000, 0, 0, 100, "%"),
45
+ "Floor Color Green": (0, 10000, 0, 0, 100, "%"),
46
+ "Floor Color Blue": (0, 10000, 0, 0, 100, "%"),
47
+
48
+ # Clean Up
49
+ "Cleanup Level": (0, 10000, 0, 0, 100, "%"),
50
+ "Cleanup Dark Recover": (0, 10000, 0, 0, 100, "%"),
51
+ "Cleanup Light Recover": (0, 10000, 0, 0, 100, "%"),
52
+ "Cleanup Strength": (0, 10000, 0, 0, 100, "%"),
53
+ "GM Cleanup Level": (0, 10000, 0, 0, 100, "%"),
54
+ "GM Cleanup Dark Recover": (0, 10000, 0, 0, 100, "%"),
55
+ "GM Cleanup Light Recover": (0, 10000, 0, 0, 100, "%"),
56
+ "GM Cleanup Strength": (0, 10000, 0, 0, 100, "%"),
57
+
58
+ # Filter
59
+ "Correction Level": (0, 10000, 0, 0, 100, "%"),
60
+ "Noise Level": (0, 10000, 0, 0, 100, "%"),
61
+
62
+ # Flare 1 / Flare 2
63
+ "Black Balance": (0, 10000, -100, 0, 100, "%"),
64
+ "Gray Balance": (0, 10000, -100, 0, 100, "%"),
65
+ "White Balance": (0, 10000, -100, 0, 100, "%"),
66
+ "Flare Level": (0, 10000, 0, 0, 100, "%"),
67
+ "Cool": (0, 10000, 0, 0, 100, "%"),
68
+ "Skin Tone": (0, 10000, 0, 0, 100, "%"),
69
+ "Light Warm": (0, 10000, 0, 0, 100, "%"),
70
+ "Dark Warm": (0, 10000, 0, 0, 100, "%"),
71
+ "Flare Correct Horizontal Size": (0, 6, None, None, None, "px"),
72
+ "Flare Correct Vertical Size": (0, 6, None, None, None, "lines"),
73
+
74
+ # Ambiance
75
+ "Ambiance Master": (0, 10000, 0, 0, 100, "%"),
76
+ "Ambiance Red": (0, 10000, 0, 0, 100, "%"),
77
+ "Ambiance Green": (0, 10000, 0, 0, 100, "%"),
78
+ "Ambiance Blue": (0, 10000, 0, 0, 100, "%"),
79
+ "Ambiance Strength": (0, 10000, 0, 0, 100, "%"),
80
+ "Direct Light Red": (0, 10000, 0, 0, 100, "%"),
81
+ "Direct Light Green": (0, 10000, 0, 0, 100, "%"),
82
+ "Direct Light Blue": (0, 10000, 0, 0, 100, "%"),
83
+ "Direct Light Mix": (0, 10000, 0, 0, 100, "%"),
84
+ "Vertical Blur": (0, 10000, 0, 0, 100, "%"),
85
+
86
+ # Foreground color
87
+ "FG Saturation Red": (0, 10000, 0, 100, 200, "%"),
88
+ "FG Saturation Green": (0, 10000, 0, 100, 200, "%"),
89
+ "FG Saturation Blue": (0, 10000, 0, 100, 200, "%"),
90
+ "FG Saturation Master": (0, 10000, 0, 100, 200, "%"),
91
+ "FG Contrast Red": (0, 10000, 0, 100, 200, "%"),
92
+ "FG Contrast Green": (0, 10000, 0, 100, 200, "%"),
93
+ "FG Contrast Blue": (0, 10000, 0, 100, 200, "%"),
94
+ "FG Contrast Master": (0, 10000, 0, 100, 200, "%"),
95
+ "FG Black Red": (0, 10000, -100, 0, 100, "%"),
96
+ "FG Black Green": (0, 10000, -100, 0, 100, "%"),
97
+ "FG Black Blue": (0, 10000, -100, 0, 100, "%"),
98
+ "FG Black Master": (0, 10000, -100, 0, 100, "%"),
99
+ "FG White Red": (0, 10000, 0, 100, 200, "%"),
100
+ "FG White Green": (0, 10000, 0, 100, 200, "%"),
101
+ "FG White Blue": (0, 10000, 0, 100, 200, "%"),
102
+ "FG White Master": (0, 10000, 0, 100, 200, "%"),
103
+ "FG Contrast Crossover": (0, 10000, 0, 50, 100, "%"),
104
+ "Fade Mix": (0, 10000, 0, 100, 100, "%"),
105
+
106
+ # Background color
107
+ "BG Saturation Red": (0, 10000, 0, 100, 200, "%"),
108
+ "BG Saturation Green": (0, 10000, 0, 100, 200, "%"),
109
+ "BG Saturation Blue": (0, 10000, 0, 100, 200, "%"),
110
+ "BG Saturation Master": (0, 10000, 0, 100, 200, "%"),
111
+ "BG Contrast Red": (0, 10000, 0, 100, 200, "%"),
112
+ "BG Contrast Green": (0, 10000, 0, 100, 200, "%"),
113
+ "BG Contrast Blue": (0, 10000, 0, 100, 200, "%"),
114
+ "BG Contrast Master": (0, 10000, 0, 100, 200, "%"),
115
+ "BG Black Red": (0, 10000, -100, 0, 100, "%"),
116
+ "BG Black Green": (0, 10000, -100, 0, 100, "%"),
117
+ "BG Black Blue": (0, 10000, -100, 0, 100, "%"),
118
+ "BG Black Master": (0, 10000, -100, 0, 100, "%"),
119
+ "BG White Red": (0, 10000, 0, 100, 200, "%"),
120
+ "BG White Green": (0, 10000, 0, 100, 200, "%"),
121
+ "BG White Blue": (0, 10000, 0, 100, 200, "%"),
122
+ "BG White Master": (0, 10000, 0, 100, 200, "%"),
123
+ "BG Contrast Crossover": (0, 10000, 0, 50, 100, "%"),
124
+ "BG Filter": (0, 10000, 0, 0, 100, "%"),
125
+ "Test Signal Master": (0, 10000, 0, 0, 100, "%"),
126
+ "Test Signal Red": (0, 10000, 0, 0, 100, "%"),
127
+ "Test Signal Green": (0, 10000, 0, 0, 100, "%"),
128
+ "Test Signal Blue": (0, 10000, 0, 0, 100, "%"),
129
+
130
+ # Layer color
131
+ "LY Saturation Red": (0, 10000, 0, 100, 200, "%"),
132
+ "LY Saturation Green": (0, 10000, 0, 100, 200, "%"),
133
+ "LY Saturation Blue": (0, 10000, 0, 100, 200, "%"),
134
+ "LY Saturation Master": (0, 10000, 0, 100, 200, "%"),
135
+ "LY Contrast Red": (0, 10000, 0, 100, 200, "%"),
136
+ "LY Contrast Green": (0, 10000, 0, 100, 200, "%"),
137
+ "LY Contrast Blue": (0, 10000, 0, 100, 200, "%"),
138
+ "LY Contrast Master": (0, 10000, 0, 100, 200, "%"),
139
+ "LY Black Red": (0, 10000, -100, 0, 100, "%"),
140
+ "LY Black Green": (0, 10000, -100, 0, 100, "%"),
141
+ "LY Black Blue": (0, 10000, -100, 0, 100, "%"),
142
+ "LY Black Master": (0, 10000, -100, 0, 100, "%"),
143
+ "LY White Red": (0, 10000, 0, 100, 200, "%"),
144
+ "LY White Green": (0, 10000, 0, 100, 200, "%"),
145
+ "LY White Blue": (0, 10000, 0, 100, 200, "%"),
146
+ "LY White Master": (0, 10000, 0, 100, 200, "%"),
147
+ "LY Contrast Crossover": (0, 10000, 0, 50, 100, "%"),
148
+ "LY Filter": (0, 10000, 0, 0, 100, "%"),
149
+ "LY Test Signal Master": (0, 10000, 0, 0, 100, "%"),
150
+ "LY Test Signal Red": (0, 10000, 0, 0, 100, "%"),
151
+ "LY Test Signal Green": (0, 10000, 0, 0, 100, "%"),
152
+ "LY Test Signal Blue": (0, 10000, 0, 0, 100, "%"),
153
+ "LY Fade Mix": (0, 10000, 0, 100, 100, "%"),
154
+
155
+ # Lighting
156
+ "Lighting Level Red": (0, 10000, 0, 100, 200, "%"),
157
+ "Lighting Level Green": (0, 10000, 0, 100, 200, "%"),
158
+ "Lighting Level Blue": (0, 10000, 0, 100, 200, "%"),
159
+ "Lighting Level Master": (0, 10000, 0, 100, 200, "%"),
160
+ "Lighting Minimum Level": (0, 10000, 0, 25, 100, "%"),
161
+
162
+ # Window
163
+ "Window Position Top": (0, 10000, 0, 0, 100, "%"),
164
+ "Window Position Bottom": (0, 10000, 0, 0, 100, "%"),
165
+ "Window Position Left": (0, 10000, 0, 0, 100, "%"),
166
+ "Window Position Right": (0, 10000, 0, 0, 100, "%"),
167
+ "Window Softness Top": (0, 10000, 0, 0, 100, "%"),
168
+ "Window Softness Bottom": (0, 10000, 0, 0, 100, "%"),
169
+ "Window Softness Left": (0, 10000, 0, 0, 100, "%"),
170
+ "Window Softness Right": (0, 10000, 0, 0, 100, "%"),
171
+ "Window Skew Top": (0, 10000, 0, 0, 100, "%"),
172
+ "Window Skew Bottom": (0, 10000, 0, 0, 100, "%"),
173
+ "Window Skew Left": (0, 10000, 0, 0, 100, "%"),
174
+ "Window Skew Right": (0, 10000, 0, 0, 100, "%"),
175
+ "Window Skew Offset Top": (0, 10000, 0, 0, 100, "%"),
176
+ "Window Skew Offset Bottom": (0, 10000, 0, 0, 100, "%"),
177
+ "Window Skew Offset Left": (0, 10000, 0, 0, 100, "%"),
178
+ "Window Skew Offset Right": (0, 10000, 0, 0, 100, "%"),
179
+
180
+ # Transition rate
181
+ "Transition Rate": (1, 120, None, None, None, "frames"),
182
+
183
+ # Matte Input processing
184
+ "BM Process Horizontal": (0, 3, None, None, None, "px"),
185
+ "BM Process Vertical": (0, 3, None, None, None, "lines"),
186
+ "BM Filter": (0, 10000, 0, 0, 100, "%"),
187
+ "BM Input Level": (0, 10000, 0, 100, 200, "%"),
188
+ "BM Input Offset": (0, 10000, -100, 0, 100, "%"),
189
+ "GM Process Horizontal": (0, 3, None, None, None, "px"),
190
+ "GM Process Vertical": (0, 3, None, None, None, "lines"),
191
+ "GM Filter": (0, 10000, 0, 0, 100, "%"),
192
+ "GM Input Level": (0, 10000, 0, 100, 200, "%"),
193
+ "GM Input Offset": (0, 10000, -100, 0, 100, "%"),
194
+ "HM Process Horizontal": (0, 3, None, None, None, "px"),
195
+ "HM Process Vertical": (0, 3, None, None, None, "lines"),
196
+ "HM Filter": (0, 10000, 0, 0, 100, "%"),
197
+ "HM Input Level": (0, 10000, 0, 100, 200, "%"),
198
+ "HM Input Offset": (0, 10000, -100, 0, 100, "%"),
199
+ "LM Process Horizontal": (0, 3, None, None, None, "px"),
200
+ "LM Process Vertical": (0, 3, None, None, None, "lines"),
201
+ "LM Filter": (0, 10000, 0, 0, 100, "%"),
202
+ "LM Input Level": (0, 10000, 0, 100, 200, "%"),
203
+ "LM Input Offset": (0, 10000, -100, 0, 100, "%"),
204
+
205
+ # Noise cursor
206
+ "Noise Cursor X": (0, 10000, 0, 0, 100, "%"),
207
+ "Noise Cursor Y": (0, 10000, 0, 0, 100, "%"),
208
+
209
+ # FG input timing
210
+ "FG Input Frame Delay": (0, 14, None, None, None, "frames"),
211
+ "FG Input U Position": (0, 10000, -2, 0, 2, "px"),
212
+ "FG Input V Position": (0, 10000, -2, 0, 2, "px"),
213
+ "FG Input UV Position": (0, 10000, -2, 0, 2, "px"),
214
+
215
+ # Highlight levels
216
+ "Talent Highlight Level": (0, 10000, 0, 0, 100, "%"),
217
+ "Monitor Highlight Level": (0, 10000, 0, 0, 100, "%"),
218
+
219
+ # Matte output level
220
+ "Matte Out Level": (0, 10000, 0, 100, 100, "%"),
221
+
222
+ # Output offset
223
+ "Output Offset": (-1500, 1500, None, None, None, "subpx"),
224
+
225
+ # GPI delays
226
+ "GP Out Delay": (1, 120, None, None, None, "frames"),
227
+ "GP 1 Input Delay": (1, 120, None, None, None, "frames"),
228
+ "GP 2 Input Delay": (1, 120, None, None, None, "frames"),
229
+ "GP 3 Input Delay": (1, 120, None, None, None, "frames"),
230
+ "GP 4 Input Delay": (1, 120, None, None, None, "frames"),
231
+ "GP 5 Input Delay": (1, 120, None, None, None, "frames"),
232
+ }
233
+
234
+
235
+ def to_display(name, raw_value):
236
+ """Convert raw protocol value to (display_value, unit). Returns
237
+ (None, None) if the param has no range entry or the value isn't
238
+ numeric."""
239
+ if name not in RANGES:
240
+ return None, None
241
+ try:
242
+ raw = float(raw_value)
243
+ except (TypeError, ValueError):
244
+ return None, None
245
+ rmin, rmax, dmin, _ddef, dmax, unit = RANGES[name]
246
+ if dmin is None:
247
+ return raw, unit
248
+ if rmax == rmin:
249
+ return dmin, unit
250
+ pct = (raw - rmin) / (rmax - rmin)
251
+ return dmin + pct * (dmax - dmin), unit
252
+
253
+
254
+ def annotate_preamble(preamble):
255
+ """Walk the preamble line-by-line and append (display) annotations
256
+ to matched CONTROL lines. Returns the annotated string."""
257
+ out = []
258
+ in_control = False
259
+ for line in preamble.splitlines():
260
+ stripped = line.rstrip("\r")
261
+ if stripped.endswith(":") and not stripped.startswith(" "):
262
+ header = stripped[:-1]
263
+ in_control = header in ("CONTROL", "CONTROL DEFAULT")
264
+ out.append(stripped)
265
+ continue
266
+ if not in_control or ":" not in stripped:
267
+ out.append(stripped)
268
+ continue
269
+ name, _, value = stripped.partition(":")
270
+ name = name.strip()
271
+ value = value.strip()
272
+ lookup_name = name[7:] if name.startswith("Offset ") else name
273
+ display, unit = to_display(lookup_name, value)
274
+ if display is None:
275
+ out.append(stripped)
276
+ continue
277
+ num = str(int(display))
278
+ sep = "" if unit == "%" else " "
279
+ annot = f"{num}{sep}{unit}"
280
+ left = f"{name}: {value}"
281
+ out.append(f"{left:<40} ({annot})")
282
+ return "\n".join(out) + "\n"
@@ -0,0 +1,136 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ Ultimatte archive — produces a zip identical to Smart Remote's
4
+ "Archive All".
5
+
6
+ This module is a port of the standalone archive script that was
7
+ reverse-engineered from packet captures against a real
8
+ Ultimatte 12 4K. Protocol details (opcodes, frame format, zip layout,
9
+ byte order, ranges, sequence) MUST NOT be changed without re-validating
10
+ against hardware — the unit's parser is strict about all of them.
11
+
12
+ Zip structure (matches Smart Remote so the zip restores from the app):
13
+ <label>.zip/
14
+ images/ (empty unless unit has images)
15
+ GPISettings (raw bytes, no length prefix)
16
+ SavedSettings (raw bytes, no length prefix)
17
+ quickfiles/ (empty unless unit has quickfiles)
18
+ presets/ (explicit dir entry — Smart Remote requires)
19
+ presets/<slot_name> (one file per saved slot from FILE LIST)
20
+ <label>_config_readable.txt (bonus: human-readable annotated state
21
+ dump. Ignored by Smart Remote on restore.)
22
+ """
23
+
24
+ import io
25
+ import zipfile
26
+ from datetime import datetime
27
+
28
+ from ultimattewire._preamble import (
29
+ PREAMBLE_QUIET_TIMEOUT,
30
+ extract_label,
31
+ parse_file_list,
32
+ read_preamble,
33
+ )
34
+ from ultimattewire._protocol import CONNECT_TIMEOUT, binary_request
35
+ from ultimattewire.profile._ranges import annotate_preamble
36
+
37
+
38
+ OP_READ_SLOT = 0x0000
39
+ OP_READ_RESOURCE = 0x0003
40
+
41
+
42
+ def _get_slot_blob(host, slot_name, timeout=CONNECT_TIMEOUT):
43
+ return binary_request(host, OP_READ_SLOT, slot_name, timeout=timeout)
44
+
45
+
46
+ def _get_resource(host, name, timeout=CONNECT_TIMEOUT):
47
+ return binary_request(host, OP_READ_RESOURCE, name, timeout=timeout)
48
+
49
+
50
+ def archive_unit_to_bytes(host, request_timeout=CONNECT_TIMEOUT):
51
+ """Pull a full archive of one Ultimatte and return (zip_bytes, info).
52
+
53
+ info is a dict:
54
+ {
55
+ "label": "<unit protocol label, sanitized>",
56
+ "slots": ["slot1", "slot2", ...], # slots present in FILE LIST
57
+ "slot_sizes": {"slot1": int, ...}, # per-slot blob size; absent on per-slot failure
58
+ "gpi_size": int, # 0 if read failed
59
+ "saved_size": int, # 0 if read failed
60
+ "warnings": ["...", ...], # per-resource soft failures
61
+ }
62
+
63
+ Raises Exception on hard failures (cannot reach preamble at all).
64
+ """
65
+ warnings = []
66
+
67
+ preamble = read_preamble(host, connect_timeout=request_timeout,
68
+ quiet_timeout=PREAMBLE_QUIET_TIMEOUT)
69
+ if not preamble:
70
+ raise IOError("empty preamble (unit unreachable or not responding)")
71
+
72
+ label = extract_label(preamble)
73
+ slots = parse_file_list(preamble)
74
+
75
+ slot_blobs = {}
76
+ slot_sizes = {}
77
+ for slot in slots:
78
+ try:
79
+ blob = _get_slot_blob(host, slot, timeout=request_timeout)
80
+ slot_blobs[slot] = blob
81
+ slot_sizes[slot] = len(blob)
82
+ except Exception as e:
83
+ warnings.append(f"preset {slot!r}: {e}")
84
+
85
+ # If a resource read fails, omit it from the zip rather than embedding
86
+ # a placeholder — the restore side handles missing resources by
87
+ # skipping them, but it would faithfully push a placeholder back to
88
+ # the unit and silently corrupt the live config.
89
+ gpi_settings = None
90
+ try:
91
+ gpi_settings = _get_resource(host, "GPISettings", timeout=request_timeout)
92
+ except Exception as e:
93
+ warnings.append(f"GPISettings: {e}")
94
+
95
+ saved_settings = None
96
+ try:
97
+ saved_settings = _get_resource(host, "SavedSettings", timeout=request_timeout)
98
+ except Exception as e:
99
+ warnings.append(f"SavedSettings: {e}")
100
+
101
+ readable_text = annotate_preamble(preamble)
102
+ now = datetime.now().timetuple()[:6]
103
+
104
+ def dir_entry(name):
105
+ zi = zipfile.ZipInfo(name, date_time=now)
106
+ zi.external_attr = 0o40755 << 16
107
+ return zi
108
+
109
+ # Build the zip in Smart Remote's exact format. Order and the explicit
110
+ # "presets/" parent directory entry matter — Software Control's parser
111
+ # rejects archives that don't match this layout.
112
+ # Smart Remote order: images/, GPISettings, SavedSettings, quickfiles/,
113
+ # presets/, presets/<slot>, presets/<slot>, ...
114
+ buf = io.BytesIO()
115
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
116
+ zf.writestr(dir_entry("images/"), "")
117
+ if gpi_settings is not None:
118
+ zf.writestr("GPISettings", gpi_settings)
119
+ if saved_settings is not None:
120
+ zf.writestr("SavedSettings", saved_settings)
121
+ zf.writestr(dir_entry("quickfiles/"), "")
122
+ zf.writestr(dir_entry("presets/"), "")
123
+ for slot, blob in slot_blobs.items():
124
+ zf.writestr(f"presets/{slot}", blob)
125
+ # Bonus: annotated state dump for humans. Smart Remote ignores unknown
126
+ # entries on restore, so this just rides along inside the archive.
127
+ zf.writestr(f"{label}_config_readable.txt", readable_text)
128
+
129
+ return buf.getvalue(), {
130
+ "label": label,
131
+ "slots": slots,
132
+ "slot_sizes": slot_sizes,
133
+ "gpi_size": len(gpi_settings) if gpi_settings is not None else 0,
134
+ "saved_size": len(saved_settings) if saved_settings is not None else 0,
135
+ "warnings": warnings,
136
+ }
@@ -0,0 +1,115 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ Ultimatte 12 / 12 4K restore — pushes a Smart Remote-style archive zip
4
+ back to a unit. Replicates the protocol Software Control uses for
5
+ "Restore".
6
+
7
+ This module is a port of the standalone restore script that was
8
+ reverse-engineered from packet captures against a real
9
+ Ultimatte 12 4K. Protocol details (opcodes, frame format, trailing 0x00
10
+ byte, restore order, 'name' field format) MUST NOT be changed without
11
+ re-validating against hardware — the unit will not ACK frames that
12
+ don't match.
13
+
14
+ Protocol (TCP 9996, one upload per connection):
15
+ Request : [opcode:2BE][name_len:4BE][name:N][data_len:4BE][data:M][0x00]
16
+ Response: 6 bytes of zeros = success ACK
17
+
18
+ Opcodes:
19
+ 0x0100 write a preset slot (mirror of read 0x0000)
20
+ 0x0103 write GPISettings or SavedSettings (mirror of read 0x0003)
21
+
22
+ Software Control puts the full filesystem path of the extracted zip
23
+ member into the 'name' field. The unit only cares about the last
24
+ path segment (and that "presets/" appears for slot writes), so we
25
+ mimic the Software Control path layout for safety. The trailing
26
+ 0x00 byte is required — the unit will not ACK without it.
27
+
28
+ Restore order (matches Software Control captures):
29
+ 1. presets/<slot> for every slot in the zip
30
+ 2. GPISettings
31
+ 3. SavedSettings (overwrites live state, so save it for last)
32
+ """
33
+
34
+ import io
35
+ import zipfile
36
+
37
+ from ultimattewire._protocol import CONNECT_TIMEOUT, READ_TIMEOUT, upload_one
38
+
39
+
40
+ OP_WRITE_SLOT = 0x0100 # for presets/<slot>
41
+ OP_WRITE_RESOURCE = 0x0103 # for GPISettings, SavedSettings
42
+
43
+ # Path prefix mimics Software Control captures. The unit only inspects
44
+ # the last path segment + the "presets/" anchor; the rest is cosmetic
45
+ # but kept identical for forensic parity.
46
+ _WIRE_NAME_PREFIX = (
47
+ "/private/var/folders/00/000000000000000000000000000000"
48
+ "/T/Ultimatte Software Control-PYREST"
49
+ )
50
+
51
+
52
+ def restore_unit_from_bytes(host, zip_bytes, connect_timeout=CONNECT_TIMEOUT,
53
+ read_timeout=READ_TIMEOUT):
54
+ """Restore one Ultimatte from in-memory zip bytes.
55
+
56
+ Returns (ok: bool, info: dict). info contains the per-resource log:
57
+ {
58
+ "presets": [(slot, ok, err_or_None), ...],
59
+ "gpi": (ok, err_or_None) or None if absent,
60
+ "saved": (ok, err_or_None) or None if absent,
61
+ "stopped_at": "<resource>" or None, # set if a failure aborted
62
+ }
63
+ """
64
+ info = {"presets": [], "gpi": None, "saved": None, "stopped_at": None}
65
+
66
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
67
+ names = zf.namelist()
68
+
69
+ # 1) Push all presets first
70
+ preset_names = sorted(
71
+ n for n in names if n.startswith("presets/") and not n.endswith("/")
72
+ )
73
+ for member in preset_names:
74
+ slot = member.split("/", 1)[1]
75
+ blob = zf.read(member)
76
+ wire_name = f"{_WIRE_NAME_PREFIX}/presets/{slot}"
77
+ try:
78
+ upload_one(host, OP_WRITE_SLOT, wire_name, blob,
79
+ connect_timeout=connect_timeout,
80
+ read_timeout=read_timeout)
81
+ info["presets"].append((slot, True, None))
82
+ except Exception as e:
83
+ info["presets"].append((slot, False, str(e)))
84
+ info["stopped_at"] = f"preset:{slot}"
85
+ return False, info
86
+
87
+ # 2) GPISettings
88
+ if "GPISettings" in names:
89
+ blob = zf.read("GPISettings")
90
+ wire_name = f"{_WIRE_NAME_PREFIX}/GPISettings"
91
+ try:
92
+ upload_one(host, OP_WRITE_RESOURCE, wire_name, blob,
93
+ connect_timeout=connect_timeout,
94
+ read_timeout=read_timeout)
95
+ info["gpi"] = (True, None)
96
+ except Exception as e:
97
+ info["gpi"] = (False, str(e))
98
+ info["stopped_at"] = "GPISettings"
99
+ return False, info
100
+
101
+ # 3) SavedSettings last (this one overwrites the live state)
102
+ if "SavedSettings" in names:
103
+ blob = zf.read("SavedSettings")
104
+ wire_name = f"{_WIRE_NAME_PREFIX}/SavedSettings"
105
+ try:
106
+ upload_one(host, OP_WRITE_RESOURCE, wire_name, blob,
107
+ connect_timeout=connect_timeout,
108
+ read_timeout=read_timeout)
109
+ info["saved"] = (True, None)
110
+ except Exception as e:
111
+ info["saved"] = (False, str(e))
112
+ info["stopped_at"] = "SavedSettings"
113
+ return False, info
114
+
115
+ return True, info
@@ -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,12 @@
1
+ ultimattewire/__init__.py,sha256=Vk9fIXY1H8I0WbRlIbqKKjxEeh61EtPgHTmAtpmmnJ4,575
2
+ ultimattewire/_preamble.py,sha256=-cJte_GK-zEovS-3uJmEoiE9F0VlWyq2FszEReFcTjw,2694
3
+ ultimattewire/_protocol.py,sha256=Syp40QhvwaTjaoJOJIPJ0PNP2KoPRLe0k_6odS-WGz0,5449
4
+ ultimattewire/profile/__init__.py,sha256=SoV019phlaOnlJkLHqL0Hngv7zJ0o6yHU3RuVNPgGYA,527
5
+ ultimattewire/profile/_ranges.py,sha256=GiD6EcsIxXRkuZOnZMOXh917yO6ye4WiZ1Jd1OhRvFE,14273
6
+ ultimattewire/profile/archive.py,sha256=KN5cVcWYUijUeJ2sXqi8OowVFUKHdHeQ6bLBvCo1WgI,5366
7
+ ultimattewire/profile/restore.py,sha256=dqnBDWXlbal5_EkX-80F2X0x-xdsCs2Iu-k9TGq_yV4,4617
8
+ ultimattewire-0.1.0.dev0.dist-info/licenses/LICENSE,sha256=Mzt4-lP7NpZGbjxKnwYliRFTN_K-BJhL4CBIMWJO_jM,1072
9
+ ultimattewire-0.1.0.dev0.dist-info/METADATA,sha256=x6lNg9kt4-YuXsoSRjZMDTHadVs71obpQL9ArK42fRo,10428
10
+ ultimattewire-0.1.0.dev0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ ultimattewire-0.1.0.dev0.dist-info/top_level.txt,sha256=wLRvQ_q4o_hCq4ONjvw3OiGbeDW7g-USg4BBjO0OL_c,14
12
+ ultimattewire-0.1.0.dev0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ ultimattewire