hapbeat-python-sdk 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl
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/__init__.py +3 -3
- hapbeat/cli.py +8 -2
- hapbeat/client.py +44 -6
- hapbeat/eventmap.py +1 -1
- hapbeat/hapbeat.py +144 -19
- hapbeat/launchpad.py +1 -1
- hapbeat/protocol.py +45 -0
- hapbeat/update_check.py +213 -0
- {hapbeat_python_sdk-0.1.0.dist-info → hapbeat_python_sdk-0.2.0.dist-info}/METADATA +35 -14
- hapbeat_python_sdk-0.2.0.dist-info/RECORD +18 -0
- {hapbeat_python_sdk-0.1.0.dist-info → hapbeat_python_sdk-0.2.0.dist-info}/WHEEL +1 -1
- hapbeat_python_sdk-0.1.0.dist-info/RECORD +0 -17
- {hapbeat_python_sdk-0.1.0.dist-info → hapbeat_python_sdk-0.2.0.dist-info}/entry_points.txt +0 -0
- {hapbeat_python_sdk-0.1.0.dist-info → hapbeat_python_sdk-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {hapbeat_python_sdk-0.1.0.dist-info → hapbeat_python_sdk-0.2.0.dist-info}/top_level.txt +0 -0
hapbeat/__init__.py
CHANGED
|
@@ -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",
|
hapbeat/cli.py
CHANGED
|
@@ -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
|
|
hapbeat/client.py
CHANGED
|
@@ -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
|
hapbeat/eventmap.py
CHANGED
|
@@ -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
|
}
|
hapbeat/hapbeat.py
CHANGED
|
@@ -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()
|
hapbeat/launchpad.py
CHANGED
|
@@ -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>
|
hapbeat/protocol.py
CHANGED
|
@@ -196,6 +196,51 @@ def build_stream_end(seq: int) -> bytes:
|
|
|
196
196
|
return build_packet(CMD_STREAM_END, seq, b"")
|
|
197
197
|
|
|
198
198
|
|
|
199
|
+
# ── Device addressing ───────────────────────────────────────────────
|
|
200
|
+
def address_matches(target: Optional[str], device_address: Optional[str]) -> bool:
|
|
201
|
+
"""Does ``device_address`` accept a packet aimed at ``target``?
|
|
202
|
+
|
|
203
|
+
A port of the firmware's ``addressMatch()``
|
|
204
|
+
(hapbeat-device-firmware/src/address_match.cpp), which is the authority:
|
|
205
|
+
the device re-applies this filter on receipt, so the SDK only uses it to
|
|
206
|
+
pick unicast destinations, never to decide what plays.
|
|
207
|
+
|
|
208
|
+
Rules (contracts/specs/device-addressing.md §4.3):
|
|
209
|
+
|
|
210
|
+
- an empty target matches every address;
|
|
211
|
+
- both strings are split on ``/`` and compared **positionally**, so
|
|
212
|
+
``group_2`` alone is compared against the *player* slot and never
|
|
213
|
+
matches — write ``*/*/group_2``;
|
|
214
|
+
- a ``*`` target segment matches any single address segment;
|
|
215
|
+
- fewer target segments than address segments is a front-match (extra
|
|
216
|
+
address segments do not cause a mismatch);
|
|
217
|
+
- more target segments than address segments is a mismatch;
|
|
218
|
+
- a single trailing ``/`` is ignored (``"player_1/" == "player_1"``),
|
|
219
|
+
because firmware's pointer walk exits on the terminator rather than
|
|
220
|
+
producing an empty segment. Splitting naively here would drop a device
|
|
221
|
+
the firmware *would* have accepted.
|
|
222
|
+
"""
|
|
223
|
+
if not target:
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
target_segments = target.split("/")
|
|
227
|
+
address_segments = (device_address or "").split("/")
|
|
228
|
+
|
|
229
|
+
# Only the last empty segment is dropped, and only once: "a//" really does
|
|
230
|
+
# compare an empty segment in firmware, and still mismatches.
|
|
231
|
+
count = len(target_segments)
|
|
232
|
+
if count > 1 and target_segments[count - 1] == "":
|
|
233
|
+
count -= 1
|
|
234
|
+
|
|
235
|
+
for i in range(count):
|
|
236
|
+
if i >= len(address_segments):
|
|
237
|
+
return False # target longer than address = mismatch
|
|
238
|
+
if target_segments[i] != "*" and target_segments[i] != address_segments[i]:
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
return True # front-match or exact match
|
|
242
|
+
|
|
243
|
+
|
|
199
244
|
# ── Response parsers ────────────────────────────────────────────────
|
|
200
245
|
def _read_cstr(buf: bytes) -> tuple[str, bytes]:
|
|
201
246
|
idx = buf.find(b"\x00")
|
hapbeat/update_check.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Update notice — 「新しい hapbeat-python-sdk が出ています」を 1 回だけ伝える。
|
|
2
|
+
|
|
3
|
+
情報源は Hapbeat の release feed (``https://devtools.hapbeat.com/releases.json``)。
|
|
4
|
+
仕様は hapbeat-contracts ``specs/release-feed.md`` (DEC-053)。feed は GitHub の
|
|
5
|
+
タグではなく **PyPI に実際に上がっている版** を載せているので、ここに出る版は
|
|
6
|
+
必ず ``pip install -U`` で取得できる。
|
|
7
|
+
|
|
8
|
+
守っている作法 (spec §5):
|
|
9
|
+
|
|
10
|
+
* **``import hapbeat`` では絶対にネットワークへ出ない。** チェックするのは
|
|
11
|
+
``hapbeat`` コマンドを実行したときだけ。ライブラリの import が外部通信を
|
|
12
|
+
するのは、CI・オフライン環境・サンドボックスで有害な副作用になる。
|
|
13
|
+
* **24 時間に 1 回**まで (§5.1 B)。閉じる操作を要求しない 1 行なので版ごとの永続
|
|
14
|
+
抑制はしないが、``hapbeat play`` のような短命コマンドは連続実行され得るため
|
|
15
|
+
時間で間引く。**新しい版が出たら間隔を待たずに出す**。
|
|
16
|
+
* 取得失敗は**完全にサイレント**。「最新版を確認できませんでした」は出さない。
|
|
17
|
+
* CLI の実行を**待たせない**。キャッシュがあればそれを使い、無ければ裏で取りに
|
|
18
|
+
行くだけで、その回は何も出さない (次回の実行で出る)。
|
|
19
|
+
* 出力は英語 (この CLI の他の出力に合わせる)。
|
|
20
|
+
* ``HAPBEAT_NO_UPDATE_CHECK=1`` で無効化できる。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
import urllib.request
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
FEED_URL = "https://devtools.hapbeat.com/releases.json"
|
|
35
|
+
PRODUCT_ID = "python-sdk"
|
|
36
|
+
TIMEOUT_S = 3.0
|
|
37
|
+
CACHE_TTL_S = 24 * 60 * 60
|
|
38
|
+
#: 同じ版について再通知するまでの最短間隔。`hapbeat play` のような短命コマンドが
|
|
39
|
+
#: 連続実行されても 1 日 1 行に収める。版が変われば間隔を待たずに出す。
|
|
40
|
+
NOTIFY_INTERVAL_S = 24 * 60 * 60
|
|
41
|
+
STATE_FILENAME = "update-check.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def config_dir() -> Path:
|
|
45
|
+
if sys.platform == "win32":
|
|
46
|
+
base = Path(os.environ.get("APPDATA", str(Path.home())))
|
|
47
|
+
return base / "hapbeat"
|
|
48
|
+
return Path.home() / ".config" / "hapbeat"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def opted_out() -> bool:
|
|
52
|
+
return os.environ.get("HAPBEAT_NO_UPDATE_CHECK", "").strip() not in ("", "0", "false")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------
|
|
56
|
+
# version compare
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_version(v: str | None) -> tuple[int, ...]:
|
|
60
|
+
"""``0.2.0`` / ``v0.2.0`` / ``0.2.0dev1`` → ``(0, 2, 0)``。解釈不能なら ``()``。"""
|
|
61
|
+
if not v:
|
|
62
|
+
return ()
|
|
63
|
+
s = str(v).strip().lstrip("vV")
|
|
64
|
+
head = s.split("-", 1)[0].split("+", 1)[0]
|
|
65
|
+
out: list[int] = []
|
|
66
|
+
for part in head.split("."):
|
|
67
|
+
digits = ""
|
|
68
|
+
for ch in part:
|
|
69
|
+
if ch.isdigit():
|
|
70
|
+
digits += ch
|
|
71
|
+
else:
|
|
72
|
+
break
|
|
73
|
+
if not digits:
|
|
74
|
+
return ()
|
|
75
|
+
out.append(int(digits))
|
|
76
|
+
return tuple(out)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_newer(candidate: str | None, baseline: str | None) -> bool:
|
|
80
|
+
"""比較不能なら False (黙る側に倒す)。"""
|
|
81
|
+
a, b = parse_version(candidate), parse_version(baseline)
|
|
82
|
+
if not a or not b:
|
|
83
|
+
return False
|
|
84
|
+
return a > b
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --------------------------------------------------------------------------
|
|
88
|
+
# state
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _state_path() -> Path:
|
|
92
|
+
return config_dir() / STATE_FILENAME
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _load_state() -> dict[str, Any]:
|
|
96
|
+
try:
|
|
97
|
+
return json.loads(_state_path().read_text(encoding="utf-8"))
|
|
98
|
+
except Exception:
|
|
99
|
+
return {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _save_state(state: dict[str, Any]) -> None:
|
|
103
|
+
try:
|
|
104
|
+
p = _state_path()
|
|
105
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
p.write_text(json.dumps(state), encoding="utf-8")
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --------------------------------------------------------------------------
|
|
112
|
+
# feed
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _fetch_entry() -> dict[str, Any] | None:
|
|
116
|
+
req = urllib.request.Request(FEED_URL, headers={"User-Agent": "hapbeat-python-sdk"})
|
|
117
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT_S) as res: # noqa: S310 (fixed https URL)
|
|
118
|
+
feed = json.loads(res.read().decode("utf-8"))
|
|
119
|
+
if feed.get("schema_version") != 1:
|
|
120
|
+
return None
|
|
121
|
+
entry = feed.get("products", {}).get(PRODUCT_ID)
|
|
122
|
+
return entry if isinstance(entry, dict) else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cached_entry() -> dict[str, Any] | None:
|
|
126
|
+
"""ネットワークを叩かずに、生きているキャッシュだけを返す。"""
|
|
127
|
+
state = _load_state()
|
|
128
|
+
entry = state.get("entry")
|
|
129
|
+
if entry and (time.time() - state.get("checked_at", 0)) < CACHE_TTL_S:
|
|
130
|
+
return entry
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def refresh(*, use_cache: bool = True) -> dict[str, Any] | None:
|
|
135
|
+
"""feed を取得 (or キャッシュ) する。取得できなければ None。"""
|
|
136
|
+
if use_cache:
|
|
137
|
+
cached = cached_entry()
|
|
138
|
+
if cached:
|
|
139
|
+
return cached
|
|
140
|
+
try:
|
|
141
|
+
entry = _fetch_entry()
|
|
142
|
+
except Exception:
|
|
143
|
+
return None
|
|
144
|
+
if not entry:
|
|
145
|
+
return None
|
|
146
|
+
state = _load_state()
|
|
147
|
+
state["entry"] = entry
|
|
148
|
+
state["checked_at"] = time.time()
|
|
149
|
+
_save_state(state)
|
|
150
|
+
return entry
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _refresh_in_background() -> None:
|
|
154
|
+
threading.Thread(
|
|
155
|
+
target=lambda: refresh(use_cache=False),
|
|
156
|
+
name="hapbeat-update-check",
|
|
157
|
+
daemon=True,
|
|
158
|
+
).start()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --------------------------------------------------------------------------
|
|
162
|
+
# public API
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def pending_notice(current: str, entry: dict[str, Any] | None = None,
|
|
166
|
+
*, respect_interval: bool = True) -> str | None:
|
|
167
|
+
"""通知すべきなら 1 行のメッセージを返す。無ければ None。"""
|
|
168
|
+
if opted_out():
|
|
169
|
+
return None
|
|
170
|
+
if entry is None:
|
|
171
|
+
entry = cached_entry()
|
|
172
|
+
if not entry:
|
|
173
|
+
return None
|
|
174
|
+
latest = entry.get("latest")
|
|
175
|
+
if not is_newer(latest, current):
|
|
176
|
+
return None
|
|
177
|
+
if respect_interval:
|
|
178
|
+
state = _load_state()
|
|
179
|
+
# 同じ版を 24h 以内に出していたら黙る。版が変わっていれば即出す。
|
|
180
|
+
if (state.get("notified") == latest
|
|
181
|
+
and (time.time() - state.get("notified_at", 0)) < NOTIFY_INTERVAL_S):
|
|
182
|
+
return None
|
|
183
|
+
upgrade = entry.get("upgrade") or "pip install -U hapbeat-python-sdk"
|
|
184
|
+
return f"note: hapbeat-python-sdk {latest} is available ({upgrade})"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def mark_notified(latest: str) -> None:
|
|
188
|
+
state = _load_state()
|
|
189
|
+
state["notified"] = latest
|
|
190
|
+
state["notified_at"] = time.time()
|
|
191
|
+
_save_state(state)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def notify_cli(current: str, *, stream=None) -> None:
|
|
195
|
+
"""CLI 実行時のフック。**待たせない**のが最優先。
|
|
196
|
+
|
|
197
|
+
キャッシュがあればその場で判定して出す。無ければ裏で取りに行くだけで、
|
|
198
|
+
その回は黙る (次に CLI を使ったときに出る)。コマンドの実行を数秒
|
|
199
|
+
ブロックしてまで伝えるほどの用件ではない。
|
|
200
|
+
"""
|
|
201
|
+
if opted_out():
|
|
202
|
+
return
|
|
203
|
+
try:
|
|
204
|
+
entry = cached_entry()
|
|
205
|
+
if entry is None:
|
|
206
|
+
_refresh_in_background()
|
|
207
|
+
return
|
|
208
|
+
msg = pending_notice(current, entry)
|
|
209
|
+
if msg:
|
|
210
|
+
print(msg, file=stream if stream is not None else sys.stderr)
|
|
211
|
+
mark_notified(entry["latest"])
|
|
212
|
+
except Exception:
|
|
213
|
+
pass # 更新通知が原因で CLI を壊さない
|
|
@@ -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
|
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
hapbeat/__init__.py,sha256=I2L0uVH60jLt69p92jn4xgoXc_n1n8mbVy9biSC2F-8,1279
|
|
2
|
+
hapbeat/__main__.py,sha256=ji2N-J5BX8XSUrihVzwQuttefiOYsu3pTzuYAsgMIUU,120
|
|
3
|
+
hapbeat/cli.py,sha256=-et9uHi__I8x32uhV5gjzebmEOUngVdB5fOMs30uq3A,5027
|
|
4
|
+
hapbeat/client.py,sha256=fMePjstmtvFyfhVhhZ8uXKPXxJRWEQlMOlGiZjNp3pw,8222
|
|
5
|
+
hapbeat/clip.py,sha256=PpVPht61HWiHolLkpCFBXdL7d_3syraaaJUwgF_2J0o,4892
|
|
6
|
+
hapbeat/eventmap.py,sha256=9PfjuEV6BpxYosMpQ2Nk3DZww2rsvP58v7ICUmvi6_k,8397
|
|
7
|
+
hapbeat/hapbeat.py,sha256=0NdNvyxXrD970NB3hQvmxHklicRr1suiZ5D9wPg4ey4,23247
|
|
8
|
+
hapbeat/launchpad.py,sha256=biom1d8ioJQ0tXT7olGyAfpj1I-w7rkPRy582rE7ong,26803
|
|
9
|
+
hapbeat/osc.py,sha256=cD0_QA6M1Km7VhXvPGYLHyWFj_V5uUQAfPG1myYvYxw,4589
|
|
10
|
+
hapbeat/protocol.py,sha256=H8GKFOxFCzfG44s2OhTBt20bpEMxRO0-cC9JxsFZN70,11006
|
|
11
|
+
hapbeat/update_check.py,sha256=0KTYpzXe-WfNR3A3mXXFIHVVzDzZKSbRf4rrnoNBIng,7395
|
|
12
|
+
hapbeat/wav.py,sha256=zAxCWLEliCclFhiOtWlgfqra3Eua89VfwsDOCFtk3lY,1716
|
|
13
|
+
hapbeat_python_sdk-0.2.0.dist-info/licenses/LICENSE,sha256=iaWGp90FSWArzrtS2s0C-hGqRlguPdFt8vxkPT4SEj8,1064
|
|
14
|
+
hapbeat_python_sdk-0.2.0.dist-info/METADATA,sha256=qdi5Yns9lfIiCe2FQSHtQaU7gSB0mdtCoxOJmy3Cl_4,8519
|
|
15
|
+
hapbeat_python_sdk-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
16
|
+
hapbeat_python_sdk-0.2.0.dist-info/entry_points.txt,sha256=6GpFtyk5e9Kh3Y_lDOGPp1jQqYL5eiCK9kbz08cdiek,45
|
|
17
|
+
hapbeat_python_sdk-0.2.0.dist-info/top_level.txt,sha256=FLePhbdKs48thuuow2Z1_X_3V-2oxhDdeOX_bu5to8M,8
|
|
18
|
+
hapbeat_python_sdk-0.2.0.dist-info/RECORD,,
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
hapbeat/__init__.py,sha256=6FABLnQeXm1nxqiiGLQWyDko1h5Nhr3RuF-vYEBW8n0,1268
|
|
2
|
-
hapbeat/__main__.py,sha256=ji2N-J5BX8XSUrihVzwQuttefiOYsu3pTzuYAsgMIUU,120
|
|
3
|
-
hapbeat/cli.py,sha256=FMCggde6NeWMNXbw5v939dQQ1X0kkY0HUno8TA5L3YE,4624
|
|
4
|
-
hapbeat/client.py,sha256=MGwTbtn6j4uAw0pDLWBDDuOi_u5nyWT3k1IiWu67TPo,5995
|
|
5
|
-
hapbeat/clip.py,sha256=PpVPht61HWiHolLkpCFBXdL7d_3syraaaJUwgF_2J0o,4892
|
|
6
|
-
hapbeat/eventmap.py,sha256=odDRXGt2CmcQsMed7NHlz0pbmQLRqxjti2T9uXxsyCg,8386
|
|
7
|
-
hapbeat/hapbeat.py,sha256=IqNIrKp2j17y4QLse80Wb98BSTGLJ7wGBtDKuUmdgVM,16858
|
|
8
|
-
hapbeat/launchpad.py,sha256=wvnIFLtrux_PzdR7TrsCqIhtOLhLJzXuOdfam7e2tQ4,26792
|
|
9
|
-
hapbeat/osc.py,sha256=cD0_QA6M1Km7VhXvPGYLHyWFj_V5uUQAfPG1myYvYxw,4589
|
|
10
|
-
hapbeat/protocol.py,sha256=WNjjgxO_RP25WF2qZDtC_p_69lUyJ9WGjAMn77i6ITY,8928
|
|
11
|
-
hapbeat/wav.py,sha256=zAxCWLEliCclFhiOtWlgfqra3Eua89VfwsDOCFtk3lY,1716
|
|
12
|
-
hapbeat_python_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=iaWGp90FSWArzrtS2s0C-hGqRlguPdFt8vxkPT4SEj8,1064
|
|
13
|
-
hapbeat_python_sdk-0.1.0.dist-info/METADATA,sha256=3lBi5pbXGtVQdl0P2ca6FwM8G2LbWcvolv1dmtCeFhY,7278
|
|
14
|
-
hapbeat_python_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
15
|
-
hapbeat_python_sdk-0.1.0.dist-info/entry_points.txt,sha256=6GpFtyk5e9Kh3Y_lDOGPp1jQqYL5eiCK9kbz08cdiek,45
|
|
16
|
-
hapbeat_python_sdk-0.1.0.dist-info/top_level.txt,sha256=FLePhbdKs48thuuow2Z1_X_3V-2oxhDdeOX_bu5to8M,8
|
|
17
|
-
hapbeat_python_sdk-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|