hyperdeckwire 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hyperdeckwire/__init__.py +46 -0
- hyperdeckwire/client.py +553 -0
- hyperdeckwire/upload.py +148 -0
- hyperdeckwire-0.1.0.dev0.dist-info/METADATA +98 -0
- hyperdeckwire-0.1.0.dev0.dist-info/RECORD +8 -0
- hyperdeckwire-0.1.0.dev0.dist-info/WHEEL +5 -0
- hyperdeckwire-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- hyperdeckwire-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
]
|
hyperdeckwire/client.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""hyperdeckwire.client — HyperDeck Ethernet Protocol client.
|
|
3
|
+
|
|
4
|
+
Text-based, line-oriented protocol over TCP 9993. Documented in
|
|
5
|
+
``HyperDeckEthernetProtocol.pdf`` from Blackmagic (Dec 2024).
|
|
6
|
+
|
|
7
|
+
Response codes:
|
|
8
|
+
100-199 : failure (raised as :class:`HyperdeckError`)
|
|
9
|
+
200 : simple ack
|
|
10
|
+
201-299 : success with parameters (multi-line, blank-line terminated)
|
|
11
|
+
500-599 : asynchronous notification (skipped by default during a
|
|
12
|
+
blocking ``request``; arrives interleaved with normal traffic)
|
|
13
|
+
|
|
14
|
+
Public surface::
|
|
15
|
+
|
|
16
|
+
from hyperdeckwire import Hyperdeck
|
|
17
|
+
|
|
18
|
+
with Hyperdeck('192.0.2.11') as hd:
|
|
19
|
+
info = hd.device_info()
|
|
20
|
+
for clip in hd.disk_list():
|
|
21
|
+
print(clip.clip_id, clip.name, clip.duration)
|
|
22
|
+
hd.stop()
|
|
23
|
+
hd.clips_clear()
|
|
24
|
+
hd.clips_add('my-clip.mp4')
|
|
25
|
+
hd.play(loop=True, single_clip=True)
|
|
26
|
+
state = hd.transport_info()
|
|
27
|
+
|
|
28
|
+
Why a single client file (no per-feature split): the protocol vocabulary
|
|
29
|
+
is small (~30 commands), the wire format is uniform (request line in,
|
|
30
|
+
response block out), and there's no switcher-style state event stream
|
|
31
|
+
to fan out. Splitting would add ceremony without payoff.
|
|
32
|
+
If the vocabulary doubles, revisit.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import logging
|
|
38
|
+
import socket
|
|
39
|
+
import time
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from typing import Callable, Dict, List, Optional
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
DEFAULT_PORT = 9993
|
|
47
|
+
DEFAULT_CONNECT_TIMEOUT = 5.0
|
|
48
|
+
DEFAULT_READ_TIMEOUT = 5.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class HyperdeckError(Exception):
|
|
52
|
+
"""The HyperDeck rejected a command (response code 100-199).
|
|
53
|
+
|
|
54
|
+
``code`` and ``text`` are the raw protocol code + text so callers
|
|
55
|
+
can distinguish "remote control disabled" (111) from "clip not
|
|
56
|
+
found" (112) etc. — see the PDF for the full list.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, code: int, text: str):
|
|
60
|
+
self.code = code
|
|
61
|
+
self.text = text
|
|
62
|
+
super().__init__(f'[{code}] {text}')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Response:
|
|
67
|
+
"""One response block parsed off the wire."""
|
|
68
|
+
|
|
69
|
+
code: int
|
|
70
|
+
text: str
|
|
71
|
+
lines: List[str]
|
|
72
|
+
|
|
73
|
+
def is_ok(self) -> bool:
|
|
74
|
+
return 200 <= self.code < 300
|
|
75
|
+
|
|
76
|
+
def is_error(self) -> bool:
|
|
77
|
+
return 100 <= self.code < 200
|
|
78
|
+
|
|
79
|
+
def is_async(self) -> bool:
|
|
80
|
+
return 500 <= self.code < 600
|
|
81
|
+
|
|
82
|
+
def params(self) -> Dict[str, str]:
|
|
83
|
+
"""Parse the response body as ``key: value`` pairs.
|
|
84
|
+
|
|
85
|
+
Multi-line responses (codes 201-299, 500-599) carry an indented
|
|
86
|
+
block of ``key: value`` lines before the blank terminator. This
|
|
87
|
+
flattens them to a plain dict.
|
|
88
|
+
"""
|
|
89
|
+
out: Dict[str, str] = {}
|
|
90
|
+
for line in self.lines:
|
|
91
|
+
stripped = line.strip()
|
|
92
|
+
if not stripped or ':' not in stripped:
|
|
93
|
+
continue
|
|
94
|
+
key, _, val = stripped.partition(':')
|
|
95
|
+
out[key.strip()] = val.strip()
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class Clip:
|
|
101
|
+
"""A clip on the timeline or on disk.
|
|
102
|
+
|
|
103
|
+
Fields vary by source: ``disk_list`` populates ``file_format`` and
|
|
104
|
+
``video_format``; ``clips_get`` (default v1) populates ``start``.
|
|
105
|
+
The intersection is ``clip_id``, ``name``, ``duration`` — those
|
|
106
|
+
three are always set.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
clip_id: int
|
|
110
|
+
name: str
|
|
111
|
+
duration: str # timecode HH:MM:SS:FF
|
|
112
|
+
start: Optional[str] = None # clips_get v1 timeline start
|
|
113
|
+
file_format: Optional[str] = None # disk_list, e.g. "H.264"
|
|
114
|
+
video_format: Optional[str] = None # disk_list, e.g. "1080p25"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Hyperdeck client
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
class Hyperdeck:
|
|
122
|
+
"""Client for the HyperDeck Ethernet Protocol over TCP 9993.
|
|
123
|
+
|
|
124
|
+
Connection lifecycle is explicit: :meth:`connect` opens the socket
|
|
125
|
+
and drains the greeting; :meth:`close` shuts down. Use as a context
|
|
126
|
+
manager to get both for free::
|
|
127
|
+
|
|
128
|
+
with Hyperdeck(ip) as hd:
|
|
129
|
+
...
|
|
130
|
+
|
|
131
|
+
Read-only commands (``device_info``, ``disk_list``, ``clips_get``,
|
|
132
|
+
``transport_info``, ``configuration``, ``slot_info``) work without
|
|
133
|
+
setup. Write commands (``stop``, ``play``, ``clips_clear``,
|
|
134
|
+
``clips_add``, ``goto_clip``) need the unit's "remote control"
|
|
135
|
+
flag enabled. On HD-class units that defaults to True; query with
|
|
136
|
+
:meth:`remote_info` if unsure.
|
|
137
|
+
|
|
138
|
+
``socket_factory`` is a hook for tests: pass a callable that
|
|
139
|
+
returns an object with ``sendall``, ``recv``, ``settimeout``,
|
|
140
|
+
``close``, ``shutdown``. Default is :func:`socket.create_connection`.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self,
|
|
144
|
+
host: str,
|
|
145
|
+
port: int = DEFAULT_PORT,
|
|
146
|
+
*,
|
|
147
|
+
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
|
148
|
+
read_timeout: float = DEFAULT_READ_TIMEOUT,
|
|
149
|
+
socket_factory: Optional[Callable] = None):
|
|
150
|
+
self.host = host
|
|
151
|
+
self.port = port
|
|
152
|
+
self.connect_timeout = connect_timeout
|
|
153
|
+
self.read_timeout = read_timeout
|
|
154
|
+
self._socket_factory = socket_factory or socket.create_connection
|
|
155
|
+
self._sock = None
|
|
156
|
+
self._buf = b''
|
|
157
|
+
self._greeting: Optional[Response] = None
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
# Lifecycle
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def __enter__(self) -> 'Hyperdeck':
|
|
164
|
+
self.connect()
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
168
|
+
self.close()
|
|
169
|
+
|
|
170
|
+
def connect(self) -> None:
|
|
171
|
+
"""Open the TCP connection and drain the greeting (500 connection info)."""
|
|
172
|
+
# connect() over an already-connected
|
|
173
|
+
# instance must not overwrite _sock and leak the old socket.
|
|
174
|
+
if self._sock is not None:
|
|
175
|
+
self.close()
|
|
176
|
+
self._sock = self._socket_factory((self.host, self.port),
|
|
177
|
+
timeout=self.connect_timeout)
|
|
178
|
+
self._sock.settimeout(self.read_timeout)
|
|
179
|
+
self._buf = b''
|
|
180
|
+
# The unit sends a 500 connection info: block immediately on
|
|
181
|
+
# accept. It IS a 5xx code, but it's a one-time synchronous
|
|
182
|
+
# greeting, not the kind of asynchronous notification we
|
|
183
|
+
# normally want to skip during a request — read it with
|
|
184
|
+
# skip_async=False so it doesn't get drained into the void.
|
|
185
|
+
try:
|
|
186
|
+
self._greeting = self._read_response(skip_async=False)
|
|
187
|
+
except Exception:
|
|
188
|
+
# A failed greeting read must not leak
|
|
189
|
+
# the just-opened socket until GC — close + clear before
|
|
190
|
+
# re-raising so the instance is cleanly reconnectable.
|
|
191
|
+
self.close()
|
|
192
|
+
raise
|
|
193
|
+
|
|
194
|
+
def close(self) -> None:
|
|
195
|
+
"""Send ``quit`` (best-effort) and close the socket."""
|
|
196
|
+
if self._sock is None:
|
|
197
|
+
return
|
|
198
|
+
try:
|
|
199
|
+
self._send_line('quit')
|
|
200
|
+
except OSError:
|
|
201
|
+
pass
|
|
202
|
+
try:
|
|
203
|
+
self._sock.shutdown(socket.SHUT_RDWR)
|
|
204
|
+
except OSError:
|
|
205
|
+
pass
|
|
206
|
+
try:
|
|
207
|
+
self._sock.close()
|
|
208
|
+
except OSError:
|
|
209
|
+
pass
|
|
210
|
+
self._sock = None
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------------------------------
|
|
213
|
+
# Greeting accessors
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def model(self) -> str:
|
|
218
|
+
"""Model name from the connection greeting (e.g. 'HyperDeck Studio HD Mini')."""
|
|
219
|
+
return (self._greeting.params().get('model', '')
|
|
220
|
+
if self._greeting else '')
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def protocol_version(self) -> str:
|
|
224
|
+
"""Protocol version string from the connection greeting (e.g. '1.13')."""
|
|
225
|
+
return (self._greeting.params().get('protocol version', '')
|
|
226
|
+
if self._greeting else '')
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
# Low-level I/O
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def _send_line(self, line: str) -> None:
|
|
233
|
+
if self._sock is None:
|
|
234
|
+
raise OSError('not connected')
|
|
235
|
+
self._sock.sendall((line + '\n').encode('utf-8'))
|
|
236
|
+
|
|
237
|
+
def _recv_line(self, deadline: float) -> str:
|
|
238
|
+
"""Read one CRLF-terminated line. Strips trailing \\r\\n."""
|
|
239
|
+
while b'\n' not in self._buf:
|
|
240
|
+
remaining = deadline - time.monotonic()
|
|
241
|
+
if remaining <= 0:
|
|
242
|
+
raise TimeoutError('no line within deadline')
|
|
243
|
+
self._sock.settimeout(min(remaining, 0.5))
|
|
244
|
+
try:
|
|
245
|
+
chunk = self._sock.recv(4096)
|
|
246
|
+
except socket.timeout:
|
|
247
|
+
continue
|
|
248
|
+
if not chunk:
|
|
249
|
+
raise ConnectionError('peer closed connection')
|
|
250
|
+
self._buf += chunk
|
|
251
|
+
line, _, rest = self._buf.partition(b'\n')
|
|
252
|
+
self._buf = rest
|
|
253
|
+
return line.rstrip(b'\r').decode('utf-8', errors='replace')
|
|
254
|
+
|
|
255
|
+
def _read_response(self, timeout: Optional[float] = None,
|
|
256
|
+
skip_async: bool = True) -> Response:
|
|
257
|
+
"""Read one full response block off the wire.
|
|
258
|
+
|
|
259
|
+
Multi-line detection: a response is multi-line iff the headline
|
|
260
|
+
ends with a colon. Continuation lines are then read until a
|
|
261
|
+
blank-line terminator.
|
|
262
|
+
|
|
263
|
+
Asynchronous 5xx messages can arrive interleaved. By default
|
|
264
|
+
they're logged and skipped here so a caller waiting on the
|
|
265
|
+
actual response doesn't see them. Set ``skip_async=False`` to
|
|
266
|
+
return them through.
|
|
267
|
+
"""
|
|
268
|
+
timeout = timeout if timeout is not None else self.read_timeout
|
|
269
|
+
deadline = time.monotonic() + timeout
|
|
270
|
+
while True:
|
|
271
|
+
head = self._recv_line(deadline)
|
|
272
|
+
if not head:
|
|
273
|
+
continue
|
|
274
|
+
code, _, text = head.partition(' ')
|
|
275
|
+
try:
|
|
276
|
+
code_int = int(code)
|
|
277
|
+
except ValueError:
|
|
278
|
+
raise OSError(f'malformed response head: {head!r}')
|
|
279
|
+
|
|
280
|
+
lines: List[str] = []
|
|
281
|
+
if text.endswith(':'):
|
|
282
|
+
text = text[:-1]
|
|
283
|
+
while True:
|
|
284
|
+
cont = self._recv_line(deadline)
|
|
285
|
+
if cont == '':
|
|
286
|
+
break
|
|
287
|
+
lines.append(cont)
|
|
288
|
+
|
|
289
|
+
resp = Response(code=code_int, text=text, lines=lines)
|
|
290
|
+
if resp.is_async() and skip_async:
|
|
291
|
+
logger.debug('hyperdeck async: %s', resp)
|
|
292
|
+
continue
|
|
293
|
+
return resp
|
|
294
|
+
|
|
295
|
+
def request(self, command: str,
|
|
296
|
+
timeout: Optional[float] = None) -> Response:
|
|
297
|
+
"""Send a command, read one response, return it.
|
|
298
|
+
|
|
299
|
+
Does NOT raise on protocol-level error codes (1xx). Use
|
|
300
|
+
:meth:`_check_ok` (or just inspect the response) for that.
|
|
301
|
+
"""
|
|
302
|
+
self._send_line(command)
|
|
303
|
+
return self._read_response(timeout=timeout)
|
|
304
|
+
|
|
305
|
+
def _check_ok(self, command: str,
|
|
306
|
+
timeout: Optional[float] = None) -> Response:
|
|
307
|
+
"""Like :meth:`request` but raises :class:`HyperdeckError` on 1xx."""
|
|
308
|
+
resp = self.request(command, timeout=timeout)
|
|
309
|
+
if resp.is_error():
|
|
310
|
+
raise HyperdeckError(resp.code, resp.text)
|
|
311
|
+
return resp
|
|
312
|
+
|
|
313
|
+
# ------------------------------------------------------------------
|
|
314
|
+
# Info commands (read-only)
|
|
315
|
+
# ------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
def device_info(self) -> Dict[str, str]:
|
|
318
|
+
"""Return ``model``, ``protocol version``, ``unique id``, etc."""
|
|
319
|
+
return self._check_ok('device info').params()
|
|
320
|
+
|
|
321
|
+
def remote_info(self) -> Dict[str, str]:
|
|
322
|
+
"""Return remote-control state (``enabled``, ``override``)."""
|
|
323
|
+
return self._check_ok('remote').params()
|
|
324
|
+
|
|
325
|
+
def slot_info(self, slot_id: Optional[int] = None) -> Dict[str, str]:
|
|
326
|
+
"""Return info for the active slot (or a specific slot id)."""
|
|
327
|
+
cmd = 'slot info' if slot_id is None else f'slot info: slot id: {slot_id}'
|
|
328
|
+
return self._check_ok(cmd).params()
|
|
329
|
+
|
|
330
|
+
def transport_info(self) -> Dict[str, str]:
|
|
331
|
+
"""Return current transport state (status, speed, clip id, timecode, loop, …)."""
|
|
332
|
+
return self._check_ok('transport info').params()
|
|
333
|
+
|
|
334
|
+
def configuration(self) -> Dict[str, str]:
|
|
335
|
+
"""Return the unit's current configuration (file format, audio, timecode, …)."""
|
|
336
|
+
return self._check_ok('configuration').params()
|
|
337
|
+
|
|
338
|
+
def set_configuration(self, **params) -> None:
|
|
339
|
+
"""Update one or more configuration parameters in a single command.
|
|
340
|
+
|
|
341
|
+
Pass snake_case keyword args; they're translated to the protocol's
|
|
342
|
+
``{param}: {value}`` form (underscores -> spaces) and stacked into
|
|
343
|
+
a single ``configuration:`` line so the deck applies them as a
|
|
344
|
+
batch. Booleans render as ``true`` / ``false`` strings.
|
|
345
|
+
|
|
346
|
+
Examples::
|
|
347
|
+
|
|
348
|
+
hd.set_configuration(file_format='H.264High')
|
|
349
|
+
hd.set_configuration(file_format='QuickTimeProResHQ',
|
|
350
|
+
default_standard='2160p25')
|
|
351
|
+
hd.set_configuration(record_cache=True)
|
|
352
|
+
|
|
353
|
+
Common parameter names (see ``HyperDeckEthernetProtocol.pdf`` for
|
|
354
|
+
the full enum on each):
|
|
355
|
+
|
|
356
|
+
===================== =========================================
|
|
357
|
+
snake_case kwarg Protocol field
|
|
358
|
+
===================== =========================================
|
|
359
|
+
``file_format`` H.264High, QuickTimeProResHQ, DNxHR_HQX, …
|
|
360
|
+
``default_standard`` 1080p25, 2160p50, 720p5994, …
|
|
361
|
+
``video_input`` SDI, 4xSDI, HDMI, component, composite
|
|
362
|
+
``audio_input`` embedded, XLR, RCA
|
|
363
|
+
``audio_codec`` PCM, AAC
|
|
364
|
+
``record_prefix`` str (UTF-8)
|
|
365
|
+
``record_cache`` bool
|
|
366
|
+
``append_timestamp`` bool
|
|
367
|
+
===================== =========================================
|
|
368
|
+
|
|
369
|
+
A no-args call is a no-op; the protocol would reject an empty
|
|
370
|
+
``configuration:`` anyway.
|
|
371
|
+
|
|
372
|
+
Note: BMD's docs say changing ``file_format`` *may* respond with
|
|
373
|
+
``213 deck rebooting`` (a 2xx success code) instead of ``200 ok``
|
|
374
|
+
and close the connection. Both are treated as success here. In
|
|
375
|
+
testing, a Studio HD Mini did not reboot on the format changes
|
|
376
|
+
exercised, so no auto-reconnect logic is wired up; catch
|
|
377
|
+
``OSError`` on the next call if you trip the rare case.
|
|
378
|
+
"""
|
|
379
|
+
if not params:
|
|
380
|
+
return
|
|
381
|
+
parts = []
|
|
382
|
+
for key, value in params.items():
|
|
383
|
+
proto_key = key.replace('_', ' ')
|
|
384
|
+
if isinstance(value, bool):
|
|
385
|
+
proto_value = 'true' if value else 'false'
|
|
386
|
+
else:
|
|
387
|
+
proto_value = str(value)
|
|
388
|
+
parts.append(f'{proto_key}: {proto_value}')
|
|
389
|
+
self._check_ok('configuration: ' + ' '.join(parts))
|
|
390
|
+
|
|
391
|
+
def disk_list(self, slot_id: Optional[int] = None) -> List[Clip]:
|
|
392
|
+
"""List clips on the active disk (or specified slot)."""
|
|
393
|
+
cmd = 'disk list' if slot_id is None else f'disk list: slot id: {slot_id}'
|
|
394
|
+
resp = self._check_ok(cmd)
|
|
395
|
+
return _parse_clip_lines(resp.lines, _parse_disk_line)
|
|
396
|
+
|
|
397
|
+
def clips_count(self) -> int:
|
|
398
|
+
"""Return the number of clips on the current timeline."""
|
|
399
|
+
resp = self._check_ok('clips count')
|
|
400
|
+
return int(resp.params().get('clip count', '0'))
|
|
401
|
+
|
|
402
|
+
def clips_get(self) -> List[Clip]:
|
|
403
|
+
"""Return all clips currently on the timeline (version 1 format)."""
|
|
404
|
+
resp = self._check_ok('clips get')
|
|
405
|
+
return _parse_clip_lines(resp.lines, _parse_clips_v1_line)
|
|
406
|
+
|
|
407
|
+
# ------------------------------------------------------------------
|
|
408
|
+
# Action commands (write)
|
|
409
|
+
# ------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
def stop(self) -> None:
|
|
412
|
+
"""Stop playback or recording."""
|
|
413
|
+
self._check_ok('stop')
|
|
414
|
+
|
|
415
|
+
def play(self, *,
|
|
416
|
+
loop: bool = False,
|
|
417
|
+
single_clip: bool = False,
|
|
418
|
+
speed: Optional[int] = None,
|
|
419
|
+
clip_id: Optional[int] = None) -> None:
|
|
420
|
+
"""Start playback. All parameters optional; defaults to a plain ``play``.
|
|
421
|
+
|
|
422
|
+
``speed`` is a percentage between -5000 and 5000 (100 = normal).
|
|
423
|
+
``clip_id`` starts playback at the given clip on the timeline.
|
|
424
|
+
"""
|
|
425
|
+
parts = []
|
|
426
|
+
if clip_id is not None:
|
|
427
|
+
parts.append(f'clip id: {clip_id}')
|
|
428
|
+
if loop:
|
|
429
|
+
parts.append('loop: true')
|
|
430
|
+
if single_clip:
|
|
431
|
+
parts.append('single clip: true')
|
|
432
|
+
if speed is not None:
|
|
433
|
+
parts.append(f'speed: {int(speed)}')
|
|
434
|
+
cmd = 'play' if not parts else 'play: ' + ' '.join(parts)
|
|
435
|
+
self._check_ok(cmd)
|
|
436
|
+
|
|
437
|
+
def pause(self) -> None:
|
|
438
|
+
"""Pause playback, holding the current frame.
|
|
439
|
+
|
|
440
|
+
The HyperDeck protocol has no literal ``pause`` verb — pausing is
|
|
441
|
+
``play`` at zero speed, which freezes on the current frame (vs ``stop``
|
|
442
|
+
which ends playback). ``play()`` resumes at normal speed.
|
|
443
|
+
"""
|
|
444
|
+
self._check_ok('play: speed: 0')
|
|
445
|
+
|
|
446
|
+
def clips_clear(self) -> None:
|
|
447
|
+
"""Empty the current timeline (does not delete files on disk)."""
|
|
448
|
+
self._check_ok('clips clear')
|
|
449
|
+
|
|
450
|
+
def clips_add(self, name: str,
|
|
451
|
+
*, before_clip_id: Optional[int] = None) -> None:
|
|
452
|
+
"""Append a clip to the timeline (or insert before ``before_clip_id``).
|
|
453
|
+
|
|
454
|
+
``name`` may include subfolders (``folder/clip.mp4``). Names with
|
|
455
|
+
spaces are passed through verbatim — the protocol parses the
|
|
456
|
+
parameter value as everything after ``name:`` until end-of-line.
|
|
457
|
+
"""
|
|
458
|
+
if before_clip_id is not None:
|
|
459
|
+
cmd = f'clips add: clip id: {before_clip_id} name: {name}'
|
|
460
|
+
else:
|
|
461
|
+
cmd = f'clips add: name: {name}'
|
|
462
|
+
self._check_ok(cmd)
|
|
463
|
+
|
|
464
|
+
def clips_remove(self, clip_id: int) -> None:
|
|
465
|
+
"""Remove a clip from the timeline by id (invalidates ids after it)."""
|
|
466
|
+
self._check_ok(f'clips remove: clip id: {clip_id}')
|
|
467
|
+
|
|
468
|
+
def goto_clip(self, clip_id: int) -> None:
|
|
469
|
+
"""Seek the transport to the start of ``clip_id`` (does not play)."""
|
|
470
|
+
self._check_ok(f'goto: clip id: {clip_id}')
|
|
471
|
+
|
|
472
|
+
def remote_enable(self, enable: bool = True) -> None:
|
|
473
|
+
"""Enable or disable remote control."""
|
|
474
|
+
flag = 'true' if enable else 'false'
|
|
475
|
+
self._check_ok(f'remote: enable: {flag}')
|
|
476
|
+
|
|
477
|
+
def slot_select(self, slot_id: int) -> None:
|
|
478
|
+
"""Switch the active slot."""
|
|
479
|
+
self._check_ok(f'slot select: slot id: {slot_id}')
|
|
480
|
+
|
|
481
|
+
def ping(self) -> None:
|
|
482
|
+
"""Check the unit is responding. Raises on error / disconnect."""
|
|
483
|
+
self._check_ok('ping')
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# ---------------------------------------------------------------------------
|
|
487
|
+
# Line parsers
|
|
488
|
+
# ---------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
def _looks_like_clip_line(line: str) -> bool:
|
|
491
|
+
"""True if the line begins with ``<int>:`` (a clip row) rather than
|
|
492
|
+
a header like ``slot id: 1`` or ``clip count: 5``."""
|
|
493
|
+
head, _, _ = line.strip().partition(':')
|
|
494
|
+
return head.isdigit()
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _parse_clip_lines(lines, parser) -> List[Clip]:
|
|
498
|
+
"""Apply ``parser`` to every clip-shaped line, skipping (and logging)
|
|
499
|
+
any single row the parser rejects.
|
|
500
|
+
|
|
501
|
+
One malformed row from the deck must not blow away the whole list;
|
|
502
|
+
callers need whatever clips parsed cleanly, not an exception. Header/non-clip lines are filtered first as before."""
|
|
503
|
+
clips: List[Clip] = []
|
|
504
|
+
for line in lines:
|
|
505
|
+
if not _looks_like_clip_line(line):
|
|
506
|
+
continue
|
|
507
|
+
try:
|
|
508
|
+
clips.append(parser(line))
|
|
509
|
+
except (ValueError, IndexError) as e:
|
|
510
|
+
logger.warning('hyperdeck: skipping unparseable clip line %r: %s',
|
|
511
|
+
line, e)
|
|
512
|
+
return clips
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _parse_disk_line(line: str) -> Clip:
|
|
516
|
+
"""Parse a ``disk list`` row (version 1).
|
|
517
|
+
|
|
518
|
+
Format: ``{id}: {name} {file format} {video format} {duration}``
|
|
519
|
+
|
|
520
|
+
Filenames may contain spaces (verified against real hardware:
|
|
521
|
+
"Intro Loop animation.mp4"). We pull the three trailing tokens
|
|
522
|
+
(duration, video_format, file_format) and treat the rest as name.
|
|
523
|
+
"""
|
|
524
|
+
head, _, rest = line.strip().partition(':')
|
|
525
|
+
clip_id = int(head.strip())
|
|
526
|
+
tokens = rest.strip().split(' ')
|
|
527
|
+
if len(tokens) < 4:
|
|
528
|
+
raise ValueError(f'malformed disk list line: {line!r}')
|
|
529
|
+
duration = tokens[-1]
|
|
530
|
+
video_format = tokens[-2]
|
|
531
|
+
file_format = tokens[-3]
|
|
532
|
+
name = ' '.join(tokens[:-3])
|
|
533
|
+
return Clip(clip_id=clip_id, name=name, duration=duration,
|
|
534
|
+
file_format=file_format, video_format=video_format)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _parse_clips_v1_line(line: str) -> Clip:
|
|
538
|
+
"""Parse a ``clips get`` row in version 1 format.
|
|
539
|
+
|
|
540
|
+
Format: ``{id}: {name} {startT} {duration}``
|
|
541
|
+
|
|
542
|
+
Same filename-with-spaces caveat as ``_parse_disk_line``; tokens
|
|
543
|
+
are parsed from the right.
|
|
544
|
+
"""
|
|
545
|
+
head, _, rest = line.strip().partition(':')
|
|
546
|
+
clip_id = int(head.strip())
|
|
547
|
+
tokens = rest.strip().split(' ')
|
|
548
|
+
if len(tokens) < 3:
|
|
549
|
+
raise ValueError(f'malformed clips line: {line!r}')
|
|
550
|
+
duration = tokens[-1]
|
|
551
|
+
start = tokens[-2]
|
|
552
|
+
name = ' '.join(tokens[:-2])
|
|
553
|
+
return Clip(clip_id=clip_id, name=name, duration=duration, start=start)
|
hyperdeckwire/upload.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""hyperdeckwire.upload — FTP file upload to a HyperDeck Studio HD-class unit.
|
|
3
|
+
|
|
4
|
+
The HTTP REST API for clip upload landed in firmware 8.x but only on
|
|
5
|
+
the Plus/Pro/HDR/Shuttle SKUs — the Studio HD Mini doesn't have it
|
|
6
|
+
(verified against firmware 8.1.1, port 80 closed). Every networked
|
|
7
|
+
HyperDeck has FTP though, so that's the path this module takes.
|
|
8
|
+
|
|
9
|
+
Wire details:
|
|
10
|
+
- Anonymous FTP (no auth required on stock firmware).
|
|
11
|
+
- Top-level dirs are storage volumes. Studio HD Mini names them
|
|
12
|
+
numerically per slot id (``/1/``, ``/2/``, ``/3/``); other models
|
|
13
|
+
(Studio HD, 4K) name them by medium (``sd1``, ``ssd1``, ``usb``).
|
|
14
|
+
Default behaviour: auto-detect a storage volume at root (numeric
|
|
15
|
+
first, else a known media prefix, SD preferred) and CWD into it.
|
|
16
|
+
- STOR at root is rejected (550 file unavailable); STOR inside a
|
|
17
|
+
slot dir works. The auto-detect handles that automatically.
|
|
18
|
+
- The Hyperdeck filesystem index updates live: after a successful
|
|
19
|
+
STOR the new clip is immediately visible via the 9993 ``disk list``
|
|
20
|
+
command, no rescan or slot-reselect required.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import ftplib
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DEFAULT_FTP_TIMEOUT = 120.0 # large clips can take a minute+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class UploadResult:
|
|
40
|
+
"""Successful upload outcome — returned by :func:`upload_clip`."""
|
|
41
|
+
|
|
42
|
+
name: str # the remote filename as written (basename of source)
|
|
43
|
+
size: int # bytes written
|
|
44
|
+
duration_seconds: float # wall-clock duration of the STOR
|
|
45
|
+
slot_dir: str # FTP working directory used (e.g. "1")
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def throughput_mb_s(self) -> float:
|
|
49
|
+
return self.size / max(self.duration_seconds, 0.001) / 1e6
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def upload_clip(host: str,
|
|
53
|
+
local_path: str,
|
|
54
|
+
*,
|
|
55
|
+
slot: Optional[int] = None,
|
|
56
|
+
login_user: str = '',
|
|
57
|
+
login_pass: str = '',
|
|
58
|
+
timeout: float = DEFAULT_FTP_TIMEOUT,
|
|
59
|
+
progress_callback=None) -> UploadResult:
|
|
60
|
+
"""Upload ``local_path`` to a HyperDeck at ``host`` over FTP.
|
|
61
|
+
|
|
62
|
+
:param host: HyperDeck IP or hostname.
|
|
63
|
+
:param local_path: absolute path to the source file. Basename
|
|
64
|
+
becomes the remote name (preserved as-is, spaces and all —
|
|
65
|
+
the Hyperdeck handles those fine on both FTP and 9993 sides).
|
|
66
|
+
:param slot: numeric slot id to upload into. ``None`` (default)
|
|
67
|
+
auto-detects the first numeric subdirectory at the FTP root.
|
|
68
|
+
:param login_user / login_pass: FTP credentials. Empty defaults
|
|
69
|
+
attempt anonymous login (works on stock firmware); the
|
|
70
|
+
anonymous-with-empty-fields retry handles servers that reject
|
|
71
|
+
the bare ``USER`` form.
|
|
72
|
+
:param timeout: FTP socket timeout in seconds.
|
|
73
|
+
:param progress_callback: optional callable taking the running
|
|
74
|
+
byte count; called after each chunk for big-file UX.
|
|
75
|
+
|
|
76
|
+
:raises FileNotFoundError: ``local_path`` doesn't exist.
|
|
77
|
+
:raises ftplib.all_errors: anything FTP-level (login, CWD, STOR).
|
|
78
|
+
"""
|
|
79
|
+
if not os.path.exists(local_path):
|
|
80
|
+
raise FileNotFoundError(local_path)
|
|
81
|
+
|
|
82
|
+
size = os.path.getsize(local_path)
|
|
83
|
+
name = os.path.basename(local_path)
|
|
84
|
+
logger.info('hyperdeckwire: starting upload %s -> %s:21 (%d bytes)',
|
|
85
|
+
local_path, host, size)
|
|
86
|
+
|
|
87
|
+
t0 = time.monotonic()
|
|
88
|
+
with ftplib.FTP(host, timeout=timeout) as ftp:
|
|
89
|
+
try:
|
|
90
|
+
ftp.login(login_user, login_pass)
|
|
91
|
+
except ftplib.error_perm:
|
|
92
|
+
# Some FTP servers reject the bare anonymous form; retry
|
|
93
|
+
# with the canonical ``anonymous`` user.
|
|
94
|
+
ftp.login('anonymous', '')
|
|
95
|
+
|
|
96
|
+
slot_dir = _resolve_slot_dir(ftp, slot)
|
|
97
|
+
ftp.cwd(slot_dir)
|
|
98
|
+
|
|
99
|
+
bytes_sent = [0]
|
|
100
|
+
|
|
101
|
+
def _on_chunk(chunk: bytes) -> None:
|
|
102
|
+
bytes_sent[0] += len(chunk)
|
|
103
|
+
if progress_callback is not None:
|
|
104
|
+
try:
|
|
105
|
+
progress_callback(bytes_sent[0])
|
|
106
|
+
except Exception:
|
|
107
|
+
logger.exception('upload progress_callback raised')
|
|
108
|
+
|
|
109
|
+
with open(local_path, 'rb') as f:
|
|
110
|
+
ftp.storbinary(f'STOR {name}', f, callback=_on_chunk)
|
|
111
|
+
|
|
112
|
+
dt = time.monotonic() - t0
|
|
113
|
+
result = UploadResult(name=name, size=size,
|
|
114
|
+
duration_seconds=dt, slot_dir=slot_dir)
|
|
115
|
+
logger.info('hyperdeckwire: upload OK %s (%.1fs, %.1f MB/s)',
|
|
116
|
+
name, dt, result.throughput_mb_s)
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# Storage-volume name prefixes HyperDeck firmware presents at FTP root, in
|
|
121
|
+
# preference order. Studio HD Mini exposes numeric dirs (``/1/``, ``/2/``);
|
|
122
|
+
# other models (e.g. Studio HD, 4K) expose named mounts like ``sd1`` /
|
|
123
|
+
# ``ssd1`` / ``usb``. SD is preferred since that's the usual record/playback
|
|
124
|
+
# medium. ``System Volume Information`` / ``.Trashes`` are never matched.
|
|
125
|
+
_MEDIA_DIR_PREFIXES = ('sd', 'ssd', 'usb', 'nas')
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve_slot_dir(ftp: ftplib.FTP, slot=None) -> str:
|
|
129
|
+
"""Return the slot dir to CWD into. A caller-supplied ``slot`` wins
|
|
130
|
+
(an int slot id like ``2`` or a literal dir name like ``'sd1'``);
|
|
131
|
+
otherwise list root and pick a storage volume.
|
|
132
|
+
|
|
133
|
+
Numeric slot dirs win when present (legacy firmware). Otherwise the
|
|
134
|
+
first directory matching a known media prefix (sd/ssd/usb/nas) is
|
|
135
|
+
used, so newer models that name their mounts (``['sd1', 'usb']``)
|
|
136
|
+
work too."""
|
|
137
|
+
if slot is not None:
|
|
138
|
+
return str(slot)
|
|
139
|
+
entries = ftp.nlst()
|
|
140
|
+
numeric = sorted([e for e in entries if e.isdigit()], key=int)
|
|
141
|
+
if numeric:
|
|
142
|
+
return numeric[0]
|
|
143
|
+
for prefix in _MEDIA_DIR_PREFIXES:
|
|
144
|
+
named = sorted([e for e in entries if e.lower().startswith(prefix)])
|
|
145
|
+
if named:
|
|
146
|
+
return named[0]
|
|
147
|
+
raise OSError(
|
|
148
|
+
f'no slot dir found at FTP root (entries: {entries!r})')
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hyperdeckwire
|
|
3
|
+
Version: 0.1.0.dev0
|
|
4
|
+
Summary: Blackmagic HyperDeck control over the Ethernet Protocol (TCP 9993) plus FTP clip upload
|
|
5
|
+
Author: Lucas Romanenko
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest; extra == "test"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# hyperdeckwire
|
|
18
|
+
|
|
19
|
+
hyperdeckwire is a small, dependency-free Python library for driving
|
|
20
|
+
Blackmagic **HyperDeck Studio** recorders over the network. It speaks the
|
|
21
|
+
HyperDeck Ethernet Protocol (line-oriented text over TCP 9993) for transport
|
|
22
|
+
control, clip listing and timeline editing, and uses the deck's built-in FTP
|
|
23
|
+
server (port 21) to push new clips onto its storage. It targets the
|
|
24
|
+
`9993 + FTP` combination on purpose: the HTTP REST API that arrived in
|
|
25
|
+
firmware 8.x is only available on the Plus/Pro/HDR/Shuttle models, while every
|
|
26
|
+
networked HyperDeck, including the Studio HD Mini, offers these two.
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- Blocking, single-socket `Hyperdeck` client with an explicit connect/close lifecycle and context-manager support.
|
|
31
|
+
- Read commands: `device_info`, `remote_info`, `slot_info`, `transport_info`, `configuration`, `disk_list`, `clips_get`, `clips_count`.
|
|
32
|
+
- Write commands: `play` (loop, single clip, speed, clip id), `pause`, `stop`, `goto_clip`, `clips_add`, `clips_remove`, `clips_clear`, `slot_select`, `remote_enable`, `set_configuration`, `ping`.
|
|
33
|
+
- Typed `Clip` and `Response` dataclasses; protocol errors raise `HyperdeckError` with the deck's code and text.
|
|
34
|
+
- Asynchronous 5xx notifications are filtered out of blocking requests and can be read explicitly.
|
|
35
|
+
- `upload_clip` FTP helper with storage-volume auto-detection, anonymous-login fallback, progress callback and throughput reporting.
|
|
36
|
+
- Pure standard library; a `socket_factory` hook and `ftplib` monkeypatching make the whole suite runnable without hardware.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pip install "git+https://github.com/lucas-romanenko/hyperdeckwire.git@v0.1.0.dev0"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Python 3.10 or newer. To run the tests from a checkout:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
pip install ".[test]"
|
|
48
|
+
python -m pytest
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from hyperdeckwire import Hyperdeck, HyperdeckError, upload_clip
|
|
55
|
+
|
|
56
|
+
# Push a clip onto the deck's active storage volume.
|
|
57
|
+
result = upload_clip('192.0.2.11', '/local/path/intro.mp4')
|
|
58
|
+
print(f'{result.name}: {result.throughput_mb_s:.1f} MB/s into slot {result.slot_dir}')
|
|
59
|
+
|
|
60
|
+
# Cue it and loop it.
|
|
61
|
+
with Hyperdeck('192.0.2.11') as hd:
|
|
62
|
+
print(hd.model, hd.protocol_version)
|
|
63
|
+
for clip in hd.disk_list():
|
|
64
|
+
print(clip.clip_id, clip.name, clip.duration)
|
|
65
|
+
hd.stop()
|
|
66
|
+
hd.clips_clear()
|
|
67
|
+
try:
|
|
68
|
+
hd.clips_add('intro.mp4')
|
|
69
|
+
except HyperdeckError as e:
|
|
70
|
+
raise SystemExit(f'deck refused the clip: {e}')
|
|
71
|
+
hd.play(loop=True, single_clip=True)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The full command reference, dataclass fields and error-code table are in
|
|
75
|
+
[docs/API.md](docs/API.md).
|
|
76
|
+
|
|
77
|
+
## Protocol notes
|
|
78
|
+
|
|
79
|
+
The Ethernet Protocol itself is documented by Blackmagic in
|
|
80
|
+
`HyperDeckEthernetProtocol.pdf` (December 2024 revision). The points below
|
|
81
|
+
are behaviour the library relies on that the document does not spell out, or
|
|
82
|
+
that was established on hardware.
|
|
83
|
+
|
|
84
|
+
- **No REST API on the Studio HD Mini.** Port 80 is closed on firmware 8.1.1, so the library never depends on HTTP.
|
|
85
|
+
- **The greeting is a 5xx.** On connect the deck sends `500 connection info:` as a multi-line block. It shares the code range of asynchronous notifications but is synchronous and arrives once; the client reads it eagerly and caches `model` and `protocol version` from it.
|
|
86
|
+
- **Multi-line framing.** A response is multi-line if and only if its head line ends with a colon; the body is then read until a blank line. Async notifications can interleave with a pending response and are skipped inside `request()` by default.
|
|
87
|
+
- **There is no pause verb.** `pause()` sends `play: speed: 0`, which freezes on the current frame. `stop` also holds the last frame under the factory `stop mode: lastframe` setting; the transport reports `stopped` in both cases.
|
|
88
|
+
- **`213 deck rebooting` is a success.** A `file format` change may answer with 213 instead of `200 ok` and drop the connection. The client treats both as success and leaves reconnecting to the caller.
|
|
89
|
+
- **FTP volume layout.** Storage volumes are top-level directories. The Studio HD Mini names them by slot number (`/1/`, `/2/`); other models name them by medium (`sd1`, `ssd1`, `usb`, `nas`). `STOR` at the root is refused with `550`, so `upload_clip` lists the root, picks a volume (numeric first, then SD, SSD, USB, NAS) and changes into it. `System Volume Information` and `.Trashes` are never selected.
|
|
90
|
+
- **FTP login.** Stock firmware accepts an empty anonymous login; some servers reject the bare `USER` form, so the helper retries as `anonymous` before failing.
|
|
91
|
+
- **The disk index updates live.** A clip is visible to `disk list` immediately after its `STOR` completes; no rescan or slot reselect is needed.
|
|
92
|
+
- **Clip names contain spaces.** `disk list` and `clips get` rows are tokenised from the right (duration, format fields) and everything left over is the name, which is why a name such as `Intro Loop animation.mp4` round-trips.
|
|
93
|
+
- **Client limit.** Beyond a small number of simultaneous 9993 clients the deck answers `120 connection failed` and closes the socket.
|
|
94
|
+
- **Verified hardware.** HyperDeck Studio HD Mini, firmware 8.1.1, full probe / clear / upload / cue-and-loop cycle. Other Studio HD models speak the same protocol but were not on the bench.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
hyperdeckwire/__init__.py,sha256=5e9xA895FNwzqj03z-WbZhrM5o7blSEaJmeZb_4qeq8,1275
|
|
2
|
+
hyperdeckwire/client.py,sha256=QNAj2Sr3oAYGqLeIPFAQBhL05QGrZ-461J0W6CKZH0Q,21160
|
|
3
|
+
hyperdeckwire/upload.py,sha256=CPUT5gYtz_Dfnp-lYqHO4STSEKc3YfMsUXZMx0JHEXw,5937
|
|
4
|
+
hyperdeckwire-0.1.0.dev0.dist-info/licenses/LICENSE,sha256=Mzt4-lP7NpZGbjxKnwYliRFTN_K-BJhL4CBIMWJO_jM,1072
|
|
5
|
+
hyperdeckwire-0.1.0.dev0.dist-info/METADATA,sha256=0dvvWH5mDVSJEP37yMLMwJVOy6nCrCRRjibIwTdl7DA,5677
|
|
6
|
+
hyperdeckwire-0.1.0.dev0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
hyperdeckwire-0.1.0.dev0.dist-info/top_level.txt,sha256=0lB6e9ShXX_UoU8a5Yd1dXOhcgmM7PsB-GApPuVDVsY,14
|
|
8
|
+
hyperdeckwire-0.1.0.dev0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucas Romanenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hyperdeckwire
|