bmdwire 1.5.0__tar.gz → 1.5.1__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.5.0/bmdwire.egg-info → bmdwire-1.5.1}/PKG-INFO +2 -2
  2. {bmdwire-1.5.0 → bmdwire-1.5.1}/README.md +1 -1
  3. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/docs/MEDIA_LOCK.md +52 -1
  4. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/docs/SURFACE.md +15 -2
  5. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/protocol.py +129 -43
  6. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/transport.py +24 -0
  7. {bmdwire-1.5.0 → bmdwire-1.5.1/bmdwire.egg-info}/PKG-INFO +2 -2
  8. {bmdwire-1.5.0 → bmdwire-1.5.1}/pyproject.toml +1 -1
  9. {bmdwire-1.5.0 → bmdwire-1.5.1}/LICENSE +0 -0
  10. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/LICENSE +0 -0
  11. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/LICENSE-gpl3.txt +0 -0
  12. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/NOTICE +0 -0
  13. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/__init__.py +0 -0
  14. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/_socketqueue.py +0 -0
  15. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/_transfer.py +0 -0
  16. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/atem.py +0 -0
  17. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/connection.py +0 -0
  18. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/docs/MACRO_FORMAT.md +0 -0
  19. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/docs/PROFILE_FORMAT.md +0 -0
  20. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/docs/PROFILE_SAVE_RESTORE.md +0 -0
  21. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/helpers.py +0 -0
  22. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/imaging.py +0 -0
  23. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/macrotransfer/__init__.py +0 -0
  24. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/macrotransfer/_helpers.py +0 -0
  25. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/macrotransfer/_io.py +0 -0
  26. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/macrotransfer/downstream_keyer.py +0 -0
  27. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/macrotransfer/fairlight.py +0 -0
  28. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/macrotransfer/flow.py +0 -0
  29. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/macrotransfer/hyperdeck.py +0 -0
  30. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/macrotransfer/media.py +0 -0
  31. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/macrotransfer/switching.py +0 -0
  32. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/macrotransfer/upstream_keyer.py +0 -0
  33. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/mediaconvertmodule.c +0 -0
  34. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/__init__.py +0 -0
  35. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/_dsl.py +0 -0
  36. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/audio_legacy.py +0 -0
  37. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/camera_control.py +0 -0
  38. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/color_generator.py +0 -0
  39. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/downstream_keyer.py +0 -0
  40. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/fade_to_black.py +0 -0
  41. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/fairlight.py +0 -0
  42. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/file_transfer.py +0 -0
  43. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/hyperdeck.py +0 -0
  44. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/input_video.py +0 -0
  45. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/lock.py +0 -0
  46. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/macros.py +0 -0
  47. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/manual.py +0 -0
  48. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/media.py +0 -0
  49. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/meters.py +0 -0
  50. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/multiviewer.py +0 -0
  51. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/recording.py +0 -0
  52. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/streaming.py +0 -0
  53. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/supersource.py +0 -0
  54. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/switching.py +0 -0
  55. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/system_info.py +0 -0
  56. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/tally.py +0 -0
  57. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/transition.py +0 -0
  58. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/messages/upstream_keyer.py +0 -0
  59. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/pool.py +0 -0
  60. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/probe.py +0 -0
  61. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/profile/__init__.py +0 -0
  62. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/profile/_common.py +0 -0
  63. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/profile/_enums.py +0 -0
  64. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/profile/_xml.py +0 -0
  65. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/profile/apply.py +0 -0
  66. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/profile/options.py +0 -0
  67. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/profile/save.py +0 -0
  68. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/py.typed +0 -0
  69. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/ready.py +0 -0
  70. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/state.py +0 -0
  71. {bmdwire-1.5.0 → bmdwire-1.5.1}/atemwire/testing.py +0 -0
  72. {bmdwire-1.5.0 → bmdwire-1.5.1}/bmdwire.egg-info/SOURCES.txt +0 -0
  73. {bmdwire-1.5.0 → bmdwire-1.5.1}/bmdwire.egg-info/dependency_links.txt +0 -0
  74. {bmdwire-1.5.0 → bmdwire-1.5.1}/bmdwire.egg-info/requires.txt +0 -0
  75. {bmdwire-1.5.0 → bmdwire-1.5.1}/bmdwire.egg-info/top_level.txt +0 -0
  76. {bmdwire-1.5.0 → bmdwire-1.5.1}/hyperdeckwire/LICENSE +0 -0
  77. {bmdwire-1.5.0 → bmdwire-1.5.1}/hyperdeckwire/__init__.py +0 -0
  78. {bmdwire-1.5.0 → bmdwire-1.5.1}/hyperdeckwire/client.py +0 -0
  79. {bmdwire-1.5.0 → bmdwire-1.5.1}/hyperdeckwire/setup.py +0 -0
  80. {bmdwire-1.5.0 → bmdwire-1.5.1}/hyperdeckwire/upload.py +0 -0
  81. {bmdwire-1.5.0 → bmdwire-1.5.1}/setup.cfg +0 -0
  82. {bmdwire-1.5.0 → bmdwire-1.5.1}/setup.py +0 -0
  83. {bmdwire-1.5.0 → bmdwire-1.5.1}/ultimattewire/LICENSE +0 -0
  84. {bmdwire-1.5.0 → bmdwire-1.5.1}/ultimattewire/__init__.py +0 -0
  85. {bmdwire-1.5.0 → bmdwire-1.5.1}/ultimattewire/_preamble.py +0 -0
  86. {bmdwire-1.5.0 → bmdwire-1.5.1}/ultimattewire/_protocol.py +0 -0
  87. {bmdwire-1.5.0 → bmdwire-1.5.1}/ultimattewire/network.py +0 -0
  88. {bmdwire-1.5.0 → bmdwire-1.5.1}/ultimattewire/profile/__init__.py +0 -0
  89. {bmdwire-1.5.0 → bmdwire-1.5.1}/ultimattewire/profile/_ranges.py +0 -0
  90. {bmdwire-1.5.0 → bmdwire-1.5.1}/ultimattewire/profile/archive.py +0 -0
  91. {bmdwire-1.5.0 → bmdwire-1.5.1}/ultimattewire/profile/restore.py +0 -0
  92. {bmdwire-1.5.0 → bmdwire-1.5.1}/videohubwire/LICENSE +0 -0
  93. {bmdwire-1.5.0 → bmdwire-1.5.1}/videohubwire/__init__.py +0 -0
  94. {bmdwire-1.5.0 → bmdwire-1.5.1}/videohubwire/client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bmdwire
