hapbeat-python-sdk 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/PKG-INFO +35 -14
  2. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/README.md +34 -13
  3. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/pyproject.toml +1 -1
  4. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/__init__.py +3 -3
  5. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/cli.py +8 -2
  6. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/client.py +44 -6
  7. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/eventmap.py +1 -1
  8. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/hapbeat.py +144 -19
  9. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/launchpad.py +1 -1
  10. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/protocol.py +45 -0
  11. hapbeat_python_sdk-0.2.0/src/hapbeat/update_check.py +213 -0
  12. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat_python_sdk.egg-info/PKG-INFO +35 -14
  13. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat_python_sdk.egg-info/SOURCES.txt +4 -1
  14. hapbeat_python_sdk-0.2.0/tests/test_addressing.py +183 -0
  15. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/tests/test_examples.py +2 -2
  16. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/tests/test_hapbeat.py +9 -1
  17. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/tests/test_launchpad.py +4 -4
  18. hapbeat_python_sdk-0.2.0/tests/test_update_check.py +162 -0
  19. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/LICENSE +0 -0
  20. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/setup.cfg +0 -0
  21. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/__main__.py +0 -0
  22. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/clip.py +0 -0
  23. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/osc.py +0 -0
  24. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/wav.py +0 -0
  25. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat_python_sdk.egg-info/dependency_links.txt +0 -0
  26. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat_python_sdk.egg-info/entry_points.txt +0 -0
  27. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat_python_sdk.egg-info/requires.txt +0 -0
  28. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat_python_sdk.egg-info/top_level.txt +0 -0
  29. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/tests/test_clip.py +0 -0
  30. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/tests/test_overlay_osc.py +0 -0
  31. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/tests/test_protocol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hapbeat-python-sdk
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python SDK for driving Hapbeat haptic devices over Wi-Fi UDP
5
5
  Author-email: Hapbeat <yus988@hapbeat.com>
6
6
  License: MIT
@@ -71,10 +71,10 @@ a tiny stdlib HTTP server that relays button presses to the device over UDP
71
71
  ```python
72
72
  import hapbeat
73
73
 
74
- hb = hapbeat.connect(app_name="MyExperiment") # opens UDP broadcast + keep-alive
75
- hb.play("impact.hit", gain=0.3) # fire event "impact.hit" at gain 0.3
76
- hb.play("impact.hit") # gain omitted -> kit baseline intensity
77
- hb.stop("impact.hit")
74
+ hb = hapbeat.connect(app_name="MyExperiment") # opens the UDP socket + keep-alive
75
+ hb.play("sample-kit.sine_100hz", gain=0.3) # fire event "sample-kit.sine_100hz" at gain 0.3
76
+ hb.play("sample-kit.sine_100hz") # gain omitted -> kit baseline intensity
77
+ hb.stop("sample-kit.sine_100hz")
78
78
  hb.stop_all()
79
79
  hb.close()
80
80
  ```
@@ -83,10 +83,10 @@ or as a context manager:
83
83
 
84
84
  ```python
85
85
  with hapbeat.connect(app_name="MyExperiment") as hb:
86
- hb.play("impact.hit")
86
+ hb.play("sample-kit.sine_100hz")
87
87
  ```
88
88
 
