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.
Files changed (94) hide show
  1. {bmdwire-1.1.0/bmdwire.egg-info → bmdwire-1.2.0}/PKG-INFO +4 -4
  2. {bmdwire-1.1.0 → bmdwire-1.2.0}/README.md +3 -3
  3. {bmdwire-1.1.0 → bmdwire-1.2.0/bmdwire.egg-info}/PKG-INFO +4 -4
  4. {bmdwire-1.1.0 → bmdwire-1.2.0}/bmdwire.egg-info/SOURCES.txt +1 -0
  5. bmdwire-1.2.0/hyperdeckwire/__init__.py +74 -0
  6. bmdwire-1.2.0/hyperdeckwire/setup.py +660 -0
  7. {bmdwire-1.1.0 → bmdwire-1.2.0}/pyproject.toml +1 -1
  8. bmdwire-1.1.0/hyperdeckwire/__init__.py +0 -46
  9. {bmdwire-1.1.0 → bmdwire-1.2.0}/LICENSE +0 -0
  10. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/LICENSE +0 -0
  11. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/LICENSE-gpl3.txt +0 -0
  12. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/NOTICE +0 -0
  13. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/__init__.py +0 -0
  14. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/_socketqueue.py +0 -0
  15. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/_transfer.py +0 -0
  16. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/atem.py +0 -0
  17. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/connection.py +0 -0
  18. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/docs/MACRO_FORMAT.md +0 -0
  19. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/docs/MEDIA_LOCK.md +0 -0
  20. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/docs/PROFILE_FORMAT.md +0 -0
  21. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/docs/PROFILE_SAVE_RESTORE.md +0 -0
  22. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/helpers.py +0 -0
  23. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/imaging.py +0 -0
  24. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/__init__.py +0 -0
  25. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/_helpers.py +0 -0
  26. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/_io.py +0 -0
  27. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/downstream_keyer.py +0 -0
  28. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/fairlight.py +0 -0
  29. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/flow.py +0 -0
  30. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/hyperdeck.py +0 -0
  31. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/media.py +0 -0
  32. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/switching.py +0 -0
  33. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/macrotransfer/upstream_keyer.py +0 -0
  34. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/mediaconvertmodule.c +0 -0
  35. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/__init__.py +0 -0
  36. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/_dsl.py +0 -0
  37. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/audio_legacy.py +0 -0
  38. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/camera_control.py +0 -0
  39. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/color_generator.py +0 -0
  40. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/downstream_keyer.py +0 -0
  41. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/fade_to_black.py +0 -0
  42. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/fairlight.py +0 -0
  43. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/file_transfer.py +0 -0
  44. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/hyperdeck.py +0 -0
  45. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/input_video.py +0 -0
  46. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/lock.py +0 -0
  47. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/macros.py +0 -0
  48. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/manual.py +0 -0
  49. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/media.py +0 -0
  50. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/meters.py +0 -0
  51. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/multiviewer.py +0 -0
  52. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/recording.py +0 -0
  53. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/streaming.py +0 -0
  54. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/supersource.py +0 -0
  55. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/switching.py +0 -0
  56. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/system_info.py +0 -0
  57. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/tally.py +0 -0
  58. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/transition.py +0 -0
  59. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/messages/upstream_keyer.py +0 -0
  60. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/pool.py +0 -0
  61. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/probe.py +0 -0
  62. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/profile/__init__.py +0 -0
  63. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/profile/_common.py +0 -0
  64. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/profile/_enums.py +0 -0
  65. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/profile/_xml.py +0 -0
  66. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/profile/apply.py +0 -0
  67. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/profile/options.py +0 -0
  68. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/profile/save.py +0 -0
  69. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/protocol.py +0 -0
  70. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/py.typed +0 -0
  71. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/ready.py +0 -0
  72. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/state.py +0 -0
  73. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/testing.py +0 -0
  74. {bmdwire-1.1.0 → bmdwire-1.2.0}/atemwire/transport.py +0 -0
  75. {bmdwire-1.1.0 → bmdwire-1.2.0}/bmdwire.egg-info/dependency_links.txt +0 -0
  76. {bmdwire-1.1.0 → bmdwire-1.2.0}/bmdwire.egg-info/requires.txt +0 -0
  77. {bmdwire-1.1.0 → bmdwire-1.2.0}/bmdwire.egg-info/top_level.txt +0 -0
  78. {bmdwire-1.1.0 → bmdwire-1.2.0}/hyperdeckwire/LICENSE +0 -0
  79. {bmdwire-1.1.0 → bmdwire-1.2.0}/hyperdeckwire/client.py +0 -0
  80. {bmdwire-1.1.0 → bmdwire-1.2.0}/hyperdeckwire/upload.py +0 -0
  81. {bmdwire-1.1.0 → bmdwire-1.2.0}/setup.cfg +0 -0
  82. {bmdwire-1.1.0 → bmdwire-1.2.0}/setup.py +0 -0
  83. {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/LICENSE +0 -0
  84. {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/__init__.py +0 -0
  85. {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/_preamble.py +0 -0
  86. {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/_protocol.py +0 -0
  87. {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/network.py +0 -0
  88. {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/profile/__init__.py +0 -0
  89. {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/profile/_ranges.py +0 -0
  90. {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/profile/archive.py +0 -0
  91. {bmdwire-1.1.0 → bmdwire-1.2.0}/ultimattewire/profile/restore.py +0 -0
  92. {bmdwire-1.1.0 → bmdwire-1.2.0}/videohubwire/LICENSE +0 -0
  93. {bmdwire-1.1.0 → bmdwire-1.2.0}/videohubwire/__init__.py +0 -0
  94. {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.1.0
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.1.0`) so a later release cannot change your install under you.
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 names stay on PyPI at their last 1.0.x release and are not updated. They are not shims either, on purpose: a release of `atemwire` that depended on bmdwire would break `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 already has any of the four old packages, 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.
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.1.0`) so a later release cannot change your install under you.
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 names stay on PyPI at their last 1.0.x release and are not updated. They are not shims either, on purpose: a release of `atemwire` that depended on bmdwire would break `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 already has any of the four old packages, 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.
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.1.0
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.1.0`) so a later release cannot change your install under you.
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 names stay on PyPI at their last 1.0.x release and are not updated. They are not shims either, on purpose: a release of `atemwire` that depended on bmdwire would break `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 already has any of the four old packages, 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.
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
 
@@ -75,6 +75,7 @@ bmdwire.egg-info/top_level.txt
75
75
  hyperdeckwire/LICENSE
76
76
  hyperdeckwire/__init__.py
77
77
  hyperdeckwire/client.py
78
+ hyperdeckwire/setup.py
78
79
  hyperdeckwire/upload.py
79
80
  ultimattewire/LICENSE
80
81
  ultimattewire/__init__.py
@@ -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.1.0"
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