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.
- {hapbeat_python_sdk-0.2.0/src/hapbeat_python_sdk.egg-info → hapbeat_python_sdk-0.3.0}/PKG-INFO +17 -9
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/README.md +16 -8
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/pyproject.toml +1 -1
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/__init__.py +14 -4
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/cli.py +14 -2
- hapbeat_python_sdk-0.3.0/src/hapbeat/client.py +411 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/hapbeat.py +293 -78
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/launchpad.py +2 -1
- hapbeat_python_sdk-0.3.0/src/hapbeat/netif.py +270 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/osc.py +4 -2
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/protocol.py +165 -23
- hapbeat_python_sdk-0.3.0/src/hapbeat/stream.py +766 -0
- hapbeat_python_sdk-0.3.0/src/hapbeat/stream_lease.py +182 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0/src/hapbeat_python_sdk.egg-info}/PKG-INFO +17 -9
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/SOURCES.txt +6 -1
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/tests/test_addressing.py +27 -18
- hapbeat_python_sdk-0.3.0/tests/test_broadcast_routes.py +285 -0
- hapbeat_python_sdk-0.3.0/tests/test_clip.py +390 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/tests/test_hapbeat.py +26 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/tests/test_overlay_osc.py +8 -5
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/tests/test_protocol.py +20 -1
- hapbeat_python_sdk-0.3.0/tests/test_stream_mix.py +168 -0
- hapbeat_python_sdk-0.3.0/tests/test_stream_session_v2.py +673 -0
- hapbeat_python_sdk-0.2.0/src/hapbeat/client.py +0 -190
- hapbeat_python_sdk-0.2.0/src/hapbeat/clip.py +0 -121
- hapbeat_python_sdk-0.2.0/tests/test_clip.py +0 -204
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/LICENSE +0 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/setup.cfg +0 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/__main__.py +0 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/eventmap.py +0 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/update_check.py +0 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/wav.py +0 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/dependency_links.txt +0 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/entry_points.txt +0 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/requires.txt +0 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/top_level.txt +0 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/tests/test_examples.py +0 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/tests/test_launchpad.py +0 -0
- {hapbeat_python_sdk-0.2.0 → hapbeat_python_sdk-0.3.0}/tests/test_update_check.py +0 -0
{hapbeat_python_sdk-0.2.0/src/hapbeat_python_sdk.egg-info → hapbeat_python_sdk-0.3.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hapbeat-python-sdk
|
|
3
|
-
Version: 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
|
|
103
|
-
|
|
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")
|
|
184
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
|
70
|
-
|
|
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")
|
|
151
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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.
|
|
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.
|
|
37
|
+
__version__ = "0.3.0+local"
|
|
32
38
|
except ImportError: # pragma: no cover
|
|
33
|
-
__version__ = "0.
|
|
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
|
-
"
|
|
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")
|