hapbeat-python-sdk 0.2.0__tar.gz → 0.3.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 (39) hide show
  1. {hapbeat_python_sdk-0.2.0/src/hapbeat_python_sdk.egg-info → hapbeat_python_sdk-0.3.0}/PKG-INFO +17 -9
  2. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/README.md +16 -8
  3. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/pyproject.toml +1 -1
  4. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/__init__.py +14 -4
  5. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/cli.py +14 -2
  6. hapbeat_python_sdk-0.3.0/src/hapbeat/client.py +411 -0
  7. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/hapbeat.py +293 -78
  8. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/launchpad.py +2 -1
  9. hapbeat_python_sdk-0.3.0/src/hapbeat/netif.py +270 -0
  10. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/osc.py +4 -2
  11. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/protocol.py +165 -23
  12. hapbeat_python_sdk-0.3.0/src/hapbeat/stream.py +766 -0
  13. hapbeat_python_sdk-0.3.0/src/hapbeat/stream_lease.py +182 -0
  14. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0/src/hapbeat_python_sdk.egg-info}/PKG-INFO +17 -9
  15. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/SOURCES.txt +6 -1
  16. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/tests/test_addressing.py +27 -18
  17. hapbeat_python_sdk-0.3.0/tests/test_broadcast_routes.py +285 -0
  18. hapbeat_python_sdk-0.3.0/tests/test_clip.py +390 -0
  19. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/tests/test_hapbeat.py +26 -0
  20. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/tests/test_overlay_osc.py +8 -5
  21. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/tests/test_protocol.py +20 -1
  22. hapbeat_python_sdk-0.3.0/tests/test_stream_mix.py +168 -0
  23. hapbeat_python_sdk-0.3.0/tests/test_stream_session_v2.py +673 -0
  24. hapbeat_python_sdk-0.2.0/src/hapbeat/client.py +0 -190
  25. hapbeat_python_sdk-0.2.0/src/hapbeat/clip.py +0 -121
  26. hapbeat_python_sdk-0.2.0/tests/test_clip.py +0 -204
  27. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/LICENSE +0 -0
  28. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/setup.cfg +0 -0
  29. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/__main__.py +0 -0
  30. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/eventmap.py +0 -0
  31. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/update_check.py +0 -0
  32. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/wav.py +0 -0
  33. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/dependency_links.txt +0 -0
  34. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/entry_points.txt +0 -0
  35. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/requires.txt +0 -0
  36. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/top_level.txt +0 -0
  37. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/tests/test_examples.py +0 -0
  38. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/tests/test_launchpad.py +0 -0
  39. {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/tests/test_update_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hapbeat-python-sdk
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -74,6 +74,7 @@ import hapbeat
74
74
  hb = hapbeat.connect(app_name="MyExperiment") # opens the UDP socket + keep-alive
75
75
  hb.play("sample-kit.sine_100hz", gain=0.3) # fire event "sample-kit.sine_100hz" at gain 0.3
76
76
  hb.play("sample-kit.sine_100hz") # gain omitted -> kit baseline intensity
77
+ hb.play("sample-kit.sine_100hz", pan=-1) # pan: -1 left / 0 centre / +1 right
77
78
  hb.stop("sample-kit.sine_100hz")
78
79
  hb.stop_all()
79
80
  hb.close()
@@ -99,8 +100,10 @@ for dev in hb.discover(timeout=1.5):
99
100
 
100
101
  ### How packets are sent
101
102
 
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
103
+ Commands are unicast to devices that answered a PING and use broadcast only
104
+ while no device is known. Clip streams are stricter: they start only on exact
105
+ PONG-reported endpoints and remain `Deferred` while none matches; stream packets
106
+ are never broadcast. Wi-Fi APs hold broadcast frames
104
107
  until their next DTIM beacon (100–300 ms) whenever any client on the AP is
105
108
  power-saving, which is heard as late haptics and stuttering streams; unicast is
106
109
  not batched that way. The 5-second keep-alive PING keeps that table current, so
@@ -180,25 +183,30 @@ my-app/
180
183
  import hapbeat
181
184
  hb = hapbeat.connect(app_name="MyApp", kit="kits/my-kit")
182
185
  hb.play("sample-kit.sine_100hz") # command -> device plays its installed clip
183
- hb.play("rain.loop") # clip -> SDK streams stream-clips/<wav> over UDP
184
- hb.stop("rain.loop") # ends the active stream
186
+ rain = hb.play("rain.loop") # clip -> returns a StreamPlayback
187
+ rain.pan = -0.5
188
+ rain.stop() # stops only this logical source
185
189
  ```
186
190
 
187
191
  You can also stream an ad-hoc PCM16 buffer (e.g. a synthesized stereo cue where
188
192
  L/R amplitude conveys direction):
189
193
 
190
194
  ```python
191
- hb.stream_pcm(pcm_bytes, sample_rate=16000, channels=2)
195
+ left = hb.stream_pcm(pcm_bytes, sample_rate=16000, channels=2,
196
+ target="player_1/left", loop=True)
197
+ right = hb.stream_pcm(other_pcm, target="player_1/right", loop=True)
198
+ left.gain = 0.5
192
199
  ```
193
200
 
194
- Author clips as **16 kHz mono PCM16** (the device plays at 16 kHz; the SDK does
195
- not resample). A full runnable example is in
201
+ PCM16 input is normalized to **16 kHz stereo** inside the shared endpoint mixer.
202
+ Sources aimed at one endpoint are mixed into one wire session; sources aimed at
203
+ different endpoints run independently. A full runnable example is in
196
204
  [examples/clip_project/](examples/clip_project/).
197
205
 
198
206
  ## Generic OSC bridge
199
207
 
200
208
  Any OSC tool (TouchOSC, Max/MSP, TouchDesigner, a DAW) can drive Hapbeat
201
- without code. Run the bridge and send `/hapbeat/play <event_id> [target] [time] [gain]`:
209
+ without code. Run the bridge and send `/hapbeat/play <event_id> [target] [time] [gain] [pan]`:
202
210
 
203
211
  ```bash
204
212
  hapbeat osc-bridge --listen 7702
@@ -41,6 +41,7 @@ import hapbeat
41
41
  hb = hapbeat.connect(app_name="MyExperiment") # opens the UDP socket + keep-alive
42
42
  hb.play("sample-kit.sine_100hz", gain=0.3) # fire event "sample-kit.sine_100hz" at gain 0.3
43
43
  hb.play("sample-kit.sine_100hz") # gain omitted -> kit baseline intensity
44
+ hb.play("sample-kit.sine_100hz", pan=-1) # pan: -1 left / 0 centre / +1 right
44
45
  hb.stop("sample-kit.sine_100hz")
45
46
  hb.stop_all()
46
47
  hb.close()
@@ -66,8 +67,10 @@ for dev in hb.discover(timeout=1.5):
66
67
 
67
68
  ### How packets are sent
68
69
 
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
70
+ Commands are unicast to devices that answered a PING and use broadcast only
71
+ while no device is known. Clip streams are stricter: they start only on exact
72
+ PONG-reported endpoints and remain `Deferred` while none matches; stream packets
73
+ are never broadcast. Wi-Fi APs hold broadcast frames
71
74
  until their next DTIM beacon (100–300 ms) whenever any client on the AP is
72
75
  power-saving, which is heard as late haptics and stuttering streams; unicast is
73
76
  not batched that way. The 5-second keep-alive PING keeps that table current, so
@@ -147,25 +150,30 @@ my-app/
147
150
  import hapbeat
148
151
  hb = hapbeat.connect(app_name="MyApp", kit="kits/my-kit")
149
152
  hb.play("sample-kit.sine_100hz") # command -> device plays its installed clip
150
- hb.play("rain.loop") # clip -> SDK streams stream-clips/<wav> over UDP
151
- hb.stop("rain.loop") # ends the active stream
153
+ rain = hb.play("rain.loop") # clip -> returns a StreamPlayback
154
+ rain.pan = -0.5
155
+ rain.stop() # stops only this logical source
152
156
  ```
153
157
 
154
158
  You can also stream an ad-hoc PCM16 buffer (e.g. a synthesized stereo cue where
155
159
  L/R amplitude conveys direction):
156
160
 
157
161
  ```python
158
- hb.stream_pcm(pcm_bytes, sample_rate=16000, channels=2)
162
+ left = hb.stream_pcm(pcm_bytes, sample_rate=16000, channels=2,
163
+ target="player_1/left", loop=True)
164
+ right = hb.stream_pcm(other_pcm, target="player_1/right", loop=True)
165
+ left.gain = 0.5
159
166
  ```
160
167
 
161
- Author clips as **16 kHz mono PCM16** (the device plays at 16 kHz; the SDK does
162
- not resample). A full runnable example is in
168
+ PCM16 input is normalized to **16 kHz stereo** inside the shared endpoint mixer.
169
+ Sources aimed at one endpoint are mixed into one wire session; sources aimed at
170
+ different endpoints run independently. A full runnable example is in
163
171
  [examples/clip_project/](examples/clip_project/).
164
172
 
165
173
  ## Generic OSC bridge
166
174
 
167
175
  Any OSC tool (TouchOSC, Max/MSP, TouchDesigner, a DAW) can drive Hapbeat
168
- without code. Run the bridge and send `/hapbeat/play <event_id> [target] [time] [gain]`:
176
+ without code. Run the bridge and send `/hapbeat/play <event_id> [target] [time] [gain] [pan]`:
169
177
 
170
178
  ```bash
171
179
  hapbeat osc-bridge --listen 7702
@@ -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.2.0"
8
+ version = "0.3.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"
@@ -17,9 +17,15 @@ from __future__ import annotations
17
17
 
18
18
  from . import protocol
19
19
  from .client import DEFAULT_BROADCAST, DEFAULT_PORT, UdpClient
20
- from .clip import ClipStreamer
21
20
  from .eventmap import EventDef, EventMap
22
21
  from .hapbeat import Device, Hapbeat, connect
22
+ from .stream import (
23
+ DeferredReason,
24
+ PlaybackStatus,
25
+ StreamEndpoint,
26
+ StreamHub,
27
+ StreamPlayback,
28
+ )
23
29
  from .wav import WavPcm, read_wav_pcm16
24
30
 
25
31
  try: # populated from installed package metadata
@@ -28,9 +34,9 @@ try: # populated from installed package metadata
28
34
  try:
29
35
  __version__ = version("hapbeat-python-sdk")
30
36
  except PackageNotFoundError: # running from a source checkout
31
- __version__ = "0.2.0+local"
37
+ __version__ = "0.3.0+local"
32
38
  except ImportError: # pragma: no cover
33
- __version__ = "0.2.0+local"
39
+ __version__ = "0.3.0+local"
34
40
 
35
41
  __all__ = [
36
42
  "connect",
@@ -38,7 +44,11 @@ __all__ = [
38
44
  "Device",
39
45
  "EventMap",
40
46
  "EventDef",
41
- "ClipStreamer",
47
+ "StreamHub",
48
+ "StreamPlayback",
49
+ "StreamEndpoint",
50
+ "PlaybackStatus",
51
+ "DeferredReason",
42
52
  "WavPcm",
43
53
  "read_wav_pcm16",
44
54
  "UdpClient",
@@ -33,6 +33,8 @@ def build_parser() -> argparse.ArgumentParser:
33
33
  p_play = sub.add_parser("play", help="play an event by id")
34
34
  p_play.add_argument("event_id")
35
35
  p_play.add_argument("--gain", type=float, default=None, help="0..1 (default: kit baseline)")
36
+ p_play.add_argument("--pan", type=float, default=0.0,
37
+ help="-1 left .. 0 centre .. +1 right (default 0)")
36
38
  _add_common(p_play)
37
39
 
38
40
  p_stop = sub.add_parser("stop", help="stop one event id")
@@ -106,9 +108,19 @@ def main(argv: list[str] | None = None) -> int:
106
108
 
107
109
  hb = connect(port=args.port, default_target=getattr(args, "target", ""), keepalive=False)
108
110
  try:
111
+ if args.command in ("play", "stop", "stop-all"):
112
+ # Discover before sending. Without a PONG the SDK knows no devices,
113
+ # so the command would go out as a broadcast — which the AP holds
114
+ # back until the next DTIM beacon, and which on a multi-homed host
115
+ # may leave through an interface with no Hapbeat behind it. One
116
+ # reply is enough to pick the subnet and unicast, and it normally
117
+ # arrives in a few milliseconds; the wait only costs the full
118
+ # window when nothing is there to answer.
119
+ hb.discover(timeout=0.4, stop_early=True)
120
+
109
121
  if args.command == "play":
110
- ok = hb.play(args.event_id, args.gain)
111
- print(f"play {args.event_id} gain={args.gain if args.gain is not None else 'baseline'} -> {'sent' if ok else 'FAILED'}")
122
+ ok = hb.play(args.event_id, args.gain, pan=args.pan)
123
+ print(f"play {args.event_id} gain={args.gain if args.gain is not None else 'baseline'} pan={args.pan} -> {'sent' if ok else 'FAILED'}")
112
124
  elif args.command == "stop":
113
125
  print("sent" if hb.stop(args.event_id) else "FAILED")
114
126
  elif args.command == "stop-all":
@@ -0,0 +1,411 @@
1
+ """UDP transport for the Hapbeat SDK.
2
+
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.
8
+
9
+ Design note — port binding:
10
+ The standard transport is Wi-Fi UDP broadcast (see workspace CLAUDE.md).
11
+ To receive PONG replies we bind the well-known port 7700. If that port is
12
+ already taken (e.g. hapbeat-helper is running on the same machine) we fall
13
+ back to an ephemeral bind: sends still work and PING replies still arrive
14
+ at the ephemeral source port; only async broadcast pushes on 7700 are
15
+ missed, which level-1 does not rely on.
16
+
17
+ Design note — broadcast destinations:
18
+ Discovery fans out across every local subnet (see :class:`BroadcastRoute`),
19
+ while playback goes to exactly one destination. Keeping those two apart is
20
+ not a detail: firmware older than v0.3.0 does not de-duplicate PLAY/STOP by
21
+ sequence number, so a device reachable on two destinations would fire the
22
+ haptic twice. PING and CONNECT_STATUS are idempotent and safe to duplicate.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import socket
29
+ import threading
30
+ import time
31
+ from typing import Callable, Iterable, List, Optional
32
+
33
+ from . import protocol
34
+ from .netif import local_ipv4_subnets
35
+
36
+ logger = logging.getLogger("hapbeat")
37
+
38
+ DEFAULT_PORT = 7700
39
+ DEFAULT_BROADCAST = "255.255.255.255"
40
+
41
+ PongCallback = Callable[[dict, str], None]
42
+
43
+ # Stand-in "destination" for the case where a whole fan-out failed, so that
44
+ # outage gets the same once-per-outage treatment as a single address.
45
+ _ALL_DESTINATIONS = "all broadcast destinations"
46
+
47
+
48
+ def _to_uint32(dotted: str) -> int:
49
+ a, b, c, d = (int(p) for p in dotted.split("."))
50
+ return (a << 24) | (b << 16) | (c << 8) | d
51
+
52
+
53
+ def _to_dotted(value: int) -> str:
54
+ return "{}.{}.{}.{}".format(
55
+ (value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF
56
+ )
57
+
58
+
59
+ class BroadcastRoute:
60
+ """One address the SDK can broadcast to.
61
+
62
+ ``255.255.255.255`` leaves a multi-homed host through the single interface
63
+ with the lowest metric. On a machine running Hyper-V / WSL2 / Docker that is
64
+ often an always-up virtual switch with no Hapbeat behind it — and no
65
+ Ethernet cable is needed for that to happen, which is why the symptom looks
66
+ nothing like "multi-homed". A subnet-directed address (``192.168.0.255``)
67
+ resolves through the directly-connected route for its subnet instead, so the
68
+ metric never applies. That is also why hapbeat-helper kept working on such a
69
+ host: it finds devices over mDNS and unicasts, which resolves the same way.
70
+ """
71
+
72
+ __slots__ = ("addr", "network", "mask", "limited")
73
+
74
+ def __init__(self, addr: str, network: int = 0, mask: int = 0,
75
+ limited: bool = False) -> None:
76
+ self.addr = addr
77
+ self.network = network
78
+ self.mask = mask
79
+ self.limited = limited
80
+
81
+ def contains(self, ip: str) -> bool:
82
+ """Whether ``ip`` sits on this route's subnet."""
83
+ if self.limited or not self.mask:
84
+ return False
85
+ try:
86
+ return (_to_uint32(ip) & self.mask) == self.network
87
+ except (ValueError, AttributeError):
88
+ return False
89
+
90
+ def __repr__(self) -> str: # pragma: no cover — debugging aid
91
+ return f"BroadcastRoute({self.addr})"
92
+
93
+
94
+ def enumerate_broadcast_routes() -> List[BroadcastRoute]:
95
+ """One destination per local IPv4 subnet, plus the limited broadcast.
96
+
97
+ The address comes from each interface's real mask rather than being assumed
98
+ to end in ``.255``: a /16 broadcasts to ``x.y.255.255`` and a /25 to
99
+ ``x.y.z.127``, and the subnet itself is whatever the router hands out.
100
+ Deduplicated by address — two interfaces on one subnet (a laptop docked over
101
+ Ethernet while Wi-Fi is still up) would otherwise deliver every packet twice.
102
+
103
+ The limited broadcast is always kept as a catch-all, so SoftAP setups and
104
+ platforms whose interfaces cannot be enumerated behave exactly as before.
105
+ """
106
+ routes: List[BroadcastRoute] = []
107
+ seen = set()
108
+
109
+ for ip, prefix in local_ipv4_subnets():
110
+ try:
111
+ ip_value = _to_uint32(ip)
112
+ except ValueError:
113
+ continue
114
+ mask = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
115
+ addr = _to_dotted((ip_value & mask) | (~mask & 0xFFFFFFFF))
116
+ if addr in seen:
117
+ continue
118
+ seen.add(addr)
119
+ routes.append(BroadcastRoute(addr, network=ip_value & mask, mask=mask))
120
+
121
+ routes.append(BroadcastRoute(DEFAULT_BROADCAST, limited=True))
122
+ return routes
123
+
124
+
125
+ class UdpClient:
126
+ """Broadcast-capable UDP socket plus a PONG receive loop."""
127
+
128
+ def __init__(
129
+ self,
130
+ port: int = DEFAULT_PORT,
131
+ broadcast_addr: str = DEFAULT_BROADCAST,
132
+ bind_port: int = 0,
133
+ ) -> None:
134
+ self.port = port # destination port (the device listens here)
135
+ # Local receive bind. Default 0 = ephemeral: this leaves the
136
+ # well-known device port (7700) to the single host daemon that owns
137
+ # it (hapbeat-helper, serving Hapbeat Studio) so an SDK script and
138
+ # Studio coexist. PING replies still arrive at the ephemeral source
139
+ # port, so discovery keeps working. Pass ``bind_port=port`` only to
140
+ # also receive the device's unsolicited broadcasts (daemon use).
141
+ self.bind_port = bind_port
142
+ self.broadcast_addr = broadcast_addr
143
+ # An explicit broadcast_addr is the caller naming the destination, so
144
+ # honour it verbatim: no enumeration, no fan-out, no route locking.
145
+ # This doubles as the escape hatch for anyone who does need to pin the
146
+ # destination by hand, which is why there is no separate setting.
147
+ self._auto_routes = broadcast_addr == DEFAULT_BROADCAST
148
+ self._routes: List[BroadcastRoute] = []
149
+ # The route a device actually answered on; None until the first PONG.
150
+ # Written from the receive thread, read from every sending thread —
151
+ # rebinding one attribute, which is atomic under the GIL.
152
+ self._locked_route: Optional[BroadcastRoute] = None
153
+ self._sock: Optional[socket.socket] = None
154
+ self._thread: Optional[threading.Thread] = None
155
+ self._running = False
156
+ self._bound_well_known = False
157
+ # Destinations currently in a failed state, so each outage is reported
158
+ # once rather than once per packet. See _note_send_failed.
159
+ self._failed_destinations: set[str] = set()
160
+ self._pong_callbacks: list[PongCallback] = []
161
+
162
+ # ── Lifecycle ───────────────────────────────────────────────────
163
+ def open(self) -> None:
164
+ if self._sock is not None:
165
+ return
166
+ # Enumerated per open() rather than once per process: the host's
167
+ # interfaces change when a laptop is docked, a VPN comes up or Wi-Fi
168
+ # moves to another network.
169
+ self._routes = enumerate_broadcast_routes() if self._auto_routes else []
170
+ self._locked_route = None
171
+ self._failed_destinations = set()
172
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
173
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
174
+ # Windows only: a prior sendto() to an unreachable peer queues an ICMP
175
+ # port-unreachable, which Winsock surfaces as ConnectionResetError
176
+ # (WinError 10054) on the *next* recvfrom() — even on this unconnected
177
+ # socket. Once we unicast (a device that is off, rebooting, or being
178
+ # reflashed is unreachable) that becomes routine, and the recv loop
179
+ # below would die on it: the SDK would then go deaf to ALL PONGs until
180
+ # the process restarts. SIO_UDP_CONNRESET=False tells Windows to ignore
181
+ # those resets. No-op elsewhere (the constant is Windows-only).
182
+ # Same fix as hapbeat-helper f06fa04, which shipped before this SDK.
183
+ if hasattr(socket, "SIO_UDP_CONNRESET"):
184
+ try:
185
+ sock.ioctl(socket.SIO_UDP_CONNRESET, False)
186
+ except OSError as exc:
187
+ logger.debug("SIO_UDP_CONNRESET ioctl failed (non-fatal): %s", exc)
188
+ if self.bind_port == self.port:
189
+ # Only when contending for the shared well-known port do we ask to
190
+ # reuse it; an ephemeral bind needs no reuse and must not steal a
191
+ # port another process owns.
192
+ try:
193
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
194
+ except OSError:
195
+ pass
196
+ try:
197
+ sock.bind(("0.0.0.0", self.bind_port))
198
+ self._bound_well_known = self.bind_port == self.port
199
+ except OSError as exc:
200
+ logger.warning(
201
+ "port %d busy (%s); binding ephemeral port (discovery still "
202
+ "works, async broadcast pushes are not received)",
203
+ self.bind_port,
204
+ exc,
205
+ )
206
+ try:
207
+ sock.bind(("0.0.0.0", 0))
208
+ except OSError:
209
+ pass
210
+ self._bound_well_known = False
211
+ sock.settimeout(0.2)
212
+ self._sock = sock
213
+ self._running = True
214
+ self._thread = threading.Thread(
215
+ target=self._recv_loop, name="hapbeat-recv", daemon=True
216
+ )
217
+ self._thread.start()
218
+
219
+ def close(self) -> None:
220
+ self._running = False
221
+ sock, self._sock = self._sock, None
222
+ if sock is not None:
223
+ try:
224
+ sock.close()
225
+ except OSError:
226
+ pass
227
+ if self._thread is not None:
228
+ self._thread.join(timeout=1.0)
229
+ self._thread = None
230
+
231
+ # ── Listeners ───────────────────────────────────────────────────
232
+ def add_pong_listener(self, cb: PongCallback) -> None:
233
+ self._pong_callbacks.append(cb)
234
+
235
+ def remove_pong_listener(self, cb: PongCallback) -> None:
236
+ try:
237
+ self._pong_callbacks.remove(cb)
238
+ except ValueError:
239
+ pass
240
+
241
+ # ── Send ────────────────────────────────────────────────────────
242
+ def broadcast_destination(self) -> str:
243
+ """The single address a broadcast currently goes to.
244
+
245
+ Once a device has answered this is its subnet's broadcast address;
246
+ before that (and whenever the caller named ``broadcast_addr``) it is the
247
+ limited broadcast, exactly as it has always been.
248
+ """
249
+ locked = self._locked_route
250
+ return locked.addr if locked is not None else self.broadcast_addr
251
+
252
+ def _note_send_failed(self, dst: str, exc: OSError) -> None:
253
+ """Report a failed send at most once per destination, per outage.
254
+
255
+ A link that fails keeps failing, and stream chunks go out roughly 100
256
+ times a second — logging every one would bury the very lines an operator
257
+ needs. Tracked per destination rather than globally: a host normally has
258
+ an adapter that can never carry a broadcast (Bluetooth PAN, Wi-Fi
259
+ Direct, a virtual switch), and one shared flag would flip to "failed" and
260
+ back to "recovered" on every single fan-out. Cleared when that same
261
+ destination works again, so a later, unrelated outage is still reported.
262
+ """
263
+ if dst in self._failed_destinations:
264
+ return
265
+ self._failed_destinations.add(dst)
266
+ logger.warning(
267
+ "UDP send to %s:%d failed: %s. The socket stays open; further send "
268
+ "errors to this destination are silenced until it recovers.",
269
+ dst, self.port, exc)
270
+
271
+ def _note_send_ok(self, dst: str) -> None:
272
+ """Say once that a destination works again.
273
+
274
+ Without this an unattended installation's log shows when haptics broke
275
+ but never whether they came back.
276
+ """
277
+ if dst not in self._failed_destinations:
278
+ return
279
+ self._failed_destinations.discard(dst)
280
+ logger.info("UDP sending to %s recovered.", dst)
281
+
282
+ def send(self, packet: bytes, addr: Optional[str] = None) -> bool:
283
+ """Send a prebuilt packet. ``addr=None`` -> broadcast.
284
+
285
+ One destination only. A failure is reported but never tears anything
286
+ down: a lost datagram says nothing about whether the socket is still
287
+ usable, and closing it here is what turns one Wi-Fi hiccup into a
288
+ session that never recovers.
289
+ """
290
+ sock = self._sock
291
+ if sock is None:
292
+ logger.error("send before open()")
293
+ return False
294
+ dst = self.broadcast_destination() if not addr else addr
295
+ try:
296
+ sock.sendto(packet, (dst, self.port))
297
+ self._note_send_ok(dst)
298
+ return True
299
+ except OSError as exc:
300
+ self._note_send_failed(dst, exc)
301
+ return False
302
+
303
+ def send_discovery(self, packet: bytes) -> bool:
304
+ """Send on every candidate broadcast destination.
305
+
306
+ Reserved for PING and CONNECT_STATUS. Both are idempotent, so a device
307
+ reachable on two destinations simply receives the message twice with no
308
+ visible effect — whereas duplicating PLAY would fire the haptic twice on
309
+ firmware that predates sequence de-duplication. This fan-out is what
310
+ reaches a device the limited broadcast never gets to, and the PONG it
311
+ provokes is what pins playback to the right subnet.
312
+ """
313
+ sock = self._sock
314
+ if sock is None:
315
+ logger.error("send before open()")
316
+ return False
317
+ # Already pinned to a subnet, or no enumeration to fan out over: one
318
+ # destination, same as any other packet.
319
+ if self._locked_route is not None or not self._routes:
320
+ return self.send(packet)
321
+
322
+ sent = False
323
+ last_error: Optional[OSError] = None
324
+ for route in self._routes:
325
+ try:
326
+ sock.sendto(packet, (route.addr, self.port))
327
+ sent = True
328
+ self._note_send_ok(route.addr)
329
+ except OSError as exc:
330
+ # Routine, and not worth a warning on its own: most hosts carry
331
+ # an adapter that can never take a broadcast (Bluetooth PAN,
332
+ # Wi-Fi Direct, an idle virtual switch). Trying anyway and
333
+ # letting the others through is exactly what this fan-out is
334
+ # for. Only total failure below is worth an operator's
335
+ # attention — the difference is "one path is dead" versus
336
+ # "discovery is dead".
337
+ last_error = exc
338
+ logger.debug("discovery send to %s:%d failed: %s",
339
+ route.addr, self.port, exc)
340
+ if sent:
341
+ # Clear the "nothing got out" state too, so a second, later outage
342
+ # is reported instead of being swallowed by the first one's flag.
343
+ self._note_send_ok(_ALL_DESTINATIONS)
344
+ elif last_error is not None:
345
+ self._note_send_failed(_ALL_DESTINATIONS, last_error)
346
+ return sent
347
+
348
+ def _lock_route_for(self, ip: str) -> None:
349
+ """Pin broadcasts to the subnet a device actually replied from.
350
+
351
+ First reply wins, and the choice lasts until the socket is reopened.
352
+ With devices on two subnets at once this settles on whichever answered
353
+ first; broadcasts do not cross subnets anyway, so the alternative is not
354
+ reaching both, it is reaching neither reliably.
355
+ """
356
+ if self._locked_route is not None or not self._routes:
357
+ return
358
+ for route in self._routes:
359
+ if route.contains(ip):
360
+ self._locked_route = route
361
+ logger.info("broadcasting to %s (a device answered from %s)",
362
+ route.addr, ip)
363
+ return
364
+
365
+ def send_many(self, packet: bytes, addrs: Iterable[str]) -> bool:
366
+ """Unicast the same packet to several devices.
367
+
368
+ Returns True if at least one send succeeded. One unreachable device
369
+ (powered off between its last PONG and now) must not stop the packet
370
+ from reaching the others, so failures are logged and skipped.
371
+ """
372
+ sent = False
373
+ for addr in addrs:
374
+ if self.send(packet, addr):
375
+ sent = True
376
+ return sent
377
+
378
+ # ── Recv loop ───────────────────────────────────────────────────
379
+ def _recv_loop(self) -> None:
380
+ while self._running:
381
+ sock = self._sock
382
+ if sock is None:
383
+ break
384
+ try:
385
+ data, addr = sock.recvfrom(4096)
386
+ except socket.timeout:
387
+ continue
388
+ except OSError as exc:
389
+ if not self._running or self._sock is None:
390
+ break # closed by close() — expected exit
391
+ # Still running: residual ICMP-unreachable (should be
392
+ # suppressed by SIO_UDP_CONNRESET above, but stay defensive in
393
+ # case the ioctl was unavailable) or a brief NIC flap. Breaking
394
+ # here is exactly what makes an SDK go permanently deaf to
395
+ # PONGs, so back off briefly and keep listening.
396
+ logger.debug("UDP recv transient error (continuing): %s", exc)
397
+ time.sleep(0.2)
398
+ continue
399
+ pong = protocol.parse_pong(data)
400
+ if pong is None:
401
+ continue
402
+ ip = addr[0]
403
+ # A reply proves which subnet the device is really on. Until this
404
+ # happens a broadcast still goes out limited, which on a multi-homed
405
+ # host may be leaving through an interface with no Hapbeat behind it.
406
+ self._lock_route_for(ip)
407
+ for cb in list(self._pong_callbacks):
408
+ try:
409
+ cb(pong, ip)
410
+ except Exception: # noqa: BLE001 — never let a listener kill the loop
411
+ logger.exception("pong listener raised")