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.
- videohubwire-0.1.0.dev0/LICENSE +21 -0
- videohubwire-0.1.0.dev0/PKG-INFO +95 -0
- videohubwire-0.1.0.dev0/README.md +79 -0
- videohubwire-0.1.0.dev0/pyproject.toml +25 -0
- videohubwire-0.1.0.dev0/setup.cfg +4 -0
- videohubwire-0.1.0.dev0/videohubwire/__init__.py +16 -0
- videohubwire-0.1.0.dev0/videohubwire/client.py +376 -0
- videohubwire-0.1.0.dev0/videohubwire.egg-info/PKG-INFO +95 -0
- videohubwire-0.1.0.dev0/videohubwire.egg-info/SOURCES.txt +10 -0
- videohubwire-0.1.0.dev0/videohubwire.egg-info/dependency_links.txt +1 -0
- videohubwire-0.1.0.dev0/videohubwire.egg-info/requires.txt +3 -0
- videohubwire-0.1.0.dev0/videohubwire.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucas Romanenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
videohubwire
|