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.
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/PKG-INFO +35 -14
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/README.md +34 -13
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/pyproject.toml +1 -1
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/__init__.py +3 -3
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/cli.py +8 -2
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/client.py +44 -6
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/eventmap.py +1 -1
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/hapbeat.py +144 -19
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/launchpad.py +1 -1
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/protocol.py +45 -0
- hapbeat_python_sdk-0.2.0/src/hapbeat/update_check.py +213 -0
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat_python_sdk.egg-info/PKG-INFO +35 -14
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat_python_sdk.egg-info/SOURCES.txt +4 -1
- hapbeat_python_sdk-0.2.0/tests/test_addressing.py +183 -0
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/tests/test_examples.py +2 -2
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/tests/test_hapbeat.py +9 -1
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/tests/test_launchpad.py +4 -4
- hapbeat_python_sdk-0.2.0/tests/test_update_check.py +162 -0
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/LICENSE +0 -0
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/setup.cfg +0 -0
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/__main__.py +0 -0
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/clip.py +0 -0
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/osc.py +0 -0
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat/wav.py +0 -0
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat_python_sdk.egg-info/dependency_links.txt +0 -0
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat_python_sdk.egg-info/entry_points.txt +0 -0
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat_python_sdk.egg-info/requires.txt +0 -0
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/src/hapbeat_python_sdk.egg-info/top_level.txt +0 -0
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/tests/test_clip.py +0 -0
- {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.2.0}/tests/test_overlay_osc.py +0 -0
- {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.
|
|
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
|
|
75
|
-
hb.play("
|
|
76
|
-
hb.play("
|
|
77
|
-
hb.stop("
|
|
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("
|
|
86
|
+
hb.play("sample-kit.sine_100hz")
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
`"
|
|
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("
|
|
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({"
|
|
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
|
-
"
|
|
127
|
-
"rain.loop": { "target": "*/
|
|
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("
|
|
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("
|
|
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
|
|
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
|
|
42
|
-
hb.play("
|
|
43
|
-
hb.play("
|
|
44
|
-
hb.stop("
|
|
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("
|
|
53
|
+
hb.play("sample-kit.sine_100hz")
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
`"
|
|
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("
|
|
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({"
|
|
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
|
-
"
|
|
94
|
-
"rain.loop": { "target": "*/
|
|
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("
|
|
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("
|
|
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
|
|
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.
|
|
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("
|
|
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.
|
|
31
|
+
__version__ = "0.2.0+local"
|
|
32
32
|
except ImportError: # pragma: no cover
|
|
33
|
-
__version__ = "0.
|
|
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
|
|
7
|
-
hapbeat stop
|
|
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
|
|
4
|
-
the LAN broadcast address and runs a background
|
|
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
|
-
|
|
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
|
-
"
|
|
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("
|
|
15
|
-
hb.stop("
|
|
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("
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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.
|
|
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.
|
|
446
|
+
self._send_stream(protocol.build_stream_data(self._next_seq(), offset, data))
|
|
348
447
|
|
|
349
448
|
def stream_end(self) -> None:
|
|
350
|
-
self.
|
|
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.
|
|
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.
|
|
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.
|
|
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>
|