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