bmdwire 1.1.0__tar.gz → 1.2.0__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.
- {bmdwire-1.1.0/bmdwire.egg-info → bmdwire-1.2.0}/PKG-INFO +4 -4
- {bmdwire-1.1.0 → bmdwire-1.2.0}/README.md +3 -3
- {bmdwire-1.1.0 → bmdwire-1.2.0/bmdwire.egg-info}/PKG-INFO +4 -4
- {bmdwire-1.1.0 → bmdwire-1.2.0}/bmdwire.egg-info/SOURCES.txt +1 -0
- bmdwire-1.2.0/hyperdeckwire/__init__.py +74 -0
- bmdwire-1.2.0/hyperdeckwire/setup.py +660 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/pyproject.toml +1 -1
- bmdwire-1.1.0/hyperdeckwire/__init__.py +0 -46
- {bmdwire-1.1.0 → bmdwire-1.2.0}/LICENSE +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/LICENSE +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/LICENSE-gpl3.txt +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/NOTICE +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/__init__.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/_socketqueue.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/_transfer.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/atem.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/connection.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/docs/MACRO_FORMAT.md +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/docs/MEDIA_LOCK.md +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/docs/PROFILE_FORMAT.md +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/docs/PROFILE_SAVE_RESTORE.md +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/helpers.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/imaging.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/__init__.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/_helpers.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/_io.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/downstream_keyer.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/fairlight.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/flow.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/hyperdeck.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/media.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/switching.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/upstream_keyer.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/mediaconvertmodule.c +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/__init__.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/_dsl.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/audio_legacy.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/camera_control.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/color_generator.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/downstream_keyer.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/fade_to_black.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/fairlight.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/file_transfer.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/hyperdeck.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/input_video.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/lock.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/macros.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/manual.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/media.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/meters.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/multiviewer.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/recording.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/streaming.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/supersource.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/switching.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/system_info.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/tally.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/transition.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/upstream_keyer.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/pool.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/probe.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/profile/__init__.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/profile/_common.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/profile/_enums.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/profile/_xml.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/profile/apply.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/profile/options.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/profile/save.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/protocol.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/py.typed +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/ready.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/state.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/testing.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/transport.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/bmdwire.egg-info/dependency_links.txt +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/bmdwire.egg-info/requires.txt +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/bmdwire.egg-info/top_level.txt +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/hyperdeckwire/LICENSE +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/hyperdeckwire/client.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/hyperdeckwire/upload.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/setup.cfg +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/setup.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/LICENSE +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/__init__.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/_preamble.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/_protocol.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/network.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/profile/__init__.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/profile/_ranges.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/profile/archive.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/profile/restore.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/videohubwire/LICENSE +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/videohubwire/__init__.py +0 -0
- {bmdwire-1.1.0 → bmdwire-1.2.0}/videohubwire/client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bmdwire
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Python libraries for Blackmagic Design broadcast devices: ATEM switchers, HyperDeck recorders, Ultimatte keyers and Videohub routers
|
|
5
5
|
Author: Lucas Romanenko, Martijn Braam and the OpenAtem contributors
|
|
6
6
|
License-Expression: LGPL-3.0-only AND MIT
|
|
@@ -45,11 +45,11 @@ pip install bmdwire
|
|
|
45
45
|
| Import | Purpose | License | Docs |
|
|
46
46
|
|---|---|---|---|
|
|
47
47
|
| **`atemwire`** | ATEM switchers: the native UDP protocol, a connection pool, macro bytecode, "Save Switcher State" profiles | LGPL-3.0-only | [atemwire/README.md](atemwire/README.md) |
|
|
48
|
-
| **`hyperdeckwire`** | HyperDeck recorders: transport control and clip listing over TCP 9993, clip upload over FTP | MIT | [hyperdeckwire/README.md](hyperdeckwire/README.md) |
|
|
48
|
+
| **`hyperdeckwire`** | HyperDeck recorders: transport control and clip listing over TCP 9993, clip upload over FTP, and the deck's configuration (network, FTP / Web Media Manager / Ethernet Protocol switches, certificate, users, time) over the API HyperDeck Setup drives | MIT | [hyperdeckwire/README.md](hyperdeckwire/README.md) |
|
|
49
49
|
| **`ultimattewire`** | Ultimatte 12 keyers: archive and restore over the native TCP protocol, Smart Remote 4 compatible zips | MIT | [ultimattewire/README.md](ultimattewire/README.md) |
|
|
50
50
|
| **`videohubwire`** | Videohub routers: state snapshot, crosspoint routing, labels over TCP 9990 | MIT | [videohubwire/README.md](videohubwire/README.md) |
|
|
51
51
|
|
|
52
|
-
All four are pure standard library except atemwire, whose small C extension ships prebuilt for Linux, macOS and Windows on CPython 3.10 to 3.14, so installing needs no compiler. Pin the version in a requirements file (`bmdwire==1.
|
|
52
|
+
All four are pure standard library except atemwire, whose small C extension ships prebuilt for Linux, macOS and Windows on CPython 3.10 to 3.14, so installing needs no compiler. Pin the version in a requirements file (`bmdwire==1.2.0`) so a later release cannot change your install under you.
|
|
53
53
|
|
|
54
54
|
## atemwire stands on pyatem
|
|
55
55
|
|
|
@@ -74,7 +74,7 @@ If you want the original rather than this fork, it is at
|
|
|
74
74
|
|
|
75
75
|
The four libraries share an author, a shape and a purpose: they are the device layer of one broadcast control application, extracted and published so others can use them. Until 1.0.x each was its own PyPI project with its own version number. Nothing downstream ever wanted them apart, and four numbers to track for one release train was confusion with no benefit, so since 1.1.0 they are one distribution: one tag, one version, one line to pin. The import names did not change.
|
|
76
76
|
|
|
77
|
-
The four old
|
|
77
|
+
The four old PyPI projects (`atemwire`, `hyperdeckwire`, `ultimattewire`, `videohubwire`) were deleted on 2026-09-16; bmdwire is the only package. They were not replaced by shims, on purpose: a release of `atemwire` that depended on bmdwire would have broken `pip install -U atemwire`, because pip installs the dependency first and then removes the old package's files, which are the same paths bmdwire just wrote. **If an environment still has any of the four old packages from before, uninstall them before installing bmdwire** (`pip uninstall atemwire hyperdeckwire ultimattewire videohubwire`). Two distributions owning one module directory work until one of them is uninstalled. A fresh virtualenv, a pipx install or a container build never has this problem.
|
|
78
78
|
|
|
79
79
|
## Development
|
|
80
80
|
|
|
@@ -11,11 +11,11 @@ pip install bmdwire
|
|
|
11
11
|
| Import | Purpose | License | Docs |
|
|
12
12
|
|---|---|---|---|
|
|
13
13
|
| **`atemwire`** | ATEM switchers: the native UDP protocol, a connection pool, macro bytecode, "Save Switcher State" profiles | LGPL-3.0-only | [atemwire/README.md](atemwire/README.md) |
|
|
14
|
-
| **`hyperdeckwire`** | HyperDeck recorders: transport control and clip listing over TCP 9993, clip upload over FTP | MIT | [hyperdeckwire/README.md](hyperdeckwire/README.md) |
|
|
14
|
+
| **`hyperdeckwire`** | HyperDeck recorders: transport control and clip listing over TCP 9993, clip upload over FTP, and the deck's configuration (network, FTP / Web Media Manager / Ethernet Protocol switches, certificate, users, time) over the API HyperDeck Setup drives | MIT | [hyperdeckwire/README.md](hyperdeckwire/README.md) |
|
|
15
15
|
| **`ultimattewire`** | Ultimatte 12 keyers: archive and restore over the native TCP protocol, Smart Remote 4 compatible zips | MIT | [ultimattewire/README.md](ultimattewire/README.md) |
|
|
16
16
|
| **`videohubwire`** | Videohub routers: state snapshot, crosspoint routing, labels over TCP 9990 | MIT | [videohubwire/README.md](videohubwire/README.md) |
|
|
17
17
|
|
|
18
|
-
All four are pure standard library except atemwire, whose small C extension ships prebuilt for Linux, macOS and Windows on CPython 3.10 to 3.14, so installing needs no compiler. Pin the version in a requirements file (`bmdwire==1.
|
|
18
|
+
All four are pure standard library except atemwire, whose small C extension ships prebuilt for Linux, macOS and Windows on CPython 3.10 to 3.14, so installing needs no compiler. Pin the version in a requirements file (`bmdwire==1.2.0`) so a later release cannot change your install under you.
|
|
19
19
|
|
|
20
20
|
## atemwire stands on pyatem
|
|
21
21
|
|
|
@@ -40,7 +40,7 @@ If you want the original rather than this fork, it is at
|
|
|
40
40
|
|
|
41
41
|
The four libraries share an author, a shape and a purpose: they are the device layer of one broadcast control application, extracted and published so others can use them. Until 1.0.x each was its own PyPI project with its own version number. Nothing downstream ever wanted them apart, and four numbers to track for one release train was confusion with no benefit, so since 1.1.0 they are one distribution: one tag, one version, one line to pin. The import names did not change.
|
|
42
42
|
|
|
43
|
-
The four old
|
|
43
|
+
The four old PyPI projects (`atemwire`, `hyperdeckwire`, `ultimattewire`, `videohubwire`) were deleted on 2026-09-16; bmdwire is the only package. They were not replaced by shims, on purpose: a release of `atemwire` that depended on bmdwire would have broken `pip install -U atemwire`, because pip installs the dependency first and then removes the old package's files, which are the same paths bmdwire just wrote. **If an environment still has any of the four old packages from before, uninstall them before installing bmdwire** (`pip uninstall atemwire hyperdeckwire ultimattewire videohubwire`). Two distributions owning one module directory work until one of them is uninstalled. A fresh virtualenv, a pipx install or a container build never has this problem.
|
|
44
44
|
|
|
45
45
|
## Development
|
|
46
46
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bmdwire
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Python libraries for Blackmagic Design broadcast devices: ATEM switchers, HyperDeck recorders, Ultimatte keyers and Videohub routers
|
|
5
5
|
Author: Lucas Romanenko, Martijn Braam and the OpenAtem contributors
|
|
6
6
|
License-Expression: LGPL-3.0-only AND MIT
|
|
@@ -45,11 +45,11 @@ pip install bmdwire
|
|
|
45
45
|
| Import | Purpose | License | Docs |
|
|
46
46
|
|---|---|---|---|
|
|
47
47
|
| **`atemwire`** | ATEM switchers: the native UDP protocol, a connection pool, macro bytecode, "Save Switcher State" profiles | LGPL-3.0-only | [atemwire/README.md](atemwire/README.md) |
|
|
48
|
-
| **`hyperdeckwire`** | HyperDeck recorders: transport control and clip listing over TCP 9993, clip upload over FTP | MIT | [hyperdeckwire/README.md](hyperdeckwire/README.md) |
|
|
48
|
+
| **`hyperdeckwire`** | HyperDeck recorders: transport control and clip listing over TCP 9993, clip upload over FTP, and the deck's configuration (network, FTP / Web Media Manager / Ethernet Protocol switches, certificate, users, time) over the API HyperDeck Setup drives | MIT | [hyperdeckwire/README.md](hyperdeckwire/README.md) |
|
|
49
49
|
| **`ultimattewire`** | Ultimatte 12 keyers: archive and restore over the native TCP protocol, Smart Remote 4 compatible zips | MIT | [ultimattewire/README.md](ultimattewire/README.md) |
|
|
50
50
|
| **`videohubwire`** | Videohub routers: state snapshot, crosspoint routing, labels over TCP 9990 | MIT | [videohubwire/README.md](videohubwire/README.md) |
|
|
51
51
|
|
|
52
|
-
All four are pure standard library except atemwire, whose small C extension ships prebuilt for Linux, macOS and Windows on CPython 3.10 to 3.14, so installing needs no compiler. Pin the version in a requirements file (`bmdwire==1.
|
|
52
|
+
All four are pure standard library except atemwire, whose small C extension ships prebuilt for Linux, macOS and Windows on CPython 3.10 to 3.14, so installing needs no compiler. Pin the version in a requirements file (`bmdwire==1.2.0`) so a later release cannot change your install under you.
|
|
53
53
|
|
|
54
54
|
## atemwire stands on pyatem
|
|
55
55
|
|
|
@@ -74,7 +74,7 @@ If you want the original rather than this fork, it is at
|
|
|
74
74
|
|
|
75
75
|
The four libraries share an author, a shape and a purpose: they are the device layer of one broadcast control application, extracted and published so others can use them. Until 1.0.x each was its own PyPI project with its own version number. Nothing downstream ever wanted them apart, and four numbers to track for one release train was confusion with no benefit, so since 1.1.0 they are one distribution: one tag, one version, one line to pin. The import names did not change.
|
|
76
76
|
|
|
77
|
-
The four old
|
|
77
|
+
The four old PyPI projects (`atemwire`, `hyperdeckwire`, `ultimattewire`, `videohubwire`) were deleted on 2026-09-16; bmdwire is the only package. They were not replaced by shims, on purpose: a release of `atemwire` that depended on bmdwire would have broken `pip install -U atemwire`, because pip installs the dependency first and then removes the old package's files, which are the same paths bmdwire just wrote. **If an environment still has any of the four old packages from before, uninstall them before installing bmdwire** (`pip uninstall atemwire hyperdeckwire ultimattewire videohubwire`). Two distributions owning one module directory work until one of them is uninstalled. A fresh virtualenv, a pipx install or a container build never has this problem.
|
|
78
78
|
|
|
79
79
|
## Development
|
|
80
80
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""hyperdeckwire — Blackmagic HyperDeck Studio control library.
|
|
3
|
+
|
|
4
|
+
Three pieces, one per channel the deck offers:
|
|
5
|
+
|
|
6
|
+
* ``Hyperdeck`` — the HyperDeck Ethernet Protocol (TCP 9993, text /
|
|
7
|
+
line-oriented): transport, clips, timeline, codec configuration.
|
|
8
|
+
* ``upload_clip`` — FTP file upload onto the deck's storage.
|
|
9
|
+
* ``HyperdeckSetup`` — the deck's configuration API on its HTTP port, the
|
|
10
|
+
one HyperDeck Setup drives: name, network, the FTP / Web Media Manager /
|
|
11
|
+
Ethernet Protocol switches, certificate, users, date and time, reboot.
|
|
12
|
+
None of those settings exist in the 9993 protocol.
|
|
13
|
+
|
|
14
|
+
Playback and upload stay on 9993 + FTP on purpose: every networked
|
|
15
|
+
HyperDeck has them, while the HTTP media/transport REST API of firmware
|
|
16
|
+
8.x is only on the Plus/Pro/HDR/Shuttle models. The configuration API is
|
|
17
|
+
on the Studio HD Mini too (checked on 9.0.2).
|
|
18
|
+
|
|
19
|
+
The 9993 protocol is documented in BMD's
|
|
20
|
+
``HyperDeckEthernetProtocol.pdf`` (December 2024).
|
|
21
|
+
|
|
22
|
+
Public surface::
|
|
23
|
+
|
|
24
|
+
from hyperdeckwire import Hyperdeck, upload_clip
|
|
25
|
+
|
|
26
|
+
# Control:
|
|
27
|
+
with Hyperdeck('192.0.2.11') as hd:
|
|
28
|
+
info = hd.device_info() # dict
|
|
29
|
+
clips = hd.disk_list() # List[Clip]
|
|
30
|
+
hd.stop()
|
|
31
|
+
hd.clips_clear()
|
|
32
|
+
hd.clips_add('my-clip.mp4')
|
|
33
|
+
hd.play(loop=True, single_clip=True)
|
|
34
|
+
|
|
35
|
+
# File upload:
|
|
36
|
+
result = upload_clip('192.0.2.11', '/path/to/clip.mp4')
|
|
37
|
+
print(result.throughput_mb_s)
|
|
38
|
+
|
|
39
|
+
# Configuration (what HyperDeck Setup shows):
|
|
40
|
+
deck = HyperdeckSetup('192.0.2.11')
|
|
41
|
+
deck.network_access() # {'FTP': 'Enabled', 'HTTP': 'Enabled'}
|
|
42
|
+
deck.set_network(netmask='255.255.248.0')
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from hyperdeckwire.client import (
|
|
46
|
+
Clip,
|
|
47
|
+
Hyperdeck,
|
|
48
|
+
HyperdeckError,
|
|
49
|
+
Response,
|
|
50
|
+
)
|
|
51
|
+
from hyperdeckwire.setup import (
|
|
52
|
+
HyperdeckSetup,
|
|
53
|
+
HyperdeckSetupError,
|
|
54
|
+
NetworkInterface,
|
|
55
|
+
RemoteAdmin,
|
|
56
|
+
SetupInfo,
|
|
57
|
+
User,
|
|
58
|
+
)
|
|
59
|
+
from hyperdeckwire.upload import UploadResult, upload_clip
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
'Clip',
|
|
63
|
+
'Hyperdeck',
|
|
64
|
+
'HyperdeckError',
|
|
65
|
+
'HyperdeckSetup',
|
|
66
|
+
'HyperdeckSetupError',
|
|
67
|
+
'NetworkInterface',
|
|
68
|
+
'RemoteAdmin',
|
|
69
|
+
'Response',
|
|
70
|
+
'SetupInfo',
|
|
71
|
+
'UploadResult',
|
|
72
|
+
'User',
|
|
73
|
+
'upload_clip',
|
|
74
|
+
]
|
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""hyperdeckwire.setup — the deck's configuration API, the one HyperDeck Setup drives.
|
|
3
|
+
|
|
4
|
+
Everything HyperDeck Setup shows for a networked deck (name, IP address,
|
|
5
|
+
netmask, gateway, DNS, the FTP and Web Media Manager switches, the
|
|
6
|
+
HyperDeck Ethernet Protocol switch, the certificate, users, date and time,
|
|
7
|
+
reboot) lives on a REST API on the deck's HTTP port, at
|
|
8
|
+
``http://<deck>/admin/api/v1/``. HyperDeck Setup is a browser onto
|
|
9
|
+
``http://<deck>/admin/``; over USB it reaches the same API through the
|
|
10
|
+
cable. So anything the app can change, this module can change from the
|
|
11
|
+
network, which is what you want for a deck in a rack across the building.
|
|
12
|
+
|
|
13
|
+
None of these settings exist in the 9993 Ethernet Protocol: its
|
|
14
|
+
``configuration:`` verb covers codec, inputs and timecode and nothing about
|
|
15
|
+
the network. That is why this is a separate module from :mod:`.client`,
|
|
16
|
+
with a separate connection (one HTTP request per call, nothing held open).
|
|
17
|
+
|
|
18
|
+
Measured on two HyperDeck Studio HD Mini units on software 9.0.2 (September
|
|
19
|
+
2026). The API is unauthenticated on the LAN, the same trust model as 9993
|
|
20
|
+
and FTP, and it is the same API family the ATEM and Videohub setup apps use,
|
|
21
|
+
so the network endpoints carry the same shapes.
|
|
22
|
+
|
|
23
|
+
Two facts to know before writing anything:
|
|
24
|
+
|
|
25
|
+
* **"Configure via USB and Ethernet" is read-only from here.** While that
|
|
26
|
+
switch is off the deck answers every GET and refuses every PUT with HTTP
|
|
27
|
+
401, and the PUT that would turn it on is one of the refused ones. It can
|
|
28
|
+
only be turned on over USB. :meth:`HyperdeckSetup.remote_admin` reads it so
|
|
29
|
+
a caller can explain a 401 before trying; there is no setter because one
|
|
30
|
+
could never succeed, and turning it OFF over Ethernet would cut off the
|
|
31
|
+
channel doing the turning.
|
|
32
|
+
* **Nothing here is believed on the strength of a 200.** Every setter reads
|
|
33
|
+
the setting back and raises :class:`HyperdeckSetupError` if the deck
|
|
34
|
+
holds something else. A network change is read back until the interface
|
|
35
|
+
is live on the new values (the deck reapplies for about a second).
|
|
36
|
+
|
|
37
|
+
No policy here: :meth:`HyperdeckSetup.set_network` writes what it is told.
|
|
38
|
+
Whether re-addressing a deck in a rack is safe belongs to the caller.
|
|
39
|
+
|
|
40
|
+
Public surface::
|
|
41
|
+
|
|
42
|
+
from hyperdeckwire import HyperdeckSetup
|
|
43
|
+
|
|
44
|
+
deck = HyperdeckSetup('192.0.2.11')
|
|
45
|
+
info = deck.setup_basic() # SetupInfo: product, name, software ...
|
|
46
|
+
net = deck.network() # NetworkInterface: address, netmask ...
|
|
47
|
+
deck.network_access() # {'FTP': 'Enabled', 'HTTP': 'Enabled'}
|
|
48
|
+
deck.ethernet_protocol() # 'Enabled' (TCP 9993 on)
|
|
49
|
+
|
|
50
|
+
deck.set_network_access(ftp='Enabled')
|
|
51
|
+
deck.set_ethernet_protocol('Enabled')
|
|
52
|
+
deck.set_network(netmask='255.255.248.0') # widens; address/gateway kept
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import json
|
|
58
|
+
import logging
|
|
59
|
+
import time
|
|
60
|
+
import urllib.error
|
|
61
|
+
import urllib.request
|
|
62
|
+
from dataclasses import dataclass, field
|
|
63
|
+
from typing import Any, Dict, List, Optional
|
|
64
|
+
|
|
65
|
+
logger = logging.getLogger(__name__)
|
|
66
|
+
|
|
67
|
+
DEFAULT_TIMEOUT = 4.0 # one HTTP call; the deck answers in milliseconds
|
|
68
|
+
READBACK_TIMEOUT = 20.0 # a network change reapplies the interface
|
|
69
|
+
READBACK_POLL = 0.5
|
|
70
|
+
API_BASE = '/admin/api/v1'
|
|
71
|
+
|
|
72
|
+
# The three states a network-access protocol can be in (``/networkAccess``
|
|
73
|
+
# and ``/hyperDeckEthernetProtocol`` share the enum). ``SecureOnly`` is
|
|
74
|
+
# only meaningful where the deck offers a secure variant: HTTP (HTTPS with
|
|
75
|
+
# the deck's certificate) does, FTP does not — see ``network_access_options``.
|
|
76
|
+
DISABLED = 'Disabled'
|
|
77
|
+
ENABLED = 'Enabled'
|
|
78
|
+
SECURE_ONLY = 'SecureOnly'
|
|
79
|
+
ACCESS_STATES = (DISABLED, ENABLED, SECURE_ONLY)
|
|
80
|
+
|
|
81
|
+
FTP = 'FTP'
|
|
82
|
+
HTTP = 'HTTP' # the Web Media Manager switch
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class HyperdeckSetupError(Exception):
|
|
86
|
+
"""The deck refused a request, or a setting did not read back as written.
|
|
87
|
+
|
|
88
|
+
``status`` is the HTTP status when the deck refused (401 = "Configure via
|
|
89
|
+
USB and Ethernet" is off), ``None`` for a read-back mismatch or a
|
|
90
|
+
``success: false`` body.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, message: str, *, status: Optional[int] = None, path: str = ''):
|
|
94
|
+
self.status = status
|
|
95
|
+
self.path = path
|
|
96
|
+
super().__init__(message)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class SetupInfo:
|
|
101
|
+
"""``GET /setupBasic``: what the deck says about itself."""
|
|
102
|
+
|
|
103
|
+
product_name: str
|
|
104
|
+
device_name: str
|
|
105
|
+
hostname: str
|
|
106
|
+
software: str
|
|
107
|
+
build: str = ''
|
|
108
|
+
hardware: str = ''
|
|
109
|
+
language: str = ''
|
|
110
|
+
product_id: str = ''
|
|
111
|
+
short_product_name: str = ''
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class RemoteAdmin:
|
|
116
|
+
"""``GET /remoteAdmin``: the "Configure via USB and Ethernet" switch.
|
|
117
|
+
|
|
118
|
+
``enabled`` False means every PUT from the network is refused (401).
|
|
119
|
+
``usb_request_source`` says whether THIS request arrived over USB.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
enabled: bool
|
|
123
|
+
usb_request_source: bool
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class NetworkInterface:
|
|
128
|
+
"""One network interface as the deck reports it.
|
|
129
|
+
|
|
130
|
+
``address`` / ``netmask`` / ``gateway`` / ``dns`` are the CONFIGURED
|
|
131
|
+
(manual) values; the ``active_*`` fields are what the interface is
|
|
132
|
+
RUNNING. They differ on DHCP, and for about a second after a static
|
|
133
|
+
change while the interface reapplies. ``dhcp`` is None when the deck
|
|
134
|
+
reports a type this module does not know.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
id: str = '0'
|
|
138
|
+
name: str = ''
|
|
139
|
+
mac: str = ''
|
|
140
|
+
hostname: str = ''
|
|
141
|
+
dhcp: Optional[bool] = None
|
|
142
|
+
address: str = ''
|
|
143
|
+
netmask: str = ''
|
|
144
|
+
gateway: str = ''
|
|
145
|
+
dns: List[str] = field(default_factory=list)
|
|
146
|
+
active_address: str = ''
|
|
147
|
+
active_netmask: str = ''
|
|
148
|
+
active_gateway: str = ''
|
|
149
|
+
active_dns: List[str] = field(default_factory=list)
|
|
150
|
+
linked: Optional[bool] = None
|
|
151
|
+
speed_bps: Optional[int] = None
|
|
152
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def settled(self) -> bool:
|
|
156
|
+
"""Is the interface live on what it is configured with?
|
|
157
|
+
|
|
158
|
+
For DHCP that is "has an address". For static it is the running
|
|
159
|
+
address, netmask and gateway equalling the configured ones; while the
|
|
160
|
+
deck reapplies, the running side lags.
|
|
161
|
+
"""
|
|
162
|
+
if self.dhcp:
|
|
163
|
+
return bool(self.active_address)
|
|
164
|
+
return bool(self.active_address) and (
|
|
165
|
+
self.active_address == self.address
|
|
166
|
+
and self.active_netmask == self.netmask
|
|
167
|
+
and self.active_gateway == self.gateway)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True)
|
|
171
|
+
class User:
|
|
172
|
+
"""One entry of ``GET /auth/users``. A fresh deck has only ``Guest``."""
|
|
173
|
+
|
|
174
|
+
auth_user_id: str
|
|
175
|
+
username: str
|
|
176
|
+
remote_admin_utility_access: bool = False
|
|
177
|
+
shared_folder_permissions: List[Dict[str, Any]] = field(default_factory=list)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def parse_interface(payload: Dict[str, Any]) -> NetworkInterface:
|
|
181
|
+
"""Parse a ``/network/interface/{id}`` response body (the object holding
|
|
182
|
+
``interface`` and ``hostname``) into a :class:`NetworkInterface`."""
|
|
183
|
+
iface = payload.get('interface') or {}
|
|
184
|
+
manual = iface.get('manualConfig') or {}
|
|
185
|
+
active = iface.get('activeConfig') or {}
|
|
186
|
+
kind = (iface.get('type') or '').lower()
|
|
187
|
+
dhcp = True if kind == 'dhcp' else False if kind == 'static' else None
|
|
188
|
+
linked = iface.get('physicallyLinked')
|
|
189
|
+
speed = iface.get('currentSpeedBps')
|
|
190
|
+
return NetworkInterface(
|
|
191
|
+
id=str(iface.get('id', '0')),
|
|
192
|
+
name=iface.get('name', ''),
|
|
193
|
+
mac=iface.get('macAddress', ''),
|
|
194
|
+
hostname=payload.get('hostname', ''),
|
|
195
|
+
dhcp=dhcp,
|
|
196
|
+
address=manual.get('address', ''),
|
|
197
|
+
netmask=manual.get('netmask', ''),
|
|
198
|
+
gateway=manual.get('gateway', ''),
|
|
199
|
+
dns=list(manual.get('dnsServers') or []),
|
|
200
|
+
active_address=active.get('address', ''),
|
|
201
|
+
active_netmask=active.get('netmask', ''),
|
|
202
|
+
active_gateway=active.get('gateway', ''),
|
|
203
|
+
active_dns=list(active.get('dnsServers') or []),
|
|
204
|
+
linked=None if linked is None else bool(linked),
|
|
205
|
+
speed_bps=None if speed is None else int(speed),
|
|
206
|
+
raw=payload,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _check_state(state: str) -> str:
|
|
211
|
+
if state not in ACCESS_STATES:
|
|
212
|
+
raise ValueError(f'{state!r} is not one of {ACCESS_STATES}')
|
|
213
|
+
return state
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class HyperdeckSetup:
|
|
217
|
+
"""Client for one deck's configuration API.
|
|
218
|
+
|
|
219
|
+
Stateless: each method is one or a few HTTP requests. ``timeout`` is
|
|
220
|
+
per request. ``opener`` is a test hook, an object with
|
|
221
|
+
``open(request, timeout=...)`` returning a response with ``read()`` and
|
|
222
|
+
``status``; the default is :func:`urllib.request.build_opener`.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
def __init__(self, host: str, *, timeout: float = DEFAULT_TIMEOUT,
|
|
226
|
+
scheme: str = 'http', opener=None):
|
|
227
|
+
self.host = host
|
|
228
|
+
self.timeout = timeout
|
|
229
|
+
self.scheme = scheme
|
|
230
|
+
self._opener = opener or urllib.request.build_opener()
|
|
231
|
+
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
# Transport
|
|
234
|
+
# ------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def _url(self, path: str) -> str:
|
|
237
|
+
return f'{self.scheme}://{self.host}{API_BASE}{path}'
|
|
238
|
+
|
|
239
|
+
def _raw(self, method: str, path: str, body: Optional[Dict[str, Any]] = None,
|
|
240
|
+
*, timeout: Optional[float] = None) -> bytes:
|
|
241
|
+
"""One request. Raises :class:`HyperdeckSetupError` on a non-2xx
|
|
242
|
+
status; connection-level failures propagate as ``OSError``
|
|
243
|
+
(``urllib.error.URLError`` is one)."""
|
|
244
|
+
data = None
|
|
245
|
+
headers = {'Accept': 'application/json'}
|
|
246
|
+
if body is not None:
|
|
247
|
+
data = json.dumps(body).encode('utf-8')
|
|
248
|
+
headers['Content-Type'] = 'application/json'
|
|
249
|
+
req = urllib.request.Request(self._url(path), data=data, method=method,
|
|
250
|
+
headers=headers)
|
|
251
|
+
try:
|
|
252
|
+
with self._opener.open(req, timeout=timeout or self.timeout) as resp:
|
|
253
|
+
return resp.read()
|
|
254
|
+
except urllib.error.HTTPError as e:
|
|
255
|
+
detail = ''
|
|
256
|
+
try:
|
|
257
|
+
detail = e.read().decode('utf-8', errors='replace').strip()
|
|
258
|
+
except Exception: # noqa: BLE001
|
|
259
|
+
pass
|
|
260
|
+
if e.code == 401:
|
|
261
|
+
raise HyperdeckSetupError(
|
|
262
|
+
f'{self.host}: {method} {path} refused (HTTP 401): "Configure via '
|
|
263
|
+
f'USB and Ethernet" is off on this deck; turn it on in HyperDeck '
|
|
264
|
+
f'Setup over USB first', status=401, path=path) from None
|
|
265
|
+
raise HyperdeckSetupError(
|
|
266
|
+
f'{self.host}: {method} {path} failed (HTTP {e.code}) {detail[:200]}'.rstrip(),
|
|
267
|
+
status=e.code, path=path) from None
|
|
268
|
+
|
|
269
|
+
def _call(self, method: str, path: str, body: Optional[Dict[str, Any]] = None,
|
|
270
|
+
*, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
271
|
+
"""One request, JSON in and out. A body with ``success: false`` is
|
|
272
|
+
the deck saying no — raised with its ``errorMessage``."""
|
|
273
|
+
raw = self._raw(method, path, body, timeout=timeout)
|
|
274
|
+
if not raw.strip():
|
|
275
|
+
return {}
|
|
276
|
+
try:
|
|
277
|
+
parsed = json.loads(raw)
|
|
278
|
+
except ValueError:
|
|
279
|
+
raise HyperdeckSetupError(
|
|
280
|
+
f'{self.host}: {method} {path} answered something that is not JSON: '
|
|
281
|
+
f'{raw[:120]!r}', path=path) from None
|
|
282
|
+
if isinstance(parsed, dict) and parsed.get('success') is False:
|
|
283
|
+
raise HyperdeckSetupError(
|
|
284
|
+
f'{self.host}: {method} {path} refused: '
|
|
285
|
+
f'{parsed.get("errorMessage") or parsed}', path=path)
|
|
286
|
+
return parsed if isinstance(parsed, dict) else {'response': parsed}
|
|
287
|
+
|
|
288
|
+
def _get(self, path: str) -> Dict[str, Any]:
|
|
289
|
+
return self._call('GET', path).get('response', {}) or {}
|
|
290
|
+
|
|
291
|
+
def _verify(self, what: str, wanted: Any, got: Any) -> None:
|
|
292
|
+
if wanted != got:
|
|
293
|
+
raise HyperdeckSetupError(
|
|
294
|
+
f'{self.host}: wrote {what} = {wanted!r} but the deck reads {got!r} '
|
|
295
|
+
f'— CHECK THIS DECK')
|
|
296
|
+
|
|
297
|
+
# ------------------------------------------------------------------
|
|
298
|
+
# Identity
|
|
299
|
+
# ------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
def heartbeat(self) -> bool:
|
|
302
|
+
"""Is the configuration API answering? True, or raises."""
|
|
303
|
+
return bool(self._call('GET', '/heartBeat').get('success', True))
|
|
304
|
+
|
|
305
|
+
def capabilities(self) -> Dict[str, Any]:
|
|
306
|
+
"""``GET /capabilities``: which API sections this firmware has
|
|
307
|
+
(``networkAccess``, ``hyperDeckEthernetProtocol``, ``certificate``…),
|
|
308
|
+
each with its version. The map is the response object itself; its
|
|
309
|
+
``capabilities`` entry is this section's own version, not a nesting."""
|
|
310
|
+
return self._get('/capabilities')
|
|
311
|
+
|
|
312
|
+
def setup_basic(self) -> SetupInfo:
|
|
313
|
+
r = self._get('/setupBasic')
|
|
314
|
+
return SetupInfo(
|
|
315
|
+
product_name=r.get('productName', ''),
|
|
316
|
+
device_name=r.get('deviceName', ''),
|
|
317
|
+
hostname=r.get('hostname', ''),
|
|
318
|
+
software=r.get('software', ''),
|
|
319
|
+
build=r.get('build', ''),
|
|
320
|
+
hardware=r.get('hardware', ''),
|
|
321
|
+
language=r.get('language', ''),
|
|
322
|
+
product_id=r.get('productId', ''),
|
|
323
|
+
short_product_name=r.get('shortProductName', ''),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def set_name(self, name: str) -> SetupInfo:
|
|
327
|
+
"""Set the deck's name (the one 9993 ``device info`` also reports).
|
|
328
|
+
The deck derives its mDNS hostname from it, which invalidates a
|
|
329
|
+
certificate issued for the old hostname."""
|
|
330
|
+
self._call('PUT', '/setupBasic/name', {'name': name})
|
|
331
|
+
info = self.setup_basic()
|
|
332
|
+
self._verify('name', name, info.device_name)
|
|
333
|
+
return info
|
|
334
|
+
|
|
335
|
+
def languages(self) -> List[Dict[str, str]]:
|
|
336
|
+
"""``[{'code': 'en_US.UTF-8', 'name': 'English'}, …]``"""
|
|
337
|
+
return list(self._get('/languages').get('languages') or [])
|
|
338
|
+
|
|
339
|
+
def set_language(self, code: str) -> str:
|
|
340
|
+
self._call('PUT', '/setupBasic/language', {'language': code})
|
|
341
|
+
got = self.setup_basic().language
|
|
342
|
+
self._verify('language', code, got)
|
|
343
|
+
return got
|
|
344
|
+
|
|
345
|
+
def identify(self, on: bool = True) -> None:
|
|
346
|
+
"""Flash the deck's front panel so someone can find it in a rack
|
|
347
|
+
(``on=False`` stops it). The same button HyperDeck Setup has."""
|
|
348
|
+
self._call('PUT' if on else 'DELETE', '/setupBasic/identify')
|
|
349
|
+
|
|
350
|
+
def remote_admin(self) -> RemoteAdmin:
|
|
351
|
+
"""The "Configure via USB and Ethernet" switch. READ ONLY from the
|
|
352
|
+
network by construction (see the module docstring)."""
|
|
353
|
+
r = self._get('/remoteAdmin')
|
|
354
|
+
return RemoteAdmin(enabled=bool(r.get('enabled', False)),
|
|
355
|
+
usb_request_source=bool(r.get('isUSBRequestSource', False)))
|
|
356
|
+
|
|
357
|
+
# ------------------------------------------------------------------
|
|
358
|
+
# Network interface
|
|
359
|
+
# ------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
def network(self, interface_id: str = '0') -> NetworkInterface:
|
|
362
|
+
return parse_interface(self._call('GET', f'/network/interface/{interface_id}')
|
|
363
|
+
.get('response', {}) or {})
|
|
364
|
+
|
|
365
|
+
def set_network(self, *, address: Optional[str] = None, netmask: Optional[str] = None,
|
|
366
|
+
gateway: Optional[str] = None, dns: Optional[List[str]] = None,
|
|
367
|
+
dhcp: Optional[bool] = None, interface_id: str = '0',
|
|
368
|
+
readback_timeout: float = READBACK_TIMEOUT) -> NetworkInterface:
|
|
369
|
+
"""Set the interface and return it READ BACK once it is live.
|
|
370
|
+
|
|
371
|
+
Fields left ``None`` keep their current values: the deck is read
|
|
372
|
+
first and the whole config is written back with only the given
|
|
373
|
+
fields changed (the API takes the full object). ``dns=[]`` clears the
|
|
374
|
+
DNS list. ``dhcp=True`` switches the interface to DHCP, keeping the
|
|
375
|
+
manual values on the deck for when it is switched back.
|
|
376
|
+
|
|
377
|
+
If ``address`` changes, the read-back goes to the NEW address (the
|
|
378
|
+
old one stops answering). The read-back is retried until the running
|
|
379
|
+
config equals the configured one or ``readback_timeout`` passes, in
|
|
380
|
+
which case this raises rather than report a success it cannot stand
|
|
381
|
+
behind. Writes what it is told; the caller owns whether the change
|
|
382
|
+
is safe — a wrong address or gateway strands the deck until someone
|
|
383
|
+
walks to it.
|
|
384
|
+
"""
|
|
385
|
+
if address is None and netmask is None and gateway is None and dns is None and dhcp is None:
|
|
386
|
+
raise ValueError('nothing to set')
|
|
387
|
+
before = self.network(interface_id)
|
|
388
|
+
want_dhcp = before.dhcp if dhcp is None else dhcp
|
|
389
|
+
body = {
|
|
390
|
+
'id': before.id,
|
|
391
|
+
'type': 'DHCP' if want_dhcp else 'Static',
|
|
392
|
+
'address': before.address if address is None else address,
|
|
393
|
+
'netmask': before.netmask if netmask is None else netmask,
|
|
394
|
+
'gateway': before.gateway if gateway is None else gateway,
|
|
395
|
+
'dnsServers': list(before.dns) if dns is None else list(dns),
|
|
396
|
+
}
|
|
397
|
+
self._call('PUT', f'/network/interface/{interface_id}', body)
|
|
398
|
+
|
|
399
|
+
readback = self
|
|
400
|
+
if not want_dhcp and address is not None and address != self.host:
|
|
401
|
+
readback = HyperdeckSetup(address, timeout=self.timeout,
|
|
402
|
+
scheme=self.scheme, opener=self._opener)
|
|
403
|
+
deadline = time.monotonic() + readback_timeout
|
|
404
|
+
last: Any = None
|
|
405
|
+
while True:
|
|
406
|
+
try:
|
|
407
|
+
after = readback.network(interface_id)
|
|
408
|
+
configured_ok = (
|
|
409
|
+
(after.dhcp is True) if want_dhcp else (
|
|
410
|
+
after.dhcp is False
|
|
411
|
+
and after.address == body['address']
|
|
412
|
+
and after.netmask == body['netmask']
|
|
413
|
+
and after.gateway == body['gateway']
|
|
414
|
+
and after.dns == body['dnsServers']))
|
|
415
|
+
if configured_ok and after.settled:
|
|
416
|
+
return after
|
|
417
|
+
last = (f'deck holds {after.address}/{after.netmask} gw {after.gateway} '
|
|
418
|
+
f'dns {after.dns} type {"DHCP" if after.dhcp else "Static"}, '
|
|
419
|
+
f'running {after.active_address}/{after.active_netmask}')
|
|
420
|
+
except (HyperdeckSetupError, OSError) as exc:
|
|
421
|
+
last = exc
|
|
422
|
+
if time.monotonic() >= deadline:
|
|
423
|
+
break
|
|
424
|
+
time.sleep(READBACK_POLL)
|
|
425
|
+
raise HyperdeckSetupError(
|
|
426
|
+
f'{readback.host}: the deck accepted the network change but did not '
|
|
427
|
+
f'settle on it within {readback_timeout:g}s — CHECK THIS DECK ({last})')
|
|
428
|
+
|
|
429
|
+
# ------------------------------------------------------------------
|
|
430
|
+
# Network access: FTP, HTTP (Web Media Manager), the 9993 protocol
|
|
431
|
+
# ------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
def network_access(self) -> Dict[str, str]:
|
|
434
|
+
"""``{'FTP': 'Enabled', 'HTTP': 'Enabled'}`` — protocol id to state."""
|
|
435
|
+
protocols = self._get('/networkAccess').get('protocols') or []
|
|
436
|
+
return {p.get('id', ''): p.get('state', '') for p in protocols if p.get('id')}
|
|
437
|
+
|
|
438
|
+
def network_access_urls(self) -> Dict[str, str]:
|
|
439
|
+
"""Protocol id to the URL the deck advertises for it (``ftp://…``)."""
|
|
440
|
+
protocols = self._get('/networkAccess').get('protocols') or []
|
|
441
|
+
return {p['id']: p.get('url', '') for p in protocols if p.get('id')}
|
|
442
|
+
|
|
443
|
+
def network_access_options(self) -> Dict[str, bool]:
|
|
444
|
+
"""Protocol id to whether it offers ``SecureOnly``. On the HD Mini:
|
|
445
|
+
FTP False, HTTP True."""
|
|
446
|
+
options = self._get('/networkAccess/options').get('options') or []
|
|
447
|
+
return {o['protocol']: bool(o.get('secureOption', False))
|
|
448
|
+
for o in options if o.get('protocol')}
|
|
449
|
+
|
|
450
|
+
def set_network_access(self, **states: str) -> bool:
|
|
451
|
+
"""Set one or more protocols' access state; keyword per protocol,
|
|
452
|
+
case-insensitive: ``set_network_access(ftp='Enabled', http='SecureOnly')``.
|
|
453
|
+
|
|
454
|
+
Protocols not named keep their state (the API takes the full list, so
|
|
455
|
+
the current one is read and merged). Returns whether the deck says a
|
|
456
|
+
reboot is needed for the change to apply; when it does, the read-back
|
|
457
|
+
is skipped because the old state is what it will read until then.
|
|
458
|
+
Otherwise every named protocol is read back and a mismatch raises.
|
|
459
|
+
"""
|
|
460
|
+
if not states:
|
|
461
|
+
raise ValueError('nothing to set')
|
|
462
|
+
current = self.network_access()
|
|
463
|
+
wanted: Dict[str, str] = {}
|
|
464
|
+
for key, state in states.items():
|
|
465
|
+
match = [pid for pid in current if pid.lower() == key.lower()]
|
|
466
|
+
if not match:
|
|
467
|
+
raise ValueError(
|
|
468
|
+
f'{key!r} is not a protocol this deck offers ({sorted(current)})')
|
|
469
|
+
wanted[match[0]] = _check_state(state)
|
|
470
|
+
merged = {**current, **wanted}
|
|
471
|
+
result = self._call('PUT', '/networkAccess', {
|
|
472
|
+
'protocols': [{'id': pid, 'state': state} for pid, state in merged.items()]})
|
|
473
|
+
reboot = bool((result.get('response') or result).get('rebootRequired', False))
|
|
474
|
+
if reboot:
|
|
475
|
+
logger.info('%s: network access change needs a reboot to apply', self.host)
|
|
476
|
+
return True
|
|
477
|
+
after = self.network_access()
|
|
478
|
+
for pid, state in wanted.items():
|
|
479
|
+
self._verify(f'networkAccess {pid}', state, after.get(pid))
|
|
480
|
+
return False
|
|
481
|
+
|
|
482
|
+
def ethernet_protocol(self) -> str:
|
|
483
|
+
"""State of the HyperDeck Ethernet Protocol (TCP 9993): ``'Enabled'``
|
|
484
|
+
or ``'Disabled'``. Everything :class:`hyperdeckwire.Hyperdeck` does,
|
|
485
|
+
and the ATEM's own HyperDeck control, needs it Enabled."""
|
|
486
|
+
return self._get('/hyperDeckEthernetProtocol').get('state', '')
|
|
487
|
+
|
|
488
|
+
def set_ethernet_protocol(self, state: str = ENABLED) -> str:
|
|
489
|
+
_check_state(state)
|
|
490
|
+
self._call('PUT', '/hyperDeckEthernetProtocol', {'state': state})
|
|
491
|
+
got = self.ethernet_protocol()
|
|
492
|
+
self._verify('hyperDeckEthernetProtocol', state, got)
|
|
493
|
+
return got
|
|
494
|
+
|
|
495
|
+
# ------------------------------------------------------------------
|
|
496
|
+
# Certificate (for HTTPS / HTTP = SecureOnly)
|
|
497
|
+
# ------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
def certificate_summary(self) -> Dict[str, Any]:
|
|
500
|
+
"""``hostname`` always; ``domain``, ``issuer``, validity fields when
|
|
501
|
+
a certificate is installed (an empty summary means none is)."""
|
|
502
|
+
return self._get('/certificate/summary')
|
|
503
|
+
|
|
504
|
+
def create_self_signed_certificate(self) -> Dict[str, Any]:
|
|
505
|
+
"""Have the deck issue itself a certificate for its hostname."""
|
|
506
|
+
self._call('PUT', '/certificate/selfSigned')
|
|
507
|
+
return self.certificate_summary()
|
|
508
|
+
|
|
509
|
+
def upload_certificate(self, certificate: str) -> Dict[str, Any]:
|
|
510
|
+
"""Install a certificate (the PEM text HyperDeck Setup's file picker
|
|
511
|
+
would send). Returns the new summary."""
|
|
512
|
+
self._call('POST', '/certificate', {'certificate': certificate})
|
|
513
|
+
return self.certificate_summary()
|
|
514
|
+
|
|
515
|
+
def delete_certificate(self) -> Dict[str, Any]:
|
|
516
|
+
self._call('DELETE', '/certificate')
|
|
517
|
+
return self.certificate_summary()
|
|
518
|
+
|
|
519
|
+
def create_signing_request(self, *, common_name: str, country: str, state_name: str,
|
|
520
|
+
locality: str, organization: str,
|
|
521
|
+
subject_alternative_name: Optional[str] = None
|
|
522
|
+
) -> Dict[str, Any]:
|
|
523
|
+
"""Ask the deck to generate a CSR for its key. Returns whatever the
|
|
524
|
+
deck answers (the API documents the call, not the body); fetch the
|
|
525
|
+
file with :meth:`download_signing_request`. Not exercised on
|
|
526
|
+
hardware."""
|
|
527
|
+
body = {'commonName': common_name, 'country': country, 'stateName': state_name,
|
|
528
|
+
'locality': locality, 'organization': organization}
|
|
529
|
+
if subject_alternative_name is not None:
|
|
530
|
+
body['subjectAlternativeName'] = subject_alternative_name
|
|
531
|
+
return self._call('POST', '/certificate/signingRequest', body)
|
|
532
|
+
|
|
533
|
+
def download_signing_request(self, csr_id: str) -> bytes:
|
|
534
|
+
"""The ``.csr`` file for an id from :meth:`create_signing_request`."""
|
|
535
|
+
return self._raw('GET', f'/certificate/signingRequest/{csr_id}')
|
|
536
|
+
|
|
537
|
+
# ------------------------------------------------------------------
|
|
538
|
+
# Users (Web Media Manager logins)
|
|
539
|
+
# ------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
def admin_required(self) -> bool:
|
|
542
|
+
"""Does the deck demand admin credentials for protected endpoints?
|
|
543
|
+
False on a fresh deck, which is also what anonymous FTP relies on."""
|
|
544
|
+
return bool(self._get('/auth/adminStatus').get('adminRequired', False))
|
|
545
|
+
|
|
546
|
+
def users(self) -> List[User]:
|
|
547
|
+
return [User(auth_user_id=str(u.get('authUserId', '')),
|
|
548
|
+
username=u.get('username', ''),
|
|
549
|
+
remote_admin_utility_access=bool(u.get('remoteAdminUtilityAccess', False)),
|
|
550
|
+
shared_folder_permissions=list(u.get('sharedFolderPermissions') or []))
|
|
551
|
+
for u in (self._get('/auth/users').get('users') or [])]
|
|
552
|
+
|
|
553
|
+
def create_user(self, username: str, password: str, *,
|
|
554
|
+
remote_admin_utility_access: bool = False,
|
|
555
|
+
shared_folder_permissions: Optional[List[Dict[str, Any]]] = None) -> User:
|
|
556
|
+
body: Dict[str, Any] = {'username': username, 'password': password,
|
|
557
|
+
'remoteAdminUtilityAccess': remote_admin_utility_access}
|
|
558
|
+
if shared_folder_permissions is not None:
|
|
559
|
+
body['sharedFolderPermissions'] = shared_folder_permissions
|
|
560
|
+
self._call('POST', '/auth/user', body)
|
|
561
|
+
for user in self.users():
|
|
562
|
+
if user.username == username:
|
|
563
|
+
return user
|
|
564
|
+
raise HyperdeckSetupError(
|
|
565
|
+
f'{self.host}: created user {username!r} but it is not in the user list — CHECK THIS DECK')
|
|
566
|
+
|
|
567
|
+
def update_user(self, auth_user_id: str, *, username: Optional[str] = None,
|
|
568
|
+
password: Optional[str] = None,
|
|
569
|
+
remote_admin_utility_access: Optional[bool] = None,
|
|
570
|
+
shared_folder_permissions: Optional[List[Dict[str, Any]]] = None) -> User:
|
|
571
|
+
body: Dict[str, Any] = {'authUserId': str(auth_user_id)}
|
|
572
|
+
if username is not None:
|
|
573
|
+
body['username'] = username
|
|
574
|
+
if password is not None:
|
|
575
|
+
body['password'] = password
|
|
576
|
+
if remote_admin_utility_access is not None:
|
|
577
|
+
body['remoteAdminUtilityAccess'] = remote_admin_utility_access
|
|
578
|
+
if shared_folder_permissions is not None:
|
|
579
|
+
body['sharedFolderPermissions'] = shared_folder_permissions
|
|
580
|
+
if len(body) == 1:
|
|
581
|
+
raise ValueError('nothing to update')
|
|
582
|
+
self._call('PUT', '/auth/user', body)
|
|
583
|
+
for user in self.users():
|
|
584
|
+
if user.auth_user_id == str(auth_user_id):
|
|
585
|
+
if username is not None:
|
|
586
|
+
self._verify('username', username, user.username)
|
|
587
|
+
if remote_admin_utility_access is not None:
|
|
588
|
+
self._verify('remoteAdminUtilityAccess', remote_admin_utility_access,
|
|
589
|
+
user.remote_admin_utility_access)
|
|
590
|
+
return user
|
|
591
|
+
raise HyperdeckSetupError(
|
|
592
|
+
f'{self.host}: user {auth_user_id!r} is not in the user list after the update — CHECK THIS DECK')
|
|
593
|
+
|
|
594
|
+
def delete_user(self, auth_user_id: str) -> None:
|
|
595
|
+
self._call('DELETE', '/auth/user', {'authUserId': str(auth_user_id)})
|
|
596
|
+
if any(u.auth_user_id == str(auth_user_id) for u in self.users()):
|
|
597
|
+
raise HyperdeckSetupError(
|
|
598
|
+
f'{self.host}: deleted user {auth_user_id!r} but it is still listed — CHECK THIS DECK')
|
|
599
|
+
|
|
600
|
+
# ------------------------------------------------------------------
|
|
601
|
+
# Date and time
|
|
602
|
+
# ------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
def date_and_time(self) -> Dict[str, Any]:
|
|
605
|
+
"""``{'time': <unix seconds, int>, 'timezone_offset': <minutes>, 'time_friendly': 'YYYYMMDD-HHMMSS'}``"""
|
|
606
|
+
r = self._get('/dateAndTime')
|
|
607
|
+
return {'time': int(r.get('time', 0) or 0),
|
|
608
|
+
'timezone_offset': int(r.get('timezoneOffset', 0) or 0),
|
|
609
|
+
'time_friendly': r.get('timeFriendly', '')}
|
|
610
|
+
|
|
611
|
+
def set_date_and_time(self, unix_seconds: int, timezone_offset_minutes: int = 0,
|
|
612
|
+
tolerance_seconds: int = 5) -> Dict[str, Any]:
|
|
613
|
+
"""Set the clock. Read back within ``tolerance_seconds`` (the deck
|
|
614
|
+
keeps ticking between the write and the read)."""
|
|
615
|
+
self._call('PUT', '/dateAndTime', {'time': str(int(unix_seconds)),
|
|
616
|
+
'timezoneOffset': int(timezone_offset_minutes)})
|
|
617
|
+
after = self.date_and_time()
|
|
618
|
+
if abs(after['time'] - int(unix_seconds)) > tolerance_seconds:
|
|
619
|
+
raise HyperdeckSetupError(
|
|
620
|
+
f'{self.host}: set the time to {int(unix_seconds)} but the deck reads '
|
|
621
|
+
f'{after["time"]} — CHECK THIS DECK')
|
|
622
|
+
self._verify('timezoneOffset', int(timezone_offset_minutes), after['timezone_offset'])
|
|
623
|
+
return after
|
|
624
|
+
|
|
625
|
+
def ntp(self) -> Dict[str, Any]:
|
|
626
|
+
"""``{'enabled': bool, 'server_url': str, 'state': str}``"""
|
|
627
|
+
r = self._get('/dateAndTime/ntp')
|
|
628
|
+
return {'enabled': bool(r.get('enabled', False)),
|
|
629
|
+
'server_url': r.get('serverUrl', ''),
|
|
630
|
+
'state': r.get('state', '')}
|
|
631
|
+
|
|
632
|
+
def set_ntp(self, server_url: str, enabled: bool = True) -> Dict[str, Any]:
|
|
633
|
+
self._call('PUT', '/dateAndTime/ntp', {'serverUrl': server_url, 'enabled': bool(enabled)})
|
|
634
|
+
after = self.ntp()
|
|
635
|
+
self._verify('ntp serverUrl', server_url, after['server_url'])
|
|
636
|
+
self._verify('ntp enabled', bool(enabled), after['enabled'])
|
|
637
|
+
return after
|
|
638
|
+
|
|
639
|
+
def timezone_offset(self) -> int:
|
|
640
|
+
"""Offset from GMT in minutes."""
|
|
641
|
+
return int(self._get('/dateAndTime/timezone').get('timezoneOffset', 0) or 0)
|
|
642
|
+
|
|
643
|
+
def set_timezone_offset(self, minutes: int) -> int:
|
|
644
|
+
self._call('PUT', '/dateAndTime/timezone', {'timezoneOffset': int(minutes)})
|
|
645
|
+
got = self.timezone_offset()
|
|
646
|
+
self._verify('timezoneOffset', int(minutes), got)
|
|
647
|
+
return got
|
|
648
|
+
|
|
649
|
+
# ------------------------------------------------------------------
|
|
650
|
+
# Reboot
|
|
651
|
+
# ------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
def reboot(self) -> None:
|
|
654
|
+
"""Reboot the deck. The API hands out a one-time key on GET that the
|
|
655
|
+
PUT must return, so a stray PUT cannot reboot a deck. No read-back:
|
|
656
|
+
the deck goes away. Stops any playback."""
|
|
657
|
+
key = self._get('/reboot').get('key')
|
|
658
|
+
if not key:
|
|
659
|
+
raise HyperdeckSetupError(f'{self.host}: the deck gave no reboot key')
|
|
660
|
+
self._call('PUT', '/reboot', {'key': key})
|
|
@@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta"
|
|
|
13
13
|
|
|
14
14
|
[project]
|
|
15
15
|
name = "bmdwire"
|
|
16
|
-
version = "1.
|
|
16
|
+
version = "1.2.0"
|
|
17
17
|
description = "Python libraries for Blackmagic Design broadcast devices: ATEM switchers, HyperDeck recorders, Ultimatte keyers and Videohub routers"
|
|
18
18
|
readme = "README.md"
|
|
19
19
|
# atemwire is a fork of pyatem and stays LGPL-3.0-only; the other three were
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# SPDX-License-Identifier: MIT
|
|
2
|
-
"""hyperdeckwire — Blackmagic HyperDeck Studio control library.
|
|
3
|
-
|
|
4
|
-
A small client for the HyperDeck Ethernet Protocol (TCP 9993, text /
|
|
5
|
-
line-oriented) plus an FTP file-upload helper. Studio HD Mini-class
|
|
6
|
-
units don't expose the HTTP REST API that the Plus/Pro/HDR SKUs got
|
|
7
|
-
in firmware 8.x, so this library targets the always-on 9993 + FTP
|
|
8
|
-
combination — works across the whole networked HyperDeck product line.
|
|
9
|
-
|
|
10
|
-
The protocol is documented in BMD's
|
|
11
|
-
``HyperDeckEthernetProtocol.pdf`` (December 2024).
|
|
12
|
-
|
|
13
|
-
Public surface::
|
|
14
|
-
|
|
15
|
-
from hyperdeckwire import Hyperdeck, upload_clip
|
|
16
|
-
|
|
17
|
-
# Control:
|
|
18
|
-
with Hyperdeck('192.0.2.11') as hd:
|
|
19
|
-
info = hd.device_info() # dict
|
|
20
|
-
clips = hd.disk_list() # List[Clip]
|
|
21
|
-
hd.stop()
|
|
22
|
-
hd.clips_clear()
|
|
23
|
-
hd.clips_add('my-clip.mp4')
|
|
24
|
-
hd.play(loop=True, single_clip=True)
|
|
25
|
-
|
|
26
|
-
# File upload:
|
|
27
|
-
result = upload_clip('192.0.2.11', '/path/to/clip.mp4')
|
|
28
|
-
print(result.throughput_mb_s)
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
from hyperdeckwire.client import (
|
|
32
|
-
Clip,
|
|
33
|
-
Hyperdeck,
|
|
34
|
-
HyperdeckError,
|
|
35
|
-
Response,
|
|
36
|
-
)
|
|
37
|
-
from hyperdeckwire.upload import UploadResult, upload_clip
|
|
38
|
-
|
|
39
|
-
__all__ = [
|
|
40
|
-
'Clip',
|
|
41
|
-
'Hyperdeck',
|
|
42
|
-
'HyperdeckError',
|
|
43
|
-
'Response',
|
|
44
|
-
'UploadResult',
|
|
45
|
-
'upload_clip',
|
|
46
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|