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.
- ultimattewire/__init__.py +17 -0
- ultimattewire/_preamble.py +76 -0
- ultimattewire/_protocol.py +146 -0
- ultimattewire/profile/__init__.py +16 -0
- ultimattewire/profile/_ranges.py +282 -0
- ultimattewire/profile/archive.py +136 -0
- ultimattewire/profile/restore.py +115 -0
- ultimattewire-0.1.0.dev0.dist-info/METADATA +237 -0
- ultimattewire-0.1.0.dev0.dist-info/RECORD +12 -0
- ultimattewire-0.1.0.dev0.dist-info/WHEEL +5 -0
- ultimattewire-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- ultimattewire-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|