videohubwire 0.1.0.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: videohubwire
3
+ Version: 0.1.0.dev0
4
+ Summary: Blackmagic Videohub Ethernet Protocol client (TCP 9990): state snapshot, routing, label editing
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
+ # videohubwire
18
+
19
+ videohubwire is a small, dependency-free Python client for Blackmagic
20
+ **Videohub** routers speaking the Videohub Ethernet Protocol on TCP 9990. It
21
+ connects, parses the router's state dump into a snapshot (device info, input
22
+ and output labels, output locks, routing), routes a source to a destination
23
+ with ACK/NAK handling, renames ports, and keeps the snapshot current by
24
+ applying the update blocks the router pushes when any client changes
25
+ something. It is synchronous and single-socket by design: open, read, act,
26
+ close, in milliseconds on a LAN, which suits per-request use from a web
27
+ backend or a script.
28
+
29
+ ## Features
30
+
31
+ - `Videohub` context manager: `connect()` parses the preamble, `close()` shuts the socket, `state()` returns a UI-ready dict.
32
+ - `route(dest, src)` sends one `VIDEO OUTPUT ROUTING` change, waits for `ACK`/`NAK`, and applies any pushed blocks that arrive in between.
33
+ - `set_input_label()` / `set_output_label()` with newline collapsing and a 64-character cap before anything hits the wire.
34
+ - `ping()` liveness check.
35
+ - Output lock states (`U` / `O` / `L`) surfaced per destination; the client never takes locks.
36
+ - Bounds checks against the router's own port counts; a `NAK` or timeout raises `VideohubError`.
37
+ - Works with and without the `END PRELUDE:` marker, so old and new firmware both connect.
38
+ - One connect retry for cold-ARP first-packet loss; malformed lines are logged and skipped, not fatal.
39
+ - Pure standard library; a `socket_factory` hook lets the whole suite run without hardware.
40
+
41
+ ## Install
42
+
43
+ ```sh
44
+ pip install "git+https://github.com/lucas-romanenko/videohubwire.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 videohubwire import Videohub, VideohubError
58
+
59
+ with Videohub('192.0.2.31') as vh:
60
+ snap = vh.state()
61
+ print(snap['device']['model_name'], snap['device']['video_inputs'], 'x',
62
+ snap['device']['video_outputs'])
63
+ for out in snap['outputs']:
64
+ print(out['index'], out['label'], '<-', out['source'], out['lock'])
65
+
66
+ try:
67
+ vh.route(dest=3, src=12) # 0-based, like the wire
68
+ except VideohubError as e:
69
+ print('refused:', e) # locked destination, NAK, or timeout
70
+
71
+ vh.set_output_label(3, 'Wall Monitor 1')
72
+ ```
73
+
74
+ ## Protocol notes
75
+
76
+ Blackmagic documents the block format, the block names and the `ACK` / `NAK`
77
+ replies in the Videohub Ethernet Protocol document that ships with the
78
+ Videohub SDK; this section covers only what that document does not settle
79
+ or what this client does on top of it.
80
+
81
+ - **Preamble end detection.** Newer firmware ends the initial state dump with an `END PRELUDE:` block; older firmware simply stops sending. The client accepts either: it returns as soon as the marker arrives, or once a `VIDEOHUB DEVICE:` block and a `VIDEO OUTPUT ROUTING:` block have both been seen and the wire has been quiet for 0.3 s. A router that sends neither the marker nor a routing block is reported as "no state preamble"; that is a known limitation for an unusual device rather than a supported case.
82
+ - **Pushed updates interleave with replies.** After the preamble the router pushes the same block shapes whenever state changes from any client. Those pushes can land between a command and its `ACK`/`NAK`, so `route()`, the label setters and `ping()` apply any non-reply block they read and keep waiting for the reply. `state()` drains pending pushes with a short non-blocking read before building the snapshot.
83
+ - **Optimistic routing apply.** On `ACK` the client records the new route immediately instead of waiting for the router's own `VIDEO OUTPUT ROUTING:` broadcast, so a `state()` call right after `route()` is already correct.
84
+ - **Lock letters.** `VIDEO OUTPUT LOCKS` reports `U` (unlocked), `O` (locked by this connection) and `L` (locked by another client). This client never sends a lock command; it reports the letters so a caller can render locked destinations read-only. Routing a destination locked elsewhere returns `NAK`, which is raised as `VideohubError`.
85
+ - **Labels with spaces and empty labels.** Indexed body lines are `<index> <value>`; only the first token is the index and the rest, spaces included, is the value. A line such as `3 ` (index and nothing else) is a cleared label; `state()` substitutes `Input N` / `Output N` (1-based) for display.
86
+ - **Label limits.** The router rejects labels containing newlines because they break the block framing, and caps labels at roughly 64 characters. The client collapses all whitespace runs to single spaces and refuses labels longer than 64 characters before sending.
87
+ - **Port counts.** `video_inputs` / `video_outputs` come from the `VIDEOHUB DEVICE:` block. If those keys are absent the snapshot sizes itself from the highest label index seen. A non-numeric count in that block raises `ValueError` rather than `VideohubError`.
88
+ - **Ignored blocks.** `CONFIGURATION:`, `SERIAL PORT ...`, `MONITORING OUTPUT ...`, `VIDEO INPUT STATUS`, and any other block the client does not model are parsed past and dropped. Only `PROTOCOL PREAMBLE`, `VIDEOHUB DEVICE`, `INPUT LABELS`, `OUTPUT LABELS`, `VIDEO OUTPUT ROUTING` and `VIDEO OUTPUT LOCKS` update state.
89
+ - **Connect retry.** The first TCP connect to a router the host has not spoken to recently is sometimes lost to ARP resolution. `connect()` retries once after 0.3 s on any `OSError`; a second failure propagates unchanged.
90
+ - **Timeouts.** Connect and read timeouts default to 3 s. A read timeout while waiting for a reply raises `VideohubError`; the router closing the connection raises it too, rather than looping.
91
+ - **What the test fixture reflects.** The canned preamble in the tests has the shape of a Smart Videohub 40 x 40 reporting protocol version 2.7 (device block, labels, locks, routing, optional marker). Other models were not exercised.
92
+
93
+ ## License
94
+
95
+ MIT. See `LICENSE`.
@@ -0,0 +1,79 @@
1
+ # videohubwire
2
+
3
+ videohubwire is a small, dependency-free Python client for Blackmagic
4
+ **Videohub** routers speaking the Videohub Ethernet Protocol on TCP 9990. It
5
+ connects, parses the router's state dump into a snapshot (device info, input
6
+ and output labels, output locks, routing), routes a source to a destination
7
+ with ACK/NAK handling, renames ports, and keeps the snapshot current by
8
+ applying the update blocks the router pushes when any client changes
9
+ something. It is synchronous and single-socket by design: open, read, act,
10
+ close, in milliseconds on a LAN, which suits per-request use from a web
11
+ backend or a script.
12
+
13
+ ## Features
14
+
15
+ - `Videohub` context manager: `connect()` parses the preamble, `close()` shuts the socket, `state()` returns a UI-ready dict.
16
+ - `route(dest, src)` sends one `VIDEO OUTPUT ROUTING` change, waits for `ACK`/`NAK`, and applies any pushed blocks that arrive in between.
17
+ - `set_input_label()` / `set_output_label()` with newline collapsing and a 64-character cap before anything hits the wire.
18
+ - `ping()` liveness check.
19
+ - Output lock states (`U` / `O` / `L`) surfaced per destination; the client never takes locks.
20
+ - Bounds checks against the router's own port counts; a `NAK` or timeout raises `VideohubError`.
21
+ - Works with and without the `END PRELUDE:` marker, so old and new firmware both connect.
22
+ - One connect retry for cold-ARP first-packet loss; malformed lines are logged and skipped, not fatal.
23
+ - Pure standard library; a `socket_factory` hook lets the whole suite run without hardware.
24
+
25
+ ## Install
26
+
27
+ ```sh
28
+ pip install "git+https://github.com/lucas-romanenko/videohubwire.git@v0.1.0.dev0"
29
+ ```
30
+
31
+ Python 3.10 or newer. To run the tests from a checkout:
32
+
33
+ ```sh
34
+ pip install ".[test]"
35
+ python -m pytest
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ from videohubwire import Videohub, VideohubError
42
+
43
+ with Videohub('192.0.2.31') as vh:
44
+ snap = vh.state()
45
+ print(snap['device']['model_name'], snap['device']['video_inputs'], 'x',
46
+ snap['device']['video_outputs'])
47
+ for out in snap['outputs']:
48
+ print(out['index'], out['label'], '<-', out['source'], out['lock'])
49
+
50
+ try:
51
+ vh.route(dest=3, src=12) # 0-based, like the wire
52
+ except VideohubError as e:
53
+ print('refused:', e) # locked destination, NAK, or timeout
54
+
55
+ vh.set_output_label(3, 'Wall Monitor 1')
56
+ ```
57
+
58
+ ## Protocol notes
59
+
60
+ Blackmagic documents the block format, the block names and the `ACK` / `NAK`
61
+ replies in the Videohub Ethernet Protocol document that ships with the
62
+ Videohub SDK; this section covers only what that document does not settle
63
+ or what this client does on top of it.
64
+
65
+ - **Preamble end detection.** Newer firmware ends the initial state dump with an `END PRELUDE:` block; older firmware simply stops sending. The client accepts either: it returns as soon as the marker arrives, or once a `VIDEOHUB DEVICE:` block and a `VIDEO OUTPUT ROUTING:` block have both been seen and the wire has been quiet for 0.3 s. A router that sends neither the marker nor a routing block is reported as "no state preamble"; that is a known limitation for an unusual device rather than a supported case.
66
+ - **Pushed updates interleave with replies.** After the preamble the router pushes the same block shapes whenever state changes from any client. Those pushes can land between a command and its `ACK`/`NAK`, so `route()`, the label setters and `ping()` apply any non-reply block they read and keep waiting for the reply. `state()` drains pending pushes with a short non-blocking read before building the snapshot.
67
+ - **Optimistic routing apply.** On `ACK` the client records the new route immediately instead of waiting for the router's own `VIDEO OUTPUT ROUTING:` broadcast, so a `state()` call right after `route()` is already correct.
68
+ - **Lock letters.** `VIDEO OUTPUT LOCKS` reports `U` (unlocked), `O` (locked by this connection) and `L` (locked by another client). This client never sends a lock command; it reports the letters so a caller can render locked destinations read-only. Routing a destination locked elsewhere returns `NAK`, which is raised as `VideohubError`.
69
+ - **Labels with spaces and empty labels.** Indexed body lines are `<index> <value>`; only the first token is the index and the rest, spaces included, is the value. A line such as `3 ` (index and nothing else) is a cleared label; `state()` substitutes `Input N` / `Output N` (1-based) for display.
70
+ - **Label limits.** The router rejects labels containing newlines because they break the block framing, and caps labels at roughly 64 characters. The client collapses all whitespace runs to single spaces and refuses labels longer than 64 characters before sending.
71
+ - **Port counts.** `video_inputs` / `video_outputs` come from the `VIDEOHUB DEVICE:` block. If those keys are absent the snapshot sizes itself from the highest label index seen. A non-numeric count in that block raises `ValueError` rather than `VideohubError`.
72
+ - **Ignored blocks.** `CONFIGURATION:`, `SERIAL PORT ...`, `MONITORING OUTPUT ...`, `VIDEO INPUT STATUS`, and any other block the client does not model are parsed past and dropped. Only `PROTOCOL PREAMBLE`, `VIDEOHUB DEVICE`, `INPUT LABELS`, `OUTPUT LABELS`, `VIDEO OUTPUT ROUTING` and `VIDEO OUTPUT LOCKS` update state.
73
+ - **Connect retry.** The first TCP connect to a router the host has not spoken to recently is sometimes lost to ARP resolution. `connect()` retries once after 0.3 s on any `OSError`; a second failure propagates unchanged.
74
+ - **Timeouts.** Connect and read timeouts default to 3 s. A read timeout while waiting for a reply raises `VideohubError`; the router closing the connection raises it too, rather than looping.
75
+ - **What the test fixture reflects.** The canned preamble in the tests has the shape of a Smart Videohub 40 x 40 reporting protocol version 2.7 (device block, labels, locks, routing, optional marker). Other models were not exercised.
76
+
77
+ ## License
78
+
79
+ MIT. See `LICENSE`.
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "videohubwire"
7
+ version = "0.1.0.dev0"
8
+ description = "Blackmagic Videohub Ethernet Protocol client (TCP 9990): state snapshot, routing, label editing"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Lucas Romanenko" }]
13
+ dependencies = []
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ test = ["pytest"]
22
+
23
+ [tool.setuptools.packages.find]
24
+ include = ["videohubwire", "videohubwire.*"]
25
+ exclude = ["videohubwire.tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """videohubwire — Blackmagic Videohub Ethernet Protocol client.
3
+
4
+ Synchronous, stdlib-only. See ``client.py`` for the protocol
5
+ notes. Public surface::
6
+
7
+ from videohubwire import Videohub, VideohubError
8
+
9
+ with Videohub('192.0.2.31') as vh:
10
+ snapshot = vh.state()
11
+ vh.route(dest=3, src=12)
12
+ """
13
+
14
+ from videohubwire.client import Videohub, VideohubError
15
+
16
+ __all__ = ['Videohub', 'VideohubError']
@@ -0,0 +1,376 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """videohubwire.client — Videohub Ethernet Protocol client.
3
+
4
+ Text-based, block-oriented protocol over TCP 9990, documented in
5
+ Blackmagic's "Videohub Ethernet Protocol" PDF (ships with the Videohub
6
+ SDK). A block is a header line ending in ``:``, zero or more body
7
+ lines, and a blank-line terminator::
8
+
9
+ VIDEO OUTPUT ROUTING:
10
+ 0 12
11
+ 3 7
12
+ <blank>
13
+
14
+ On connect the hub dumps its full state as a run of blocks (protocol
15
+ preamble, device info, input/output labels, output locks, routing —
16
+ newer firmware ends the dump with ``END PRELUDE:``). After that it
17
+ PUSHES the same block shapes whenever state changes from any client
18
+ (another panel routes, a label edit), and answers commands with a bare
19
+ ``ACK`` or ``NAK`` block.
20
+
21
+ This client is synchronous and single-socket (no worker thread): connect parses the preamble into a state snapshot;
22
+ :meth:`state` drains any pushed updates and returns the current view;
23
+ :meth:`route` sends one routing change and waits for ACK/NAK, applying
24
+ any interleaved push blocks on the way. Intended usage is short-lived,
25
+ per-request connections: the preamble for even a 120x120 hub is a few
26
+ KB, so connect-read-act-close takes milliseconds on a LAN.
27
+
28
+ Lock states in ``VIDEO OUTPUT LOCKS``: ``U`` unlocked, ``O`` locked by
29
+ this connection ("owned"), ``L`` locked by another client. This client
30
+ never takes locks; it reports them so the UI can render locked
31
+ destinations read-only. Routing a locked destination gets a NAK.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import logging
37
+ import socket
38
+ import time
39
+ from typing import Callable, Dict, List, Optional
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ DEFAULT_PORT = 9990
45
+ DEFAULT_CONNECT_TIMEOUT = 3.0
46
+ DEFAULT_READ_TIMEOUT = 3.0
47
+
48
+ # The preamble has no length header and pre-END-PRELUDE firmware never
49
+ # marks its end. Treat it as complete once the essential blocks arrived
50
+ # and the wire has gone quiet for this long.
51
+ PREAMBLE_QUIET_S = 0.3
52
+
53
+ # One retry on the initial TCP connect: the first packet to a device the
54
+ # host hasn't talked to recently can be eaten by ARP resolution (the
55
+ # same cold-target behaviour other Blackmagic devices show).
56
+ CONNECT_RETRY_DELAY_S = 0.3
57
+
58
+
59
+ class VideohubError(Exception):
60
+ """Protocol-level failure: NAK from the hub, closed connection, or
61
+ a timeout waiting for a response."""
62
+
63
+
64
+ class Videohub:
65
+ """Client for one Videohub. Context manager::
66
+
67
+ with Videohub(ip) as vh:
68
+ snap = vh.state()
69
+ vh.route(dest, src)
70
+
71
+ ``socket_factory`` is a hook for tests: a callable with the
72
+ signature of :func:`socket.create_connection` returning an object
73
+ with ``sendall``, ``recv``, ``settimeout``, ``close``, ``shutdown``.
74
+ """
75
+
76
+ def __init__(self,
77
+ host: str,
78
+ port: int = DEFAULT_PORT,
79
+ *,
80
+ connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
81
+ read_timeout: float = DEFAULT_READ_TIMEOUT,
82
+ socket_factory: Optional[Callable] = None):
83
+ self.host = host
84
+ self.port = port
85
+ self.connect_timeout = connect_timeout
86
+ self.read_timeout = read_timeout
87
+ self._socket_factory = socket_factory or socket.create_connection
88
+ self._sock = None
89
+ self._buf = b''
90
+
91
+ # State assembled from preamble + pushed blocks. Dicts keyed by
92
+ # int index — the hub addresses everything 0-based.
93
+ self.protocol_version: str = ''
94
+ self.device: Dict[str, str] = {}
95
+ self.input_labels: Dict[int, str] = {}
96
+ self.output_labels: Dict[int, str] = {}
97
+ self.routing: Dict[int, int] = {} # dest -> src
98
+ self.locks: Dict[int, str] = {} # dest -> 'U' | 'O' | 'L'
99
+
100
+ # ------------------------------------------------------------------
101
+ # Lifecycle
102
+ # ------------------------------------------------------------------
103
+
104
+ def __enter__(self) -> 'Videohub':
105
+ self.connect()
106
+ return self
107
+
108
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
109
+ self.close()
110
+
111
+ def connect(self) -> None:
112
+ """Open the TCP connection and parse the state preamble."""
113
+ if self._sock is not None:
114
+ self.close()
115
+ try:
116
+ self._sock = self._socket_factory((self.host, self.port),
117
+ timeout=self.connect_timeout)
118
+ except OSError:
119
+ time.sleep(CONNECT_RETRY_DELAY_S)
120
+ self._sock = self._socket_factory((self.host, self.port),
121
+ timeout=self.connect_timeout)
122
+ self._buf = b''
123
+ try:
124
+ self._read_preamble()
125
+ except Exception:
126
+ self.close()
127
+ raise
128
+
129
+ def close(self) -> None:
130
+ if self._sock is None:
131
+ return
132
+ try:
133
+ self._sock.shutdown(socket.SHUT_RDWR)
134
+ except OSError:
135
+ pass
136
+ try:
137
+ self._sock.close()
138
+ except OSError:
139
+ pass
140
+ self._sock = None
141
+
142
+ # ------------------------------------------------------------------
143
+ # Public API
144
+ # ------------------------------------------------------------------
145
+
146
+ @property
147
+ def video_inputs(self) -> int:
148
+ return int(self.device.get('video inputs') or 0)
149
+
150
+ @property
151
+ def video_outputs(self) -> int:
152
+ return int(self.device.get('video outputs') or 0)
153
+
154
+ def state(self) -> dict:
155
+ """Drain pending pushed updates and return a UI-ready snapshot.
156
+
157
+ Shape::
158
+
159
+ {'device': {'model_name', 'friendly_name', 'video_inputs',
160
+ 'video_outputs', 'protocol_version'},
161
+ 'inputs': [{'index', 'label'}, ...],
162
+ 'outputs': [{'index', 'label', 'source', 'lock'}, ...]}
163
+ """
164
+ self._drain()
165
+ n_in = self.video_inputs or (max(self.input_labels, default=-1) + 1)
166
+ n_out = self.video_outputs or (max(self.output_labels, default=-1) + 1)
167
+ return {
168
+ 'device': {
169
+ 'model_name': self.device.get('model name', ''),
170
+ 'friendly_name': self.device.get('friendly name', ''),
171
+ 'video_inputs': n_in,
172
+ 'video_outputs': n_out,
173
+ 'protocol_version': self.protocol_version,
174
+ },
175
+ 'inputs': [
176
+ {'index': i,
177
+ 'label': self.input_labels.get(i) or f'Input {i + 1}'}
178
+ for i in range(n_in)
179
+ ],
180
+ 'outputs': [
181
+ {'index': i,
182
+ 'label': self.output_labels.get(i) or f'Output {i + 1}',
183
+ 'source': self.routing.get(i),
184
+ 'lock': self.locks.get(i, 'U')}
185
+ for i in range(n_out)
186
+ ],
187
+ }
188
+
189
+ def route(self, dest: int, src: int) -> None:
190
+ """Route ``src`` to ``dest``. Raises :class:`VideohubError` on
191
+ NAK (typically a locked destination) or response timeout."""
192
+ dest, src = int(dest), int(src)
193
+ if dest < 0 or src < 0:
194
+ raise VideohubError(f'invalid route {dest} <- {src}')
195
+ n_out, n_in = self.video_outputs, self.video_inputs
196
+ if (n_out and dest >= n_out) or (n_in and src >= n_in):
197
+ raise VideohubError(
198
+ f'route {dest} <- {src} outside this hub '
199
+ f'({n_in}x{n_out})')
200
+ self._send(f'VIDEO OUTPUT ROUTING:\n{dest} {src}\n\n')
201
+ deadline = time.monotonic() + self.read_timeout
202
+ while True:
203
+ block = self._recv_block(deadline - time.monotonic())
204
+ if block is None:
205
+ raise VideohubError(
206
+ f'timed out waiting for ACK routing {dest} <- {src}')
207
+ header = block[0].strip()
208
+ if header == 'ACK':
209
+ # The hub also broadcasts the changed ROUTING block;
210
+ # apply optimistically so a state() right after is
211
+ # correct even if that push is still in flight.
212
+ self.routing[dest] = src
213
+ return
214
+ if header == 'NAK':
215
+ raise VideohubError(
216
+ f'hub refused route {dest} <- {src} '
217
+ f'(destination locked?)')
218
+ # Interleaved push (someone else routing, etc.) — apply and
219
+ # keep waiting for our answer.
220
+ self._apply_block(block)
221
+
222
+ def set_input_label(self, index: int, label: str) -> None:
223
+ """Rename an input port. Empty label is allowed (clears it —
224
+ consumers fall back to 'Input N')."""
225
+ self._set_label('INPUT LABELS', self.input_labels,
226
+ self.video_inputs, index, label)
227
+
228
+ def set_output_label(self, index: int, label: str) -> None:
229
+ """Rename an output port."""
230
+ self._set_label('OUTPUT LABELS', self.output_labels,
231
+ self.video_outputs, index, label)
232
+
233
+ def _set_label(self, block: str, target: dict, count: int,
234
+ index: int, label: str) -> None:
235
+ index = int(index)
236
+ # Newlines would break the block framing; the hub caps labels
237
+ # around 64 chars — enforce both before they hit the wire.
238
+ label = ' '.join(str(label).split())
239
+ if len(label) > 64:
240
+ raise VideohubError('label too long (max 64 characters)')
241
+ if index < 0 or (count and index >= count):
242
+ raise VideohubError(f'{block.lower()} index {index} outside '
243
+ f'this hub')
244
+ self._send(f'{block}:\n{index} {label}\n\n')
245
+ deadline = time.monotonic() + self.read_timeout
246
+ while True:
247
+ resp = self._recv_block(deadline - time.monotonic())
248
+ if resp is None:
249
+ raise VideohubError(
250
+ f'timed out waiting for ACK renaming {block.lower()} '
251
+ f'{index}')
252
+ header = resp[0].strip()
253
+ if header == 'ACK':
254
+ target[index] = label
255
+ return
256
+ if header == 'NAK':
257
+ raise VideohubError(
258
+ f'hub refused label change on {block.lower()} {index}')
259
+ self._apply_block(resp)
260
+
261
+ def ping(self) -> None:
262
+ """Cheap liveness check (``PING:`` block, ACK expected)."""
263
+ self._send('PING:\n\n')
264
+ deadline = time.monotonic() + self.read_timeout
265
+ while True:
266
+ block = self._recv_block(deadline - time.monotonic())
267
+ if block is None:
268
+ raise VideohubError('timed out waiting for PING ack')
269
+ if block[0].strip() == 'ACK':
270
+ return
271
+ self._apply_block(block)
272
+
273
+ # ------------------------------------------------------------------
274
+ # Wire I/O
275
+ # ------------------------------------------------------------------
276
+
277
+ def _send(self, text: str) -> None:
278
+ if self._sock is None:
279
+ raise VideohubError('not connected')
280
+ self._sock.sendall(text.encode('utf-8'))
281
+
282
+ def _recv_block(self, timeout: float) -> Optional[List[str]]:
283
+ """Read one blank-line-terminated block. Returns the block's
284
+ lines (header first), or None if ``timeout`` elapses first."""
285
+ deadline = time.monotonic() + max(0.0, timeout)
286
+ while b'\n\n' not in self._buf:
287
+ remaining = deadline - time.monotonic()
288
+ if remaining <= 0:
289
+ return None
290
+ self._sock.settimeout(remaining)
291
+ try:
292
+ chunk = self._sock.recv(4096)
293
+ except socket.timeout:
294
+ return None
295
+ if not chunk:
296
+ raise VideohubError('connection closed by hub')
297
+ self._buf += chunk
298
+ raw, _, self._buf = self._buf.partition(b'\n\n')
299
+ return raw.decode('utf-8', 'replace').split('\n')
300
+
301
+ def _read_preamble(self) -> None:
302
+ """Consume the initial state dump.
303
+
304
+ Newer firmware terminates it with ``END PRELUDE:``; older
305
+ firmware just stops talking. Accept either: done on the marker,
306
+ or once the essential blocks have arrived and the wire has been
307
+ quiet for PREAMBLE_QUIET_S.
308
+ """
309
+ deadline = time.monotonic() + self.read_timeout
310
+ while True:
311
+ has_essentials = bool(self.device) and bool(self.routing)
312
+ block = self._recv_block(
313
+ PREAMBLE_QUIET_S if has_essentials
314
+ else deadline - time.monotonic())
315
+ if block is None:
316
+ if has_essentials:
317
+ return
318
+ raise VideohubError(
319
+ f'no state preamble from {self.host} — is this a '
320
+ f'Videohub?')
321
+ if self._apply_block(block) == 'END PRELUDE:':
322
+ return
323
+
324
+ def _drain(self) -> None:
325
+ """Apply any pushed blocks sitting in the socket, non-blocking."""
326
+ while True:
327
+ block = self._recv_block(0.05)
328
+ if block is None:
329
+ return
330
+ self._apply_block(block)
331
+
332
+ # ------------------------------------------------------------------
333
+ # Block parsing
334
+ # ------------------------------------------------------------------
335
+
336
+ def _apply_block(self, lines: List[str]) -> str:
337
+ """Apply one block to local state; returns the header line."""
338
+ header = lines[0].strip()
339
+ body = [ln for ln in lines[1:] if ln.strip()]
340
+ if header == 'PROTOCOL PREAMBLE:':
341
+ for key, val in self._kv(body):
342
+ if key == 'version':
343
+ self.protocol_version = val
344
+ elif header == 'VIDEOHUB DEVICE:':
345
+ for key, val in self._kv(body):
346
+ self.device[key] = val
347
+ elif header == 'INPUT LABELS:':
348
+ self._apply_indexed(body, self.input_labels, str)
349
+ elif header == 'OUTPUT LABELS:':
350
+ self._apply_indexed(body, self.output_labels, str)
351
+ elif header == 'VIDEO OUTPUT ROUTING:':
352
+ self._apply_indexed(body, self.routing, int)
353
+ elif header == 'VIDEO OUTPUT LOCKS:':
354
+ self._apply_indexed(body, self.locks, str)
355
+ # Everything else (CONFIGURATION:, SERIAL PORT..., MONITORING
356
+ # OUTPUT..., END PRELUDE:, ACK echoes) is deliberately ignored.
357
+ return header
358
+
359
+ @staticmethod
360
+ def _kv(body: List[str]):
361
+ for line in body:
362
+ key, sep, val = line.partition(':')
363
+ if sep:
364
+ yield key.strip().lower(), val.strip()
365
+
366
+ @staticmethod
367
+ def _apply_indexed(body: List[str], target: dict, cast) -> None:
368
+ """Parse ``<index> <value>`` lines. Values may contain spaces
369
+ (labels like ``0 CAM 1``) — only the first token is the index.
370
+ A malformed line is skipped and logged, not fatal."""
371
+ for line in body:
372
+ tok, _, rest = line.strip().partition(' ')
373
+ try:
374
+ target[int(tok)] = cast(rest.strip())
375
+ except (ValueError, TypeError):
376
+ logger.warning('videohubwire: skipping malformed line %r', line)
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: videohubwire
3
+ Version: 0.1.0.dev0
4
+ Summary: Blackmagic Videohub Ethernet Protocol client (TCP 9990): state snapshot, routing, label editing
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
+ # videohubwire
18
+
19
+ videohubwire is a small, dependency-free Python client for Blackmagic
20
+ **Videohub** routers speaking the Videohub Ethernet Protocol on TCP 9990. It
21
+ connects, parses the router's state dump into a snapshot (device info, input
22
+ and output labels, output locks, routing), routes a source to a destination
23
+ with ACK/NAK handling, renames ports, and keeps the snapshot current by
24
+ applying the update blocks the router pushes when any client changes
25
+ something. It is synchronous and single-socket by design: open, read, act,
26
+ close, in milliseconds on a LAN, which suits per-request use from a web
27
+ backend or a script.
28
+
29
+ ## Features
30
+
31
+ - `Videohub` context manager: `connect()` parses the preamble, `close()` shuts the socket, `state()` returns a UI-ready dict.
32
+ - `route(dest, src)` sends one `VIDEO OUTPUT ROUTING` change, waits for `ACK`/`NAK`, and applies any pushed blocks that arrive in between.
33
+ - `set_input_label()` / `set_output_label()` with newline collapsing and a 64-character cap before anything hits the wire.
34
+ - `ping()` liveness check.
35
+ - Output lock states (`U` / `O` / `L`) surfaced per destination; the client never takes locks.
36
+ - Bounds checks against the router's own port counts; a `NAK` or timeout raises `VideohubError`.
37
+ - Works with and without the `END PRELUDE:` marker, so old and new firmware both connect.
38
+ - One connect retry for cold-ARP first-packet loss; malformed lines are logged and skipped, not fatal.
39
+ - Pure standard library; a `socket_factory` hook lets the whole suite run without hardware.
40
+
41
+ ## Install
42
+
43
+ ```sh
44
+ pip install "git+https://github.com/lucas-romanenko/videohubwire.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 videohubwire import Videohub, VideohubError
58
+
59
+ with Videohub('192.0.2.31') as vh:
60
+ snap = vh.state()
61
+ print(snap['device']['model_name'], snap['device']['video_inputs'], 'x',
62
+ snap['device']['video_outputs'])
63
+ for out in snap['outputs']:
64
+ print(out['index'], out['label'], '<-', out['source'], out['lock'])
65
+
66
+ try:
67
+ vh.route(dest=3, src=12) # 0-based, like the wire
68
+ except VideohubError as e:
69
+ print('refused:', e) # locked destination, NAK, or timeout
70
+
71
+ vh.set_output_label(3, 'Wall Monitor 1')
72
+ ```
73
+
74
+ ## Protocol notes
75
+
76
+ Blackmagic documents the block format, the block names and the `ACK` / `NAK`
77
+ replies in the Videohub Ethernet Protocol document that ships with the
78
+ Videohub SDK; this section covers only what that document does not settle
79
+ or what this client does on top of it.
80
+
81
+ - **Preamble end detection.** Newer firmware ends the initial state dump with an `END PRELUDE:` block; older firmware simply stops sending. The client accepts either: it returns as soon as the marker arrives, or once a `VIDEOHUB DEVICE:` block and a `VIDEO OUTPUT ROUTING:` block have both been seen and the wire has been quiet for 0.3 s. A router that sends neither the marker nor a routing block is reported as "no state preamble"; that is a known limitation for an unusual device rather than a supported case.
82
+ - **Pushed updates interleave with replies.** After the preamble the router pushes the same block shapes whenever state changes from any client. Those pushes can land between a command and its `ACK`/`NAK`, so `route()`, the label setters and `ping()` apply any non-reply block they read and keep waiting for the reply. `state()` drains pending pushes with a short non-blocking read before building the snapshot.
83
+ - **Optimistic routing apply.** On `ACK` the client records the new route immediately instead of waiting for the router's own `VIDEO OUTPUT ROUTING:` broadcast, so a `state()` call right after `route()` is already correct.
84
+ - **Lock letters.** `VIDEO OUTPUT LOCKS` reports `U` (unlocked), `O` (locked by this connection) and `L` (locked by another client). This client never sends a lock command; it reports the letters so a caller can render locked destinations read-only. Routing a destination locked elsewhere returns `NAK`, which is raised as `VideohubError`.
85
+ - **Labels with spaces and empty labels.** Indexed body lines are `<index> <value>`; only the first token is the index and the rest, spaces included, is the value. A line such as `3 ` (index and nothing else) is a cleared label; `state()` substitutes `Input N` / `Output N` (1-based) for display.
86
+ - **Label limits.** The router rejects labels containing newlines because they break the block framing, and caps labels at roughly 64 characters. The client collapses all whitespace runs to single spaces and refuses labels longer than 64 characters before sending.
87
+ - **Port counts.** `video_inputs` / `video_outputs` come from the `VIDEOHUB DEVICE:` block. If those keys are absent the snapshot sizes itself from the highest label index seen. A non-numeric count in that block raises `ValueError` rather than `VideohubError`.
88
+ - **Ignored blocks.** `CONFIGURATION:`, `SERIAL PORT ...`, `MONITORING OUTPUT ...`, `VIDEO INPUT STATUS`, and any other block the client does not model are parsed past and dropped. Only `PROTOCOL PREAMBLE`, `VIDEOHUB DEVICE`, `INPUT LABELS`, `OUTPUT LABELS`, `VIDEO OUTPUT ROUTING` and `VIDEO OUTPUT LOCKS` update state.
89
+ - **Connect retry.** The first TCP connect to a router the host has not spoken to recently is sometimes lost to ARP resolution. `connect()` retries once after 0.3 s on any `OSError`; a second failure propagates unchanged.
90
+ - **Timeouts.** Connect and read timeouts default to 3 s. A read timeout while waiting for a reply raises `VideohubError`; the router closing the connection raises it too, rather than looping.
91
+ - **What the test fixture reflects.** The canned preamble in the tests has the shape of a Smart Videohub 40 x 40 reporting protocol version 2.7 (device block, labels, locks, routing, optional marker). Other models were not exercised.
92
+
93
+ ## License
94
+
95
+ MIT. See `LICENSE`.
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ videohubwire/__init__.py
5
+ videohubwire/client.py
6
+ videohubwire.egg-info/PKG-INFO
7
+ videohubwire.egg-info/SOURCES.txt
8
+ videohubwire.egg-info/dependency_links.txt
9
+ videohubwire.egg-info/requires.txt
10
+ videohubwire.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+
2
+ [test]
3
+ pytest
@@ -0,0 +1 @@
1
+ videohubwire