3
- Version: 1.5.0
3
+ Version: 1.5.1
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
@@ -49,7 +49,7 @@ pip install bmdwire
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.5.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.5.1`) so a later release cannot change your install under you.
53
53
 
54
54
  ## atemwire stands on pyatem
55
55
 
@@ -15,7 +15,7 @@ pip install bmdwire
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.5.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.5.1`) so a later release cannot change your install under you.
19
19
 
20
20
  ## atemwire stands on pyatem
21
21
 
@@ -26,6 +26,15 @@ one) did not hold on this hardware.
26
26
  on `LKOB`, not only on the release broadcast.
27
27
  * A `LOCK` (full store) or `PLCK` sent by a session that **already holds**
28
28
  the store gets no answer.
29
+ * A `LOCK` sent while another session holds the store is **queued** too,
30
+ like a `PLCK` (ATEM Software Control's, 2026-10-15; this package's
31
+ comments used to say it was dropped). Handed the store at the holder's
32
+ release, the requester gets `LKOB` and **nothing is announced**, no
33
+ `LKST` either way.
34
+ * A `PLCK` is granted beside another session's `LOCK`, even half a second
35
+ into its turn; the `FTSU` that follows aborts the other's transfer in
36
+ progress with `FTDE 6` (see "Sharing the store with ATEM Software
37
+ Control").
29
38
  * A `LOCK state=0` from a session that does not hold the store does
30
39
  nothing visible (this was tested only while a foreign client held it).
31
40
 
@@ -180,5 +189,47 @@ holder, which the switcher ignores (see "Requests and grants"). Waiting
180
189
  for the echo hid it, at a frame per transfer at best and up to 6.6 s
181
190
  measured, during a 20-still restore by bmdctl, which mirrors this code.
182
191
  Since `bd6ae77` atemwire holds the lock from `LKOB` to its own release, as
183
- ASC does, and sends the release and the next request in one packet.
192
+ ASC does. That commit also sent the release and the next request in one
193
+ packet, as ASC does; that shut ASC out, and since `5827626` the next
194
+ request waits its turn (below).
195
+
196
+ ## Sharing the store with ATEM Software Control
197
+
198
+ Three tests on 2026-10-15, ATEM Software Control connecting to the same
199
+ switcher while a client of this package downloaded stills back to back,
200
+ two of them captured on the ASC machine.
201
+
202
+ * When it connects, ASC fetches every used still for its thumbnails, one
203
+ at a time, each under a whole-store `LOCK`. A `LOCK` sent while the
204
+ other client held the store was queued and handed to ASC at that
205
+ client's release, with `LKOB` and no `LKST`.
206
+ * With 1.5.0 the other client then asked again at once (its release and
207
+ next request in one packet), or within half a second: its `PLCK` was
208
+ granted beside ASC's `LOCK`, and its `FTSU` aborted ASC's download in
209
+ progress, 230 to 500 ms in, with `FTDE 6`.
210
+ * ASC reacts to the 6 as the notes above say a client should: it
211
+ releases and asks again in one packet, is queued, and is handed the
212
+ store at the next release, to be aborted again. **But it tries each
213
+ still once**: after the 6 it moves on to the next. After 20 stills and
214
+ 20 `FTDE 6` it released the store and asked for nothing more, its
215
+ thumbnails left empty. It fetched them again only when the media pool
216
+ changed (a still cleared from another client).
217
+ * Between its own stills ASC releases and asks again in one packet; the
218
+ switcher announces that as a release and a lock, often in one packet.
219
+ * What lets ASC have its turn (`5827626`): after a release with more
220
+ work queued, ask again only once the store is announced free, and a
221
+ frame later (`LOCK_COURTESY`, 60 ms), so a lock announced right behind
222
+ the release (another client's next transfer) cancels the request;
223
+ never ask while another client is seen holding the store; and with no
224
+ announcement at all (the store handed to a waiting request), wait as
225
+ long as anyone is seen at work, another session's `FTDC` or any `LKST`,
226
+ asking after `LOCK_QUIET` (10 s) of quiet. With it ASC loaded all 20
227
+ thumbnails while the other client downloaded; the download paused 38 s
228
+ for ASC's 20 and went on. Alone, 20 uploads take 14.2 s, against
229
+ 12.7 s with 1.5.0 and about 12 s for ASC.
230
+ * atemwire 1.4.0 waited for its own release's `LKST` echo before asking
231
+ again, so it waited out a client handed the store too, by accident.
232
+ bmdctl, which mirrors this code, saw that as unexplained pauses of 2 to
233
+ 6.6 s between stills in a restore: WebATEM, open on the same switcher,
234
+ was downloading each newly uploaded still for its thumbnail.
184
235
 
@@ -500,7 +500,9 @@ in the transfer flow. At field level, the `CKDV`/`CTDv` fields are all echoed, b
500
500
  repeated within 1.0 s (`LOCK_REREQUEST_SECONDS`). There is no timer-driven retry: a dropped
501
501
  request is re-sent only on the next event. Since `bd6ae77` the release and the next request go
502
502
  in one packet, as ATEM Software Control sends them, and a release broadcast while the store is
503
- held changes nothing; after an FTDE 6 the release goes alone (§5.4 item 21).
503
+ held changes nothing; after an FTDE 6 the release goes alone (§5.4 item 21). Since `5827626`
504
+ the release goes alone and the next request waits for the store to be announced free and a
505
+ frame more, never while another client holds it (§5.4 item 26).
504
506
  - **`FTDE` status handling:**
505
507
  - 1 (try again): re-send the request with the same tid.
506
508
  - 5 (no lock): re-request the lock.
@@ -915,7 +917,8 @@ Items 1, 2 and 4 were fixed after 1.2.0, in the commits named in each; they stil
915
917
  1.2.0 and earlier, including the 1.1.0 that WebATEM pins. The fly-key bullets of items 9 and
916
918
  10, the split-mono bullet of item 8, the float and `sourceId` bullets of item 9, items 15, 16,
917
919
  18, 19 and 20, and part of item 17 were fixed after 1.3.0 and still apply to it. Items 21 to 25
918
- were fixed after 1.4.0 and still apply to it.
920
+ were fixed after 1.4.0 and still apply to it. Item 26 was fixed after 1.5.0 and still applies to
921
+ it.
919
922
 
920
923
  1. **Profile video mode does not round-trip at fractional rates** (1.2.0). Save writes
921
924
  `get_label()` (`1080p59.94`); apply only knows `1080p5994`-style names, so it skips the mode as
@@ -1076,6 +1079,16 @@ were fixed after 1.4.0 and still apply to it.
1076
1079
  the switcher sees no gap, and an upload loses a chunk without a word. *Fixed in `bd6ae77`:*
1077
1080
  `ENOBUFS` and `EAGAIN` are retried for up to 20 ms, and a reliable packet still refused keeps
1078
1081
  its number and its copy for retransmission.
1082
+ 26. **Another client's turn is cut short** (1.5.0). The release and the next lock request go in
1083
+ one packet (item 21). Another client's request waiting for the store is handed it at the
1084
+ release, unannounced; the next request is granted beside it (a `PLCK` beside a `LOCK`) and
1085
+ its transfer aborts theirs with `FTDE 6`. ATEM Software Control, which tries each still once
1086
+ when it connects, is left without its thumbnails (`docs/MEDIA_LOCK.md`, "Sharing the store
1087
+ with ATEM Software Control"). *Fixed in `5827626`:* the release goes alone; the next request
1088
+ waits for the store to be announced free (`LKST`) and `LOCK_COURTESY` (60 ms) more, never
1089
+ while another client is seen holding the store, and with no announcement waits while anyone
1090
+ is seen at work, asking after `LOCK_QUIET` (10 s) of quiet. `UdpProtocol.wake_at` wakes the
1091
+ protocol for it.
1079
1092
 
1080
1093
  ## Counts
1081
1094
 
@@ -33,10 +33,34 @@ def _is_tcp_transport(transport) -> bool:
33
33
 
34
34
  # A lock request (LOCK / PLCK) with no LKOB back after this long is sent
35
35
  # again on the next trigger. Measured: a granted request answers in ~1 ms;
36
- # a queued PLCK answers the moment the holder releases; a full LOCK sent
37
- # while another session holds the store is simply dropped by the switcher.
36
+ # a queued one (PLCK, and a full LOCK too: ATEM Software Control's, in a
37
+ # capture of 2026-10-15) the moment the holder releases.
38
38
  LOCK_REREQUEST_SECONDS = 1.0
39
39
 
40
+ # Another client's turn. A request of another client's that waited for
41
+ # the store is granted the moment we release it, and the switcher grants a
42
+ # request of ours sent soon after too (two sessions asking within ~150 ms
43
+ # can both get LKOB; MEDIA_LOCK.md). Our transfer then aborts theirs with
44
+ # FTDE 6, and ATEM Software Control, which tries each still once when it
45
+ # connects and again only when the media pool changes, is left without
46
+ # its thumbnails (a capture, 2026-10-15: 20 stills, 20 FTDE 6, while a
47
+ # client of this package downloaded back to back; 1.5.0 sent the release
48
+ # and the next request in one packet). A request handed the store at our
49
+ # release isn't announced at all (no LKST either way), and a PLCK of ours
50
+ # is granted beside another client's LOCK even half a second into its
51
+ # turn, its transfer aborted (a second capture, the same day). So after a
52
+ # per-frame release with more work queued for the store, the next request
53
+ # waits for the store to be announced free (LKST), then LOCK_COURTESY more
54
+ # (a frame, for anyone else to take it); a store another client is seen
55
+ # holding (its LKST) is not asked for until announced free. No
56
+ # announcement means someone was handed the store: we wait, as long as
57
+ # anyone is seen at work (another session's FTDC, any LKST), and ask only
58
+ # after LOCK_QUIET with no sign of anyone. bmdwire 1.4.0 waited the same
59
+ # way for the release's echo, by accident: its long waits in a restore
60
+ # were WebATEM refreshing each new still's thumbnail.
61
+ LOCK_COURTESY = 0.06
62
+ LOCK_QUIET = 10.0
63
+
40
64
  # A state dump that ends (InCm) without a video mode has lost its first
41
65
  # packet for good: the transport takes the first reliable packet that
42
66
  # arrives as the session's baseline, so one lost before it is never resent
@@ -174,6 +198,13 @@ class AtemProtocol:
174
198
  # Cleared by the LKOB, by the store's LKST release broadcast (the
175
199
  # pounce re-requests anyway), and by every lane reset.
176
200
  self._lock_requested = {}
201
+ # store -> [since, announced]: for a store we released and have more
202
+ # work for (LOCK_COURTESY), when we released it or last saw anyone
203
+ # at work, and when it was announced free (None until it is).
204
+ self._yielding = {}
205
+ # Stores another client holds, from its LKST until its release's:
206
+ # we don't ask for them meanwhile (LOCK_COURTESY).
207
+ self._foreign_held = set()
177
208
  self.mode = None
178
209
  self.transfer_queue = {}
179
210
  self.transfer_id = 42
@@ -221,7 +252,9 @@ class AtemProtocol:
221
252
  if isinstance(packet, Wakeup):
222
253
  # Caller-side wake-up to drain a pending outbound command — the
223
254
  # worker thread (ATEMConnection._run) will pick up the queue
224
- # right after this returns. No state to mutate here.
255
+ # right after this returns — or the transport's, for the lock
256
+ # yield (wake_at): a store left free may be asked for now.
257
+ self._service_yields()
225
258
  return
226
259
  self.connected = True
227
260
  if isinstance(packet, TransferQueueFlushed):
@@ -336,8 +369,13 @@ class AtemProtocol:
336
369
  # and silent, so mixerstate['lock-state'] only ever showed the
337
370
  # last release (bmdwire#15). The packet doesn't name the holder:
338
371
  # held by someone else is state=True while self.locks.get(store)
339
- # is falsy.
340
- pass
372
+ # is falsy. Held by someone else, we don't ask for it until they
373
+ # release it (LOCK_COURTESY), and a store we were leaving free
374
+ # is theirs now.
375
+ if not self.locks.get(contents.store):
376
+ self._foreign_held.add(contents.store)
377
+ if contents.store in self._yielding:
378
+ self._yielding[contents.store] = [time.monotonic(), None]
341
379
  elif key == 'lock-state' and self.locks.get(contents.store):
342
380
  # A release broadcast while we hold the store is the late echo
343
381
  # of our own previous release: the switcher announces lock
@@ -349,6 +387,22 @@ class AtemProtocol:
349
387
  f'our own earlier release, announced late')
350
388
  elif key == 'lock-state':
351
389
  self.log.debug(contents)
390
+ self._foreign_held.discard(contents.store)
391
+ # A release announced, ours or another client's: the store is
392
+ # seen free, and a request waits LOCK_COURTESY more for anyone
393
+ # else to take it. Another client releasing and taking the store
394
+ # again for its next transfer (ASC does, in one packet) is
395
+ # announced as a release then a lock, often in one packet: a
396
+ # request sent on the release would be granted alongside theirs
397
+ # and abort their transfer (TEST ATEM 2, 2026-10-15).
398
+ y = self._yielding.get(contents.store)
399
+ now = time.monotonic()
400
+ if y is None and self.transfer_queue.get(contents.store) and self._can_wake():
401
+ self._yielding[contents.store] = [now, now]
402
+ self._wake_at(now + LOCK_COURTESY)
403
+ elif y is not None and y[1] is None:
404
+ y[1] = now
405
+ self._wake_at(now + LOCK_COURTESY)
352
406
  # The lock is free: whatever request we had outstanding is
353
407
  # either about to be granted (a queued PLCK — the switcher
354
408
  # grants it directly, sometimes without even broadcasting the
@@ -491,8 +545,10 @@ class AtemProtocol:
491
545
  # this is another session's transfer (watcher, uploader, a
492
546
  # sibling operator) finishing. Expected on a shared setup.
493
547
  self.log.debug("FTDC for another session's transfer — ignoring")
548
+ self._someone_at_work()
494
549
  return
495
550
  if contents.transfer != self.transfer.tid:
551
+ self._someone_at_work()
496
552
  return
497
553
  # Remove current item from the transfer queue
498
554
  store = self.transfer.store
@@ -649,6 +705,8 @@ class AtemProtocol:
649
705
  self.transfer_buffer_bytes = 0
650
706
  self.locks = {}
651
707
  self._lock_requested.clear()
708
+ self._yielding.clear()
709
+ self._foreign_held.clear()
652
710
 
653
711
  def release_locks_now(self):
654
712
  """Give back every store lock we hold, SYNCHRONOUSLY, for teardown.
@@ -686,6 +744,7 @@ class AtemProtocol:
686
744
  exc_info=True)
687
745
  self.locks[store] = False
688
746
  self._lock_requested.clear()
747
+ self._yielding.clear()
689
748
 
690
749
  def abort_transfers(self):
691
750
  """Best-effort reset of the transfer state machine, for callers that
@@ -700,9 +759,11 @@ class AtemProtocol:
700
759
  self.transfer_requested = False
701
760
  self.transfer_buffer = []
702
761
  self.transfer_buffer_bytes = 0
703
- # A lock request in flight must not outlive the abort: the queue it
704
- # was for is gone, and the next caller's request must not wait on it.
762
+ # A lock request in flight, or a store left free, must not outlive
763
+ # the abort: the queue it was for is gone, and the next caller's
764
+ # request must not wait on it.
705
765
  self._lock_requested.clear()
766
+ self._yielding.clear()
706
767
  for lock in list(self.locks):
707
768
  if self.locks[lock]:
708
769
  try:
@@ -872,13 +933,11 @@ class AtemProtocol:
872
933
  LOCKED and its preview fetches starve (observed live 2026-07-07 on a
873
934
  freshly rebooted switcher). ASC holds per frame; now so do we.
874
935
 
875
- The release and the next transfer's lock request go out in ONE
876
- packet, as ATEM Software Control sends them between the stills of a
877
- restore and the frames of a download (captures, 2026-10-14 and
878
- 2026-10-15): the switcher processes the release, then grants the
879
- request in under a millisecond, with no wait for the release's LKST
880
- echo, which arrives a frame later. The request is the switcher's
881
- to queue if another client asked first, as a PLCK is queued.
936
+ With more work queued for the store, its next lock request waits
937
+ for the store's next LKST (see LOCK_COURTESY): another client that
938
+ was waiting gets the store at our release, and a request of ours
939
+ soon after would abort its transfer. 1.5.0 sent the release and the
940
+ next request in one packet, as ASC sends them; that shut ASC out.
882
941
  ``request_next=False`` sends the release alone and leaves the next
883
942
  request to the store's release broadcast (the pounce): after an
884
943
  FTDE 6 another session is queued for the store, and asking again
@@ -892,35 +951,49 @@ class AtemProtocol:
892
951
  if store != 0xffff and self.locks.get(store):
893
952
  self.log.info(f'Releasing lock {store} (per-frame)')
894
953
  self.locks[store] = False
895
- release = LockCommand(store, False)
954
+ try:
955
+ self.send_commands([LockCommand(store, False)])
956
+ except Exception:
957
+ self.log.exception('per-frame unlock send failed')
896
958
  if not request_next:
897
- try:
898
- self.send_commands([release])
899
- except Exception:
900
- self.log.exception('per-frame unlock send failed')
901
959
  return # the store's release broadcast continues the queue
902
- self._transfer_trigger(store, release=release)
903
- return
960
+ if self.transfer_queue.get(store) and self._can_wake():
961
+ # The trigger below defers the request and asks to be woken.
962
+ self._yielding[store] = [time.monotonic(), None]
904
963
  self._transfer_trigger(store)
905
964
 
906
- def _transfer_trigger(self, store, retry=False, release=None):
907
- # ``release``, a per-frame unlock (_release_then_continue), goes in
908
- # the same packet as whatever this sends next: the next lock
909
- # request, the other releases, or alone if nothing follows.
910
- pending = [release] if release is not None else []
911
-
912
- def send(commands):
913
- nonlocal pending
914
- self.send_commands(pending + list(commands))
915
- pending = []
916
-
917
- try:
918
- self._transfer_trigger_inner(store, retry, send)
919
- finally:
920
- if pending:
921
- self.send_commands(pending)
922
-
923
- def _transfer_trigger_inner(self, store, retry, send):
965
+ def _can_wake(self):
966
+ return hasattr(self.transport, 'wake_at')
967
+
968
+ def _wake_at(self, when):
969
+ wake = getattr(self.transport, 'wake_at', None)
970
+ if wake is not None:
971
+ wake(when)
972
+
973
+ def _yield_due(self, store):
974
+ """When a store we are leaving free may be asked for again."""
975
+ since, announced = self._yielding[store]
976
+ if announced is not None:
977
+ return announced + LOCK_COURTESY
978
+ return since + LOCK_QUIET
979
+
980
+ def _someone_at_work(self):
981
+ """Another session's transfer ended: a store we wait for, not yet
982
+ announced free, is busy, and LOCK_QUIET starts again."""
983
+ now = time.monotonic()
984
+ for y in self._yielding.values():
985
+ if y[1] is None:
986
+ y[0] = now
987
+
988
+ def _service_yields(self):
989
+ """Ask for each store we left free whose yield is over."""
990
+ for store in list(self._yielding):
991
+ if not self.transfer_queue.get(store):
992
+ del self._yielding[store]
993
+ elif not self.transfer_requested and not self.locks.get(store):
994
+ self._transfer_trigger(store)
995
+
996
+ def _transfer_trigger(self, store, retry=False):
924
997
  next = None
925
998
 
926
999
  self.log.info(f'transfer trigger for store {store} (retry={retry})')
@@ -945,11 +1018,24 @@ class AtemProtocol:
945
1018
  if self.locks[lock]:
946
1019
  self.log.info('Releasing lock {}'.format(lock))
947
1020
  cmd = LockCommand(lock, False)
948
- send([cmd])
1021
+ self.send_commands([cmd])
949
1022
  return
950
1023
 
951
1024
  # Request a lock if needed
952
1025
  if next.store != 0xffff and (next.store not in self.locks or not self.locks[next.store]):
1026
+ if next.store in self._foreign_held:
1027
+ # Another client's turn: its release's LKST asks for us.
1028
+ self.log.debug(f'Store {next.store} is another client\'s: '
1029
+ f'asking once they release it')
1030
+ return
1031
+ if next.store in self._yielding:
1032
+ due = self._yield_due(next.store)
1033
+ if time.monotonic() < due:
1034
+ self.log.debug(f'Leaving store {next.store} free a moment '
1035
+ f'for other clients')
1036
+ self._wake_at(due)
1037
+ return
1038
+ del self._yielding[next.store]
953
1039
  asked = self._lock_requested.get(next.store)
954
1040
  if asked is not None and time.monotonic() - asked < LOCK_REREQUEST_SECONDS:
955
1041
  self.log.debug(
@@ -966,7 +1052,7 @@ class AtemProtocol:
966
1052
  else:
967
1053
  cmd = PartialLockCommand(next.store, next.slot)
968
1054
  self._lock_requested[next.store] = time.monotonic()
969
- send([cmd])
1055
+ self.send_commands([cmd])
970
1056
  return
971
1057
 
972
1058
  # A transfer request is already running, don't start a new one
@@ -985,7 +1071,7 @@ class AtemProtocol:
985
1071
  self.transfer_queue[next.store] = queue[1:]
986
1072
  self.log.info(
987
1073
  'Clearing {}:{} under store lock'.format(next.store, next.slot))
988
- send([ClearStillCommand(slot=next.slot)])
1074
+ self.send_commands([ClearStillCommand(slot=next.slot)])
989
1075
  self._raise('clear-dispatched', next.store, next.slot)
990
1076
  self._release_then_continue(next.store)
991
1077
  return
@@ -1039,4 +1125,4 @@ class AtemProtocol:
1039
1125
  description=self.transfer.description,
1040
1126
  ))
1041
1127
  self.transfer.metadata_sent = True
1042
- send(outbound)
1128
+ self.send_commands(outbound)
@@ -376,6 +376,10 @@ class UdpProtocol(BaseProtocol):
376
376
  # when what it hasn't acknowledged is resent (RESEND_AFTER).
377
377
  self._acked_through = None
378
378
  self._resend_due = None
379
+ # When receive_packet next returns a Wakeup for the protocol's own
380
+ # timed work (wake_at); set from the protocol's thread.
381
+ self._wake_due = None
382
+ self._wake_lock = threading.Lock()
379
383
 
380
384
  self.thread_queue = SocketQueue()
381
385
  self.thread_recv_queue = Queue()
@@ -478,8 +482,12 @@ class UdpProtocol(BaseProtocol):
478
482
  continue
479
483
  if self._resend_due is not None:
480
484
  remaining = max(0.0, min(remaining, self._resend_due - time.monotonic()))
485
+ wake = self._wake_due
486
+ if wake is not None:
487
+ remaining = max(0.0, min(remaining, wake - time.monotonic()))
481
488
  readable, _, _ = select.select([self.sock, self.thread_queue], [], [], remaining)
482
489
  self._resend_unacked()
490
+ self._fire_wake()
483
491
  for queue in readable:
484
492
  if queue is self.sock:
485
493
  packet = self._receive_packet_low()
@@ -840,6 +848,22 @@ class UdpProtocol(BaseProtocol):
840
848
  raise
841
849
  time.sleep(0.001)
842
850
 
851
+ def wake_at(self, when):
852
+ """Have receive_packet return a Wakeup at ``when`` (monotonic): the
853
+ protocol's timed work, which its packets alone can't drive (the
854
+ switcher's pings never reach it). The earliest of several wins."""
855
+ with self._wake_lock:
856
+ if self._wake_due is None or when < self._wake_due:
857
+ self._wake_due = when
858
+ self.thread_queue.put(None) # select() takes the new timeout
859
+
860
+ def _fire_wake(self):
861
+ with self._wake_lock:
862
+ if self._wake_due is None or time.monotonic() < self._wake_due:
863
+ return
864
+ self._wake_due = None
865
+ self.thread_recv_queue.put(Wakeup())
866
+
843
867
  def _on_ack(self, number):
844
868
  """The switcher has our reliable packets through ``number``: the
845
869
  resend timer starts again while some remain unacknowledged."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bmdwire
3
- Version: 1.5.0
3
+ Version: 1.5.1
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
@@ -49,7 +49,7 @@ pip install bmdwire
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.5.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.5.1`) so a later release cannot change your install under you.
53
53
 
54
54
  ## atemwire stands on pyatem
55
55
 
@@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta"
13
13
 
14
14
  [project]
15
15
  name = "bmdwire"
16
- version = "1.5.0"
16
+ version = "1.5.1"
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
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