89
- `"impact.hit"` must be an event id present in the **kit deployed to the
89
+ `"sample-kit.sine_100hz"` must be an event id present in the **kit deployed to the
90
90
  device** (via [Hapbeat Studio](https://devtools.hapbeat.com)). The SDK sends
91
91
  the *instruction*; the waveform lives in the kit on the device.
92
92
 
@@ -97,6 +97,19 @@ for dev in hb.discover(timeout=1.5):
97
97
  print(dev.ip, dev.address, dev.firmware_version)
98
98
  ```
99
99
 
100
+ ### How packets are sent
101
+
102
+ Commands and clip streams are **unicast** to every device that answered a PING,
103
+ and broadcast only while no device is known. Wi-Fi APs hold broadcast frames
104
+ until their next DTIM beacon (100–300 ms) whenever any client on the AP is
105
+ power-saving, which is heard as late haptics and stuttering streams; unicast is
106
+ not batched that way. The 5-second keep-alive PING keeps that table current, so
107
+ leave `keepalive=True`.
108
+
109
+ Pass `connect(unicast=False)` to always broadcast — worth it when many devices
110
+ must fire in lockstep, since one broadcast reaches them all at once while N
111
+ unicasts go out one after another.
112
+
100
113
  ## EventMap — the tuning side (optional)
101
114
 
102
115
  Keep per-event default gains in one place and let `play("id")` resolve them,
@@ -105,11 +118,11 @@ so firing code never hard-codes intensities:
105
118
  ```python
106
119
  em = hapbeat.EventMap.from_manifest("my-kit/my-kit-manifest.json")
107
120
  hb = hapbeat.connect(event_map=em)
108
- hb.play("impact.hit") # uses the kit manifest's intensity for this event
121
+ hb.play("sample-kit.sine_100hz") # uses the kit manifest's intensity for this event
109
122
  ```
110
123
 
111
124
  `EventMap` reads the kit manifest (schema 2.0.0) `intensity` as the baseline
112
- gain. You can also build one by hand: `EventMap.from_dict({"impact.hit": 0.5})`.
125
+ gain. You can also build one by hand: `EventMap.from_dict({"sample-kit.sine_100hz": 0.5})`.
113
126
 
114
127
  ### Haptic file — add targeting on top of the kit
115
128
 
@@ -123,15 +136,23 @@ resolves the target without the caller passing it:
123
136
  {
124
137
  "kit": "kits/my-kit",
125
138
  "events": {
126
- "impact.hit": { "target": "player_1/chest", "gain": 0.8 },
127
- "rain.loop": { "target": "*/back" }
139
+ "sample-kit.sine_100hz": { "target": "player_1/pos_neck", "gain": 0.8 },
140
+ "rain.loop": { "target": "*/pos_back" }
128
141
  }
129
142
  }
130
143
  ```
131
144
 
145
+ Targets are matched **positionally**: segment *i* of the target is compared
146
+ with segment *i* of the device address, `*` stands for one **whole** segment
147
+ (`pos_*` is compared literally — use `player_1/*`), and a short target
148
+ front-matches. Device addresses are always `player_<N>/<position>/group_<M>`,
149
+ defaulting to `player_1` / `group_1`, so a group has to be written
150
+ `*/*/group_2`: plain `group_2` is compared against the *player* slot and never
151
+ matches.
152
+
132
153
  ```python
133
154
  hb = hapbeat.connect(app_name="MyApp", haptics="haptics.json")
134
- hb.play("impact.hit") # goes to player_1/chest at gain 0.8 — from the file
155
+ hb.play("sample-kit.sine_100hz") # goes to player_1/pos_neck at gain 0.8 — from the file
135
156
  ```
136
157
 
137
158
  ## Two playback modes: command and clip
@@ -158,7 +179,7 @@ my-app/
158
179
  ```python
159
180
  import hapbeat
160
181
  hb = hapbeat.connect(app_name="MyApp", kit="kits/my-kit")
161
- hb.play("impact.hit") # command -> device plays its installed clip
182
+ hb.play("sample-kit.sine_100hz") # command -> device plays its installed clip
162
183
  hb.play("rain.loop") # clip -> SDK streams stream-clips/<wav> over UDP
163
184
  hb.stop("rain.loop") # ends the active stream
164
185
  ```
@@ -189,7 +210,7 @@ See [docs/osc.md](docs/osc.md) for the address spec.
189
210
 
190
211
  ```bash
191
212
  hapbeat scan # list devices on the LAN
192
- hapbeat play impact.hit --gain 0.3
213
+ hapbeat play sample-kit.sine_100hz --gain 0.3
193
214
  hapbeat stop-all
194
215
  hapbeat launchpad # browser UI for all of the above
195
216
  ```
@@ -38,10 +38,10 @@ a tiny stdlib HTTP server that relays button presses to the device over UDP
38
38
  ```python
39
39
  import hapbeat
40
40
 
41
- hb = hapbeat.connect(app_name="MyExperiment") # opens UDP broadcast + keep-alive
42
- hb.play("impact.hit", gain=0.3) # fire event "impact.hit" at gain 0.3
43
- hb.play("impact.hit") # gain omitted -> kit baseline intensity
44
- hb.stop("impact.hit")
41
+ hb = hapbeat.connect(app_name="MyExperiment") # opens the UDP socket + keep-alive
42
+ hb.play("sample-kit.sine_100hz", gain=0.3) # fire event "sample-kit.sine_100hz" at gain 0.3
43
+ hb.play("sample-kit.sine_100hz") # gain omitted -> kit baseline intensity
44
+ hb.stop("sample-kit.sine_100hz")
45
45
  hb.stop_all()
46
46
  hb.close()
47
47
  ```
@@ -50,10 +50,10 @@ or as a context manager:
50
50
 
51
51
  ```python
52
52
  with hapbeat.connect(app_name="MyExperiment") as hb:
53
- hb.play("impact.hit")
53
+ hb.play("sample-kit.sine_100hz")
54
54
  ```
55
55
 
56
- `"impact.hit"` must be an event id present in the **kit deployed to the
56
+ `"sample-kit.sine_100hz"` must be an event id present in the **kit deployed to the
57
57
  device** (via [Hapbeat Studio](https://devtools.hapbeat.com)). The SDK sends
58
58
  the *instruction*; the waveform lives in the kit on the device.
59
59
 
@@ -64,6 +64,19 @@ for dev in hb.discover(timeout=1.5):
64
64
  print(dev.ip, dev.address, dev.firmware_version)
65
65
  ```
66
66
 
67
+ ### How packets are sent
68
+
69
+ Commands and clip streams are **unicast** to every device that answered a PING,
70
+ and broadcast only while no device is known. Wi-Fi APs hold broadcast frames
71
+ until their next DTIM beacon (100–300 ms) whenever any client on the AP is
72
+ power-saving, which is heard as late haptics and stuttering streams; unicast is
73
+ not batched that way. The 5-second keep-alive PING keeps that table current, so
74
+ leave `keepalive=True`.
75
+
76
+ Pass `connect(unicast=False)` to always broadcast — worth it when many devices
77
+ must fire in lockstep, since one broadcast reaches them all at once while N
78
+ unicasts go out one after another.
79
+
67
80
  ## EventMap — the tuning side (optional)
68
81
 
69
82
  Keep per-event default gains in one place and let `play("id")` resolve them,
@@ -72,11 +85,11 @@ so firing code never hard-codes intensities:
72
85
  ```python
73
86
  em = hapbeat.EventMap.from_manifest("my-kit/my-kit-manifest.json")
74
87
  hb = hapbeat.connect(event_map=em)
75
- hb.play("impact.hit") # uses the kit manifest's intensity for this event
88
+ hb.play("sample-kit.sine_100hz") # uses the kit manifest's intensity for this event
76
89
  ```
77
90
 
78
91
  `EventMap` reads the kit manifest (schema 2.0.0) `intensity` as the baseline
79
- gain. You can also build one by hand: `EventMap.from_dict({"impact.hit": 0.5})`.
92
+ gain. You can also build one by hand: `EventMap.from_dict({"sample-kit.sine_100hz": 0.5})`.
80
93
 
81
94
  ### Haptic file — add targeting on top of the kit
82
95
 
@@ -90,15 +103,23 @@ resolves the target without the caller passing it:
90
103
  {
91
104
  "kit": "kits/my-kit",
92
105
  "events": {
93
- "impact.hit": { "target": "player_1/chest", "gain": 0.8 },
94
- "rain.loop": { "target": "*/back" }
106
+ "sample-kit.sine_100hz": { "target": "player_1/pos_neck", "gain": 0.8 },
107
+ "rain.loop": { "target": "*/pos_back" }
95
108
  }
96
109
  }
97
110
  ```
98
111
 
112
+ Targets are matched **positionally**: segment *i* of the target is compared
113
+ with segment *i* of the device address, `*` stands for one **whole** segment
114
+ (`pos_*` is compared literally — use `player_1/*`), and a short target
115
+ front-matches. Device addresses are always `player_<N>/<position>/group_<M>`,
116
+ defaulting to `player_1` / `group_1`, so a group has to be written
117
+ `*/*/group_2`: plain `group_2` is compared against the *player* slot and never
118
+ matches.
119
+
99
120
  ```python
100
121
  hb = hapbeat.connect(app_name="MyApp", haptics="haptics.json")
101
- hb.play("impact.hit") # goes to player_1/chest at gain 0.8 — from the file
122
+ hb.play("sample-kit.sine_100hz") # goes to player_1/pos_neck at gain 0.8 — from the file
102
123
  ```
103
124
 
104
125
  ## Two playback modes: command and clip
@@ -125,7 +146,7 @@ my-app/
125
146
  ```python
126
147
  import hapbeat
127
148
  hb = hapbeat.connect(app_name="MyApp", kit="kits/my-kit")
128
- hb.play("impact.hit") # command -> device plays its installed clip
149
+ hb.play("sample-kit.sine_100hz") # command -> device plays its installed clip
129
150
  hb.play("rain.loop") # clip -> SDK streams stream-clips/<wav> over UDP
130
151
  hb.stop("rain.loop") # ends the active stream
131
152
  ```
@@ -156,7 +177,7 @@ See [docs/osc.md](docs/osc.md) for the address spec.
156
177
 
157
178
  ```bash
158
179
  hapbeat scan # list devices on the LAN
159
- hapbeat play impact.hit --gain 0.3
180
+ hapbeat play sample-kit.sine_100hz --gain 0.3
160
181
  hapbeat stop-all
161
182
  hapbeat launchpad # browser UI for all of the above
162
183
  ```
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  # Distribution name (pip/pipx). The import package stays `import hapbeat`.
7
7
  name = "hapbeat-python-sdk"
8
- version = "0.1.0"
8
+ version = "0.2.0"
9
9
  description = "Python SDK for driving Hapbeat haptic devices over Wi-Fi UDP"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -5,7 +5,7 @@ Quick start::
5
5
  import hapbeat
6
6
 
7
7
  hb = hapbeat.connect(app_name="MyExperiment")
8
- hb.play("impact.hit", gain=0.3)
8
+ hb.play("sample-kit.sine_100hz", gain=0.3)
9
9
  hb.stop_all()
10
10
  hb.close()
11
11
 
@@ -28,9 +28,9 @@ try: # populated from installed package metadata
28
28
  try:
29
29
  __version__ = version("hapbeat-python-sdk")
30
30
  except PackageNotFoundError: # running from a source checkout
31
- __version__ = "0.1.0+local"
31
+ __version__ = "0.2.0+local"
32
32
  except ImportError: # pragma: no cover
33
- __version__ = "0.1.0+local"
33
+ __version__ = "0.2.0+local"
34
34
 
35
35
  __all__ = [
36
36
  "connect",
@@ -3,8 +3,8 @@
3
3
  Examples::
4
4
 
5
5
  hapbeat scan
6
- hapbeat play impact.hit --gain 0.3
7
- hapbeat stop impact.hit
6
+ hapbeat play sample-kit.sine_100hz --gain 0.3
7
+ hapbeat stop sample-kit.sine_100hz
8
8
  hapbeat stop-all
9
9
  hapbeat osc-bridge --listen 7702
10
10
  hapbeat launchpad
@@ -72,6 +72,12 @@ def build_parser() -> argparse.ArgumentParser:
72
72
  def main(argv: list[str] | None = None) -> int:
73
73
  args = build_parser().parse_args(argv)
74
74
 
75
+ # 新しい版が出ていれば stderr に 1 行だけ (同じ版では 2 回目を出さない)。
76
+ # キャッシュが無ければ裏で取りに行くだけでコマンドは待たせない。
77
+ # ライブラリ import 側では絶対に呼ばない — CLI 実行時のみ (DEC-053 §5.4)。
78
+ from . import update_check
79
+ update_check.notify_cli(__version__)
80
+
75
81
  if args.command == "osc-bridge":
76
82
  from .osc import OscBridge
77
83
 
@@ -1,8 +1,10 @@
1
1
  """UDP transport for the Hapbeat SDK.
2
2
 
3
- Owns one datagram socket configured for broadcast. Sends command packets to
4
- the LAN broadcast address and runs a background thread that collects PONG
5
- replies so device discovery works.
3
+ Owns one datagram socket configured for broadcast. Sends packets either to a
4
+ known device (unicast) or to the LAN broadcast address, and runs a background
5
+ thread that collects PONG replies so device discovery works. Which of the two
6
+ a given packet takes is decided one layer up, in :class:`hapbeat.Hapbeat`,
7
+ which is the layer that tracks devices.
6
8
 
7
9
  Design note — port binding:
8
10
  The standard transport is Wi-Fi UDP broadcast (see workspace CLAUDE.md).
@@ -19,7 +21,7 @@ import logging
19
21
  import socket
20
22
  import threading
21
23
  import time
22
- from typing import Callable, Optional
24
+ from typing import Callable, Iterable, Optional
23
25
 
24
26
  from . import protocol
25
27
 
@@ -61,6 +63,20 @@ class UdpClient:
61
63
  return
62
64
  sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
63
65
  sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
66
+ # Windows only: a prior sendto() to an unreachable peer queues an ICMP
67
+ # port-unreachable, which Winsock surfaces as ConnectionResetError
68
+ # (WinError 10054) on the *next* recvfrom() — even on this unconnected
69
+ # socket. Once we unicast (a device that is off, rebooting, or being
70
+ # reflashed is unreachable) that becomes routine, and the recv loop
71
+ # below would die on it: the SDK would then go deaf to ALL PONGs until
72
+ # the process restarts. SIO_UDP_CONNRESET=False tells Windows to ignore
73
+ # those resets. No-op elsewhere (the constant is Windows-only).
74
+ # Same fix as hapbeat-helper f06fa04, which shipped before this SDK.
75
+ if hasattr(socket, "SIO_UDP_CONNRESET"):
76
+ try:
77
+ sock.ioctl(socket.SIO_UDP_CONNRESET, False)
78
+ except OSError as exc:
79
+ logger.debug("SIO_UDP_CONNRESET ioctl failed (non-fatal): %s", exc)
64
80
  if self.bind_port == self.port:
65
81
  # Only when contending for the shared well-known port do we ask to
66
82
  # reuse it; an ephemeral bind needs no reuse and must not steal a
@@ -129,6 +145,19 @@ class UdpClient:
129
145
  logger.warning("UDP send to %s:%d failed: %s", dst, self.port, exc)
130
146
  return False
131
147
 
148
+ def send_many(self, packet: bytes, addrs: Iterable[str]) -> bool:
149
+ """Unicast the same packet to several devices.
150
+
151
+ Returns True if at least one send succeeded. One unreachable device
152
+ (powered off between its last PONG and now) must not stop the packet
153
+ from reaching the others, so failures are logged and skipped.
154
+ """
155
+ sent = False
156
+ for addr in addrs:
157
+ if self.send(packet, addr):
158
+ sent = True
159
+ return sent
160
+
132
161
  # ── Recv loop ───────────────────────────────────────────────────
133
162
  def _recv_loop(self) -> None:
134
163
  while self._running:
@@ -139,8 +168,17 @@ class UdpClient:
139
168
  data, addr = sock.recvfrom(4096)
140
169
  except socket.timeout:
141
170
  continue
142
- except OSError:
143
- break
171
+ except OSError as exc:
172
+ if not self._running or self._sock is None:
173
+ break # closed by close() — expected exit
174
+ # Still running: residual ICMP-unreachable (should be
175
+ # suppressed by SIO_UDP_CONNRESET above, but stay defensive in
176
+ # case the ioctl was unavailable) or a brief NIC flap. Breaking
177
+ # here is exactly what makes an SDK go permanently deaf to
178
+ # PONGs, so back off briefly and keep listening.
179
+ logger.debug("UDP recv transient error (continuing): %s", exc)
180
+ time.sleep(0.2)
181
+ continue
144
182
  pong = protocol.parse_pong(data)
145
183
  if pong is None:
146
184
  continue
@@ -139,7 +139,7 @@ class EventMap:
139
139
  {
140
140
  "kit": "kits/my-kit", # kit folder, relative to this file
141
141
  "events": {
142
- "impact.hit": { "target": "player_1/chest", "gain": 0.8 },
142
+ "sample-kit.sine_100hz": { "target": "player_1/chest", "gain": 0.8 },
143
143
  "rain.loop": { "target": "*/back" }
144
144
  }
145
145
  }
@@ -11,14 +11,14 @@ Typical use::
11
11
  import hapbeat
12
12
 
13
13
  hb = hapbeat.connect(app_name="MyExperiment")
14
- hb.play("impact.hit", gain=0.3)
15
- hb.stop("impact.hit")
14
+ hb.play("sample-kit.sine_100hz", gain=0.3)
15
+ hb.stop("sample-kit.sine_100hz")
16
16
  hb.close()
17
17
 
18
18
  or as a context manager::
19
19
 
20
20
  with hapbeat.connect() as hb:
21
- hb.play("impact.hit")
21
+ hb.play("sample-kit.sine_100hz")
22
22
  """
23
23
 
24
24
  from __future__ import annotations
@@ -55,7 +55,11 @@ def _now_us() -> int:
55
55
 
56
56
 
57
57
  class Hapbeat:
58
- """A connection to Hapbeat devices over Wi-Fi UDP broadcast."""
58
+ """A connection to Hapbeat devices over Wi-Fi UDP.
59
+
60
+ Commands go out by unicast to every device that answered a recent PING,
61
+ and by broadcast until one does (see ``unicast`` in :func:`connect`).
62
+ """
59
63
 
60
64
  def __init__(
61
65
  self,
@@ -72,6 +76,8 @@ class Hapbeat:
72
76
  bind_port: int = 0,
73
77
  clip_base: Optional[Union[str, Path]] = None,
74
78
  stream_send_ahead: float = 0.15,
79
+ unicast: bool = True,
80
+ device_ttl: Optional[float] = None,
75
81
  ) -> None:
76
82
  self._client = UdpClient(port=port, broadcast_addr=broadcast_addr,
77
83
  bind_port=bind_port)
@@ -92,6 +98,23 @@ class Hapbeat:
92
98
  self._keepalive_stop: Optional[threading.Event] = None
93
99
  self._keepalive_thread: Optional[threading.Thread] = None
94
100
  self._opened = False
101
+ # Send PLAY/STOP/STOP_ALL/STREAM_* straight to devices we have heard a
102
+ # PONG from, instead of broadcasting. See _command_destinations.
103
+ self.unicast = unicast
104
+ # How long a device stays a unicast destination after its last PONG.
105
+ # Default: three keep-alive PINGs' worth (min 5 s), the same window the
106
+ # Unity SDK uses. Too short and every command falls back to broadcast;
107
+ # too long and we keep unicasting at a device that was powered off,
108
+ # never returning to broadcast for the rest of the session.
109
+ self.device_ttl = (
110
+ device_ttl if device_ttl is not None
111
+ else max(5.0, keepalive_interval * 3.0)
112
+ )
113
+ # Unicast destinations for the active stream session, snapshotted once
114
+ # in stream_begin (STREAM_DATA/END carry no target of their own).
115
+ # None = broadcast the session; [] = the snapshot matched nobody, so
116
+ # send nothing; non-empty = unicast to exactly these IPs.
117
+ self._stream_dests: Optional[list[str]] = None
95
118
  # Clip streaming (level-2). The streamer calls this object's
96
119
  # stream_begin/stream_data/stream_end (StreamSink protocol).
97
120
  self._clip = ClipStreamer(self, send_ahead_sec=stream_send_ahead)
@@ -106,7 +129,7 @@ class Hapbeat:
106
129
  self._opened = True
107
130
  self._client.add_pong_listener(self._on_pong)
108
131
  self._client.open()
109
- if self._keepalive and self.app_name:
132
+ if self._keepalive:
110
133
  self._start_keepalive()
111
134
  return self
112
135
 
@@ -149,6 +172,50 @@ class Hapbeat:
149
172
  return ev.target
150
173
  return self.default_target
151
174
 
175
+ # ── Send routing (unicast vs broadcast) ─────────────────────────
176
+ #
177
+ # Wi-Fi broadcast is held back by the AP: whenever any client on the AP is
178
+ # power-saving, group-addressed frames are buffered until the next DTIM
179
+ # beacon (100-300 ms). That shows up as a late haptic on one-shot commands
180
+ # and as periodic dropouts in a clip stream, and the device cannot avoid it
181
+ # (the delay is caused by *other* clients). Unicast frames are not batched,
182
+ # and they get MAC-level ACK/retry as a bonus. Measured and confirmed on
183
+ # the Unity SDK before being ported here.
184
+ def _alive_devices(self) -> list[Device]:
185
+ cutoff = time.monotonic() - self.device_ttl
186
+ with self._devices_lock:
187
+ return [d for d in self._devices.values() if d.last_seen >= cutoff]
188
+
189
+ def _matching_ips(self, target: str) -> list[str]:
190
+ """Alive devices whose address accepts ``target``.
191
+
192
+ A device that has not reported an address (older firmware, or no PONG
193
+ parsed yet) is kept — fail open. Worst case is one extra unicast
194
+ packet that the device itself filters out; the opposite mistake would
195
+ silently drop a command the device would have accepted.
196
+ """
197
+ return [
198
+ d.ip for d in self._alive_devices()
199
+ if not d.address or protocol.address_matches(target, d.address)
200
+ ]
201
+
202
+ def _send_routed(self, packet: bytes, target: str) -> bool:
203
+ """Send one PLAY/STOP/STOP_ALL, unicast when we can.
204
+
205
+ Falls back to broadcast when unicast is off, when no device has PONGed
206
+ recently, *and* when every alive device's address mismatches — never
207
+ skipping the send. The device re-applies the same target filter on
208
+ receipt, so a broadcast can never fire a device the target did not
209
+ address; whereas skipping would silently lose the packet whenever our
210
+ cached address is stale (a STOP lost that way leaves a loop running).
211
+ """
212
+ if not self.unicast:
213
+ return self._client.send(packet)
214
+ dests = self._matching_ips(target)
215
+ if not dests:
216
+ return self._client.send(packet)
217
+ return self._client.send_many(packet, dests)
218
+
152
219
  # ── Fire API (level-1) ──────────────────────────────────────────
153
220
  def play(
154
221
  self,
@@ -186,7 +253,7 @@ class Hapbeat:
186
253
  target_time_us=target_time_us,
187
254
  gain=gain,
188
255
  )
189
- return self._client.send(pkt)
256
+ return self._send_routed(pkt, target_eff)
190
257
 
191
258
  def stop(self, event_id: str, *, target: Optional[str] = None) -> bool:
192
259
  """Stop one event id. Clip events end the active stream; command
@@ -195,18 +262,16 @@ class Hapbeat:
195
262
  if ev is not None and ev.streaming:
196
263
  self._clip.stop()
197
264
  return True
198
- pkt = protocol.build_stop(
199
- self._next_seq(), event_id, target=self._effective_target(ev, target)
200
- )
201
- return self._client.send(pkt)
265
+ target_eff = self._effective_target(ev, target)
266
+ pkt = protocol.build_stop(self._next_seq(), event_id, target=target_eff)
267
+ return self._send_routed(pkt, target_eff)
202
268
 
203
269
  def stop_all(self, *, target: Optional[str] = None) -> bool:
204
270
  """Stop every event on matching devices (and any active clip stream)."""
205
271
  self._clip.stop()
206
- pkt = protocol.build_stop_all(
207
- self._next_seq(), target=self._resolve_target(target)
208
- )
209
- return self._client.send(pkt)
272
+ target_eff = self._resolve_target(target)
273
+ pkt = protocol.build_stop_all(self._next_seq(), target=target_eff)
274
+ return self._send_routed(pkt, target_eff)
210
275
 
211
276
  def ping(self) -> bool:
212
277
  """Broadcast a PING (keep-alive / discovery probe)."""
@@ -335,19 +400,53 @@ class Hapbeat:
335
400
  return wav
336
401
 
337
402
  # ── StreamSink (called by ClipStreamer) ─────────────────────────
403
+ def _snapshot_stream_destinations(self, target: str) -> None:
404
+ """Pick the destinations for one stream session.
405
+
406
+ Only STREAM_BEGIN carries a target, so the choice is made once here and
407
+ reused for every STREAM_DATA/END of the session (also keeping the
408
+ destination set stable while the clip thread is running).
409
+
410
+ Unlike a one-shot command, a filtered-to-empty result means *send
411
+ nothing* rather than broadcast: a stream is hundreds of packets, and
412
+ blasting all of them at devices the target excluded would waste far
413
+ more airtime than a lost stream costs (the device just stays silent —
414
+ nothing gets stuck, unlike a lost STOP).
415
+ """
416
+ if not self.unicast:
417
+ self._stream_dests = None
418
+ return
419
+ alive = self._alive_devices()
420
+ if not alive:
421
+ self._stream_dests = None # nobody known yet -> broadcast
422
+ return
423
+ self._stream_dests = [
424
+ d.ip for d in alive
425
+ if not d.address or protocol.address_matches(target, d.address)
426
+ ]
427
+
428
+ def _send_stream(self, packet: bytes) -> bool:
429
+ dests = self._stream_dests
430
+ if dests is None:
431
+ return self._client.send(packet)
432
+ if not dests:
433
+ return False # snapshot matched no device — see _snapshot_stream_destinations
434
+ return self._client.send_many(packet, dests)
435
+
338
436
  def stream_begin(self, *, sample_rate: int, channels: int,
339
437
  total_samples: int, gain: float, target: str) -> None:
340
- self._client.send(protocol.build_stream_begin(
438
+ self._snapshot_stream_destinations(target)
439
+ self._send_stream(protocol.build_stream_begin(
341
440
  self._next_seq(), sample_rate=sample_rate, channels=channels,
342
441
  fmt=protocol.AUDIO_FORMAT_PCM16, total_samples=total_samples,
343
442
  gain=gain, target=target,
344
443
  ))
345
444
 
346
445
  def stream_data(self, offset: int, data: bytes) -> None:
347
- self._client.send(protocol.build_stream_data(self._next_seq(), offset, data))
446
+ self._send_stream(protocol.build_stream_data(self._next_seq(), offset, data))
348
447
 
349
448
  def stream_end(self) -> None:
350
- self._client.send(protocol.build_stream_end(self._next_seq()))
449
+ self._send_stream(protocol.build_stream_end(self._next_seq()))
351
450
 
352
451
  # ── Discovery ───────────────────────────────────────────────────
353
452
  def discover(self, timeout: float = 1.0) -> list[Device]:
@@ -374,12 +473,24 @@ class Hapbeat:
374
473
 
375
474
  # ── Keep-alive thread ───────────────────────────────────────────
376
475
  def _start_keepalive(self) -> None:
476
+ """Periodic PING (+ CONNECT_STATUS when an app name is set).
477
+
478
+ The PING is what keeps unicast working: the device answers PONG *only*
479
+ to a PING (CONNECT_STATUS gets no reply), so without this every device
480
+ would age out of the destination table after ``device_ttl`` and the
481
+ session would silently fall back to broadcast forever. It doubles as
482
+ discovery for devices that join late.
483
+ """
377
484
  self._keepalive_stop = threading.Event()
378
- self.connect_status(connected=True)
485
+ self.ping()
486
+ if self.app_name:
487
+ self.connect_status(connected=True)
379
488
 
380
489
  def loop(stop: threading.Event) -> None:
381
490
  while not stop.wait(self._keepalive_interval):
382
- self.connect_status(connected=True)
491
+ self.ping()
492
+ if self.app_name:
493
+ self.connect_status(connected=True)
383
494
 
384
495
  self._keepalive_thread = threading.Thread(
385
496
  target=loop, args=(self._keepalive_stop,),
@@ -410,6 +521,8 @@ def connect(
410
521
  haptics: Optional[Union[str, Path]] = None,
411
522
  clip_base: Optional[Union[str, Path]] = None,
412
523
  stream_send_ahead: float = 0.15,
524
+ unicast: bool = True,
525
+ device_ttl: Optional[float] = None,
413
526
  ) -> Hapbeat:
414
527
  """Open a connection and return a ready :class:`Hapbeat`.
415
528
 
@@ -425,6 +538,16 @@ def connect(
425
538
  - ``kit`` — a kit folder; ``EventMap.from_kit`` (intensity/clip only,
426
539
  no targeting).
427
540
  ``clip_base`` overrides where clip WAVs are read (default ``<kit>/stream-clips/``).
541
+
542
+ ``unicast=True`` (default) sends PLAY/STOP/STOP_ALL and clip streams
543
+ straight to the devices that answered a PING, instead of broadcasting —
544
+ Wi-Fi APs hold broadcast frames until the next DTIM beacon (100-300 ms),
545
+ which is heard as late haptics and stuttering streams. It falls back to
546
+ broadcast until a device replies. Set ``unicast=False`` to always
547
+ broadcast (e.g. many devices that must fire in lockstep, where one
548
+ broadcast beats N sequential unicasts). ``device_ttl`` is how long a
549
+ device stays a destination after its last PONG (default: 3 keep-alive
550
+ intervals, min 5 s).
428
551
  """
429
552
  if event_map is None:
430
553
  if haptics is not None:
@@ -443,4 +566,6 @@ def connect(
443
566
  bind_port=bind_port,
444
567
  clip_base=clip_base,
445
568
  stream_send_ahead=stream_send_ahead,
569
+ unicast=unicast,
570
+ device_ttl=device_ttl,
446
571
  ).open()
@@ -449,7 +449,7 @@ PAGE = r"""<!doctype html>
449
449
  <div class="bar">
450
450
  <div class="field" style="flex:2; min-width:220px;">
451
451
  <label>Default event id (used when a card's field is blank)</label>
452
- <input id="defEvent" class="mono" placeholder="e.g. impact.hit" value="">
452
+ <input id="defEvent" class="mono" placeholder="e.g. sample-kit.sine_100hz" value="">
453
453
  </div>
454
454
  <div class="field">
455
455
  <label>Target (blank = all)</label>