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.
@@ -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
+ ]
@@ -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)
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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