hapbeat-python-sdk 0.1.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/PKG-INFO +49 -20
  2. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/README.md +48 -19
  3. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/pyproject.toml +1 -1
  4. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/__init__.py +15 -5
  5. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/cli.py +22 -4
  6. hapbeat_python_sdk-0.3.0/src/hapbeat/client.py +411 -0
  7. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/eventmap.py +1 -1
  8. hapbeat_python_sdk-0.3.0/src/hapbeat/hapbeat.py +786 -0
  9. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/launchpad.py +3 -2
  10. hapbeat_python_sdk-0.3.0/src/hapbeat/netif.py +270 -0
  11. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/osc.py +4 -2
  12. hapbeat_python_sdk-0.3.0/src/hapbeat/protocol.py +440 -0
  13. hapbeat_python_sdk-0.3.0/src/hapbeat/stream.py +766 -0
  14. hapbeat_python_sdk-0.3.0/src/hapbeat/stream_lease.py +182 -0
  15. hapbeat_python_sdk-0.3.0/src/hapbeat/update_check.py +213 -0
  16. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/PKG-INFO +49 -20
  17. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/SOURCES.txt +10 -2
  18. hapbeat_python_sdk-0.3.0/tests/test_addressing.py +192 -0
  19. hapbeat_python_sdk-0.3.0/tests/test_broadcast_routes.py +285 -0
  20. hapbeat_python_sdk-0.3.0/tests/test_clip.py +390 -0
  21. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/tests/test_examples.py +2 -2
  22. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/tests/test_hapbeat.py +35 -1
  23. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/tests/test_launchpad.py +4 -4
  24. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/tests/test_overlay_osc.py +8 -5
  25. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/tests/test_protocol.py +20 -1
  26. hapbeat_python_sdk-0.3.0/tests/test_stream_mix.py +168 -0
  27. hapbeat_python_sdk-0.3.0/tests/test_stream_session_v2.py +673 -0
  28. hapbeat_python_sdk-0.3.0/tests/test_update_check.py +162 -0
  29. hapbeat_python_sdk-0.1.0/src/hapbeat/client.py +0 -152
  30. hapbeat_python_sdk-0.1.0/src/hapbeat/clip.py +0 -121
  31. hapbeat_python_sdk-0.1.0/src/hapbeat/hapbeat.py +0 -446
  32. hapbeat_python_sdk-0.1.0/src/hapbeat/protocol.py +0 -253
  33. hapbeat_python_sdk-0.1.0/tests/test_clip.py +0 -204
  34. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/LICENSE +0 -0
  35. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/setup.cfg +0 -0
  36. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/__main__.py +0 -0
  37. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat/wav.py +0 -0
  38. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/dependency_links.txt +0 -0
  39. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/entry_points.txt +0 -0
  40. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/requires.txt +0 -0
  41. {hapbeat_python_sdk-0.1.0 → hapbeat_python_sdk-0.3.0}/src/hapbeat_python_sdk.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hapbeat-python-sdk
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Python SDK for driving Hapbeat haptic devices over Wi-Fi UDP
5
5
  Author-email: Hapbeat <yus988@hapbeat.com>
6
6
  License: MIT
@@ -71,10 +71,11 @@ 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.play("sample-kit.sine_100hz", pan=-1) # pan: -1 left / 0 centre / +1 right
78
+ hb.stop("sample-kit.sine_100hz")
78
79
  hb.stop_all()
79
80
  hb.close()
80
81
  ```
@@ -83,10 +84,10 @@ or as a context manager:
83
84
 
84
85
  ```python
85
86
  with hapbeat.connect(app_name="MyExperiment") as hb:
86
- hb.play("impact.hit")
87
+ hb.play("sample-kit.sine_100hz")
87
88
  ```
88
89
 
89
- `"impact.hit"` must be an event id present in the **kit deployed to the
90
+ `"sample-kit.sine_100hz"` must be an event id present in the **kit deployed to the
90
91
  device** (via [Hapbeat Studio](https://devtools.hapbeat.com)). The SDK sends
91
92
  the *instruction*; the waveform lives in the kit on the device.
92
93
 
@@ -97,6 +98,21 @@ for dev in hb.discover(timeout=1.5):
97
98
  print(dev.ip, dev.address, dev.firmware_version)
98
99
  ```
99
100
 
101
+ ### How packets are sent
102
+
103
+ Commands are unicast to devices that answered a PING and use broadcast only
104
+ while no device is known. Clip streams are stricter: they start only on exact
105
+ PONG-reported endpoints and remain `Deferred` while none matches; stream packets
106
+ are never broadcast. Wi-Fi APs hold broadcast frames
107
+ until their next DTIM beacon (100–300 ms) whenever any client on the AP is
108
+ power-saving, which is heard as late haptics and stuttering streams; unicast is
109
+ not batched that way. The 5-second keep-alive PING keeps that table current, so
110
+ leave `keepalive=True`.
111
+
112
+ Pass `connect(unicast=False)` to always broadcast — worth it when many devices
113
+ must fire in lockstep, since one broadcast reaches them all at once while N
114
+ unicasts go out one after another.
115
+
100
116
  ## EventMap — the tuning side (optional)
101
117
 
102
118
  Keep per-event default gains in one place and let `play("id")` resolve them,
@@ -105,11 +121,11 @@ so firing code never hard-codes intensities:
105
121
  ```python
106
122
  em = hapbeat.EventMap.from_manifest("my-kit/my-kit-manifest.json")
107
123
  hb = hapbeat.connect(event_map=em)
108
- hb.play("impact.hit") # uses the kit manifest's intensity for this event
124
+ hb.play("sample-kit.sine_100hz") # uses the kit manifest's intensity for this event
109
125
  ```
110
126
 
111
127
  `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})`.
128
+ gain. You can also build one by hand: `EventMap.from_dict({"sample-kit.sine_100hz": 0.5})`.
113
129
 
114
130
  ### Haptic file — add targeting on top of the kit
115
131
 
@@ -123,15 +139,23 @@ resolves the target without the caller passing it:
123
139
  {
124
140
  "kit": "kits/my-kit",
125
141
  "events": {
126
- "impact.hit": { "target": "player_1/chest", "gain": 0.8 },
127
- "rain.loop": { "target": "*/back" }
142
+ "sample-kit.sine_100hz": { "target": "player_1/pos_neck", "gain": 0.8 },
143
+ "rain.loop": { "target": "*/pos_back" }
128
144
  }
129
145
  }
130
146
  ```
131
147
 
148
+ Targets are matched **positionally**: segment *i* of the target is compared
149
+ with segment *i* of the device address, `*` stands for one **whole** segment
150
+ (`pos_*` is compared literally — use `player_1/*`), and a short target
151
+ front-matches. Device addresses are always `player_<N>/<position>/group_<M>`,
152
+ defaulting to `player_1` / `group_1`, so a group has to be written
153
+ `*/*/group_2`: plain `group_2` is compared against the *player* slot and never
154
+ matches.
155
+
132
156
  ```python
133
157
  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
158
+ hb.play("sample-kit.sine_100hz") # goes to player_1/pos_neck at gain 0.8 — from the file
135
159
  ```
136
160
 
137
161
  ## Two playback modes: command and clip
@@ -158,26 +182,31 @@ my-app/
158
182
  ```python
159
183
  import hapbeat
160
184
  hb = hapbeat.connect(app_name="MyApp", kit="kits/my-kit")
161
- hb.play("impact.hit") # command -> device plays its installed clip
162
- hb.play("rain.loop") # clip -> SDK streams stream-clips/<wav> over UDP
163
- hb.stop("rain.loop") # ends the active stream
185
+ hb.play("sample-kit.sine_100hz") # command -> device plays its installed clip
186
+ rain = hb.play("rain.loop") # clip -> returns a StreamPlayback
187
+ rain.pan = -0.5
188
+ rain.stop() # stops only this logical source
164
189
  ```
165
190
 
166
191
  You can also stream an ad-hoc PCM16 buffer (e.g. a synthesized stereo cue where
167
192
  L/R amplitude conveys direction):
168
193
 
169
194
  ```python
170
- hb.stream_pcm(pcm_bytes, sample_rate=16000, channels=2)
195
+ left = hb.stream_pcm(pcm_bytes, sample_rate=16000, channels=2,
196
+ target="player_1/left", loop=True)
197
+ right = hb.stream_pcm(other_pcm, target="player_1/right", loop=True)
198
+ left.gain = 0.5
171
199
  ```
172
200
 
173
- Author clips as **16 kHz mono PCM16** (the device plays at 16 kHz; the SDK does
174
- not resample). A full runnable example is in
201
+ PCM16 input is normalized to **16 kHz stereo** inside the shared endpoint mixer.
202
+ Sources aimed at one endpoint are mixed into one wire session; sources aimed at
203
+ different endpoints run independently. A full runnable example is in
175
204
  [examples/clip_project/](examples/clip_project/).
176
205
 
177
206
  ## Generic OSC bridge
178
207
 
179
208
  Any OSC tool (TouchOSC, Max/MSP, TouchDesigner, a DAW) can drive Hapbeat
180
- without code. Run the bridge and send `/hapbeat/play <event_id> [target] [time] [gain]`:
209
+ without code. Run the bridge and send `/hapbeat/play <event_id> [target] [time] [gain] [pan]`:
181
210
 
182
211
  ```bash
183
212
  hapbeat osc-bridge --listen 7702
@@ -189,7 +218,7 @@ See [docs/osc.md](docs/osc.md) for the address spec.
189
218
 
190
219
  ```bash
191
220
  hapbeat scan # list devices on the LAN
192
- hapbeat play impact.hit --gain 0.3
221
+ hapbeat play sample-kit.sine_100hz --gain 0.3
193
222
  hapbeat stop-all
194
223
  hapbeat launchpad # browser UI for all of the above
195
224
  ```
@@ -38,10 +38,11 @@ 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 broadcast + keep-alive
42
- hb.play("impact.hit", gain=0.3) # fire event "impact.hit" at gain 0.3
43
- hb.play("impact.hit") # gain omitted -> kit baseline intensity
44
- hb.stop("impact.hit")
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.play("sample-kit.sine_100hz", pan=-1) # pan: -1 left / 0 centre / +1 right
45
+ hb.stop("sample-kit.sine_100hz")
45
46
  hb.stop_all()
46
47
  hb.close()
47
48
  ```
@@ -50,10 +51,10 @@ or as a context manager:
50
51
 
51
52
  ```python
52
53
  with hapbeat.connect(app_name="MyExperiment") as hb:
53
- hb.play("impact.hit")
54
+ hb.play("sample-kit.sine_100hz")
54
55
  ```
55
56
 
56
- `"impact.hit"` must be an event id present in the **kit deployed to the
57
+ `"sample-kit.sine_100hz"` must be an event id present in the **kit deployed to the
57
58
  device** (via [Hapbeat Studio](https://devtools.hapbeat.com)). The SDK sends
58
59
  the *instruction*; the waveform lives in the kit on the device.
59
60
 
@@ -64,6 +65,21 @@ for dev in hb.discover(timeout=1.5):
64
65
  print(dev.ip, dev.address, dev.firmware_version)
65
66
  ```
66
67
 
68
+ ### How packets are sent
69
+
70
+ Commands are unicast to devices that answered a PING and use broadcast only
71
+ while no device is known. Clip streams are stricter: they start only on exact
72
+ PONG-reported endpoints and remain `Deferred` while none matches; stream packets
73
+ are never broadcast. Wi-Fi APs hold broadcast frames
74
+ until their next DTIM beacon (100–300 ms) whenever any client on the AP is
75
+ power-saving, which is heard as late haptics and stuttering streams; unicast is
76
+ not batched that way. The 5-second keep-alive PING keeps that table current, so
77
+ leave `keepalive=True`.
78
+
79
+ Pass `connect(unicast=False)` to always broadcast — worth it when many devices
80
+ must fire in lockstep, since one broadcast reaches them all at once while N
81
+ unicasts go out one after another.
82
+
67
83
  ## EventMap — the tuning side (optional)
68
84
 
69
85
  Keep per-event default gains in one place and let `play("id")` resolve them,
@@ -72,11 +88,11 @@ so firing code never hard-codes intensities:
72
88
  ```python
73
89
  em = hapbeat.EventMap.from_manifest("my-kit/my-kit-manifest.json")
74
90
  hb = hapbeat.connect(event_map=em)
75
- hb.play("impact.hit") # uses the kit manifest's intensity for this event
91
+ hb.play("sample-kit.sine_100hz") # uses the kit manifest's intensity for this event
76
92
  ```
77
93
 
78
94
  `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({"impact.hit": 0.5})`.
95
+ gain. You can also build one by hand: `EventMap.from_dict({"sample-kit.sine_100hz": 0.5})`.
80
96
 
81
97
  ### Haptic file — add targeting on top of the kit
82
98
 
@@ -90,15 +106,23 @@ resolves the target without the caller passing it:
90
106
  {
91
107
  "kit": "kits/my-kit",
92
108
  "events": {
93
- "impact.hit": { "target": "player_1/chest", "gain": 0.8 },
94
- "rain.loop": { "target": "*/back" }
109
+ "sample-kit.sine_100hz": { "target": "player_1/pos_neck", "gain": 0.8 },
110
+ "rain.loop": { "target": "*/pos_back" }
95
111
  }
96
112
  }
97
113
  ```
98
114
 
115
+ Targets are matched **positionally**: segment *i* of the target is compared
116
+ with segment *i* of the device address, `*` stands for one **whole** segment
117
+ (`pos_*` is compared literally — use `player_1/*`), and a short target
118
+ front-matches. Device addresses are always `player_<N>/<position>/group_<M>`,
119
+ defaulting to `player_1` / `group_1`, so a group has to be written
120
+ `*/*/group_2`: plain `group_2` is compared against the *player* slot and never
121
+ matches.
122
+
99
123
  ```python
100
124
  hb = hapbeat.connect(app_name="MyApp", haptics="haptics.json")
101
- hb.play("impact.hit") # goes to player_1/chest at gain 0.8 — from the file
125
+ hb.play("sample-kit.sine_100hz") # goes to player_1/pos_neck at gain 0.8 — from the file
102
126
  ```
103
127
 
104
128
  ## Two playback modes: command and clip
@@ -125,26 +149,31 @@ my-app/
125
149
  ```python
126
150
  import hapbeat
127
151
  hb = hapbeat.connect(app_name="MyApp", kit="kits/my-kit")
128
- hb.play("impact.hit") # command -> device plays its installed clip
129
- hb.play("rain.loop") # clip -> SDK streams stream-clips/<wav> over UDP
130
- hb.stop("rain.loop") # ends the active stream
152
+ hb.play("sample-kit.sine_100hz") # command -> device plays its installed clip
153
+ rain = hb.play("rain.loop") # clip -> returns a StreamPlayback
154
+ rain.pan = -0.5
155
+ rain.stop() # stops only this logical source
131
156
  ```
132
157
 
133
158
  You can also stream an ad-hoc PCM16 buffer (e.g. a synthesized stereo cue where
134
159
  L/R amplitude conveys direction):
135
160
 
136
161
  ```python
137
- hb.stream_pcm(pcm_bytes, sample_rate=16000, channels=2)
162
+ left = hb.stream_pcm(pcm_bytes, sample_rate=16000, channels=2,
163
+ target="player_1/left", loop=True)
164
+ right = hb.stream_pcm(other_pcm, target="player_1/right", loop=True)
165
+ left.gain = 0.5
138
166
  ```
139
167
 
140
- Author clips as **16 kHz mono PCM16** (the device plays at 16 kHz; the SDK does
141
- not resample). A full runnable example is in
168
+ PCM16 input is normalized to **16 kHz stereo** inside the shared endpoint mixer.
169
+ Sources aimed at one endpoint are mixed into one wire session; sources aimed at
170
+ different endpoints run independently. A full runnable example is in
142
171
  [examples/clip_project/](examples/clip_project/).
143
172
 
144
173
  ## Generic OSC bridge
145
174
 
146
175
  Any OSC tool (TouchOSC, Max/MSP, TouchDesigner, a DAW) can drive Hapbeat
147
- without code. Run the bridge and send `/hapbeat/play <event_id> [target] [time] [gain]`:
176
+ without code. Run the bridge and send `/hapbeat/play <event_id> [target] [time] [gain] [pan]`:
148
177
 
149
178
  ```bash
150
179
  hapbeat osc-bridge --listen 7702
@@ -156,7 +185,7 @@ See [docs/osc.md](docs/osc.md) for the address spec.
156
185
 
157
186
  ```bash
158
187
  hapbeat scan # list devices on the LAN
159
- hapbeat play impact.hit --gain 0.3
188
+ hapbeat play sample-kit.sine_100hz --gain 0.3
160
189
  hapbeat stop-all
161
190
  hapbeat launchpad # browser UI for all of the above
162
191
  ```
@@ -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.1.0"
8
+ version = "0.3.0"
9
9
  description = "Python SDK for driving Hapbeat haptic devices over Wi-Fi UDP"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -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
 
@@ -17,9 +17,15 @@ from __future__ import annotations
17
17
 
18
18
  from . import protocol
19
19
  from .client import DEFAULT_BROADCAST, DEFAULT_PORT, UdpClient
20
- from .clip import ClipStreamer
21
20
  from .eventmap import EventDef, EventMap
22
21
  from .hapbeat import Device, Hapbeat, connect
22
+ from .stream import (
23
+ DeferredReason,
24
+ PlaybackStatus,
25
+ StreamEndpoint,
26
+ StreamHub,
27
+ StreamPlayback,
28
+ )
23
29
  from .wav import WavPcm, read_wav_pcm16
24
30
 
25
31
  try: # populated from installed package metadata
@@ -28,9 +34,9 @@ try: # populated from installed package metadata
28
34
  try:
29
35
  __version__ = version("hapbeat-python-sdk")
30
36
  except PackageNotFoundError: # running from a source checkout
31
- __version__ = "0.1.0+local"
37
+ __version__ = "0.3.0+local"
32
38
  except ImportError: # pragma: no cover
33
- __version__ = "0.1.0+local"
39
+ __version__ = "0.3.0+local"
34
40
 
35
41
  __all__ = [
36
42
  "connect",
@@ -38,7 +44,11 @@ __all__ = [
38
44
  "Device",
39
45
  "EventMap",
40
46
  "EventDef",
41
- "ClipStreamer",
47
+ "StreamHub",
48
+ "StreamPlayback",
49
+ "StreamEndpoint",
50
+ "PlaybackStatus",
51
+ "DeferredReason",
42
52
  "WavPcm",
43
53
  "read_wav_pcm16",
44
54
  "UdpClient",
@@ -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
@@ -33,6 +33,8 @@ def build_parser() -> argparse.ArgumentParser:
33
33
  p_play = sub.add_parser("play", help="play an event by id")
34
34
  p_play.add_argument("event_id")
35
35
  p_play.add_argument("--gain", type=float, default=None, help="0..1 (default: kit baseline)")
36
+ p_play.add_argument("--pan", type=float, default=0.0,
37
+ help="-1 left .. 0 centre .. +1 right (default 0)")
36
38
  _add_common(p_play)
37
39
 
38
40
  p_stop = sub.add_parser("stop", help="stop one event id")
@@ -72,6 +74,12 @@ def build_parser() -> argparse.ArgumentParser:
72
74
  def main(argv: list[str] | None = None) -> int:
73
75
  args = build_parser().parse_args(argv)
74
76
 
77
+ # 新しい版が出ていれば stderr に 1 行だけ (同じ版では 2 回目を出さない)。
78
+ # キャッシュが無ければ裏で取りに行くだけでコマンドは待たせない。
79
+ # ライブラリ import 側では絶対に呼ばない — CLI 実行時のみ (DEC-053 §5.4)。
80
+ from . import update_check
81
+ update_check.notify_cli(__version__)
82
+
75
83
  if args.command == "osc-bridge":
76
84
  from .osc import OscBridge
77
85
 
@@ -100,9 +108,19 @@ def main(argv: list[str] | None = None) -> int:
100
108
 
101
109
  hb = connect(port=args.port, default_target=getattr(args, "target", ""), keepalive=False)
102
110
  try:
111
+ if args.command in ("play", "stop", "stop-all"):
112
+ # Discover before sending. Without a PONG the SDK knows no devices,
113
+ # so the command would go out as a broadcast — which the AP holds
114
+ # back until the next DTIM beacon, and which on a multi-homed host
115
+ # may leave through an interface with no Hapbeat behind it. One
116
+ # reply is enough to pick the subnet and unicast, and it normally
117
+ # arrives in a few milliseconds; the wait only costs the full
118
+ # window when nothing is there to answer.
119
+ hb.discover(timeout=0.4, stop_early=True)
120
+
103
121
  if args.command == "play":
104
- ok = hb.play(args.event_id, args.gain)
105
- print(f"play {args.event_id} gain={args.gain if args.gain is not None else 'baseline'} -> {'sent' if ok else 'FAILED'}")
122
+ ok = hb.play(args.event_id, args.gain, pan=args.pan)
123
+ print(f"play {args.event_id} gain={args.gain if args.gain is not None else 'baseline'} pan={args.pan} -> {'sent' if ok else 'FAILED'}")
106
124
  elif args.command == "stop":
107
125
  print("sent" if hb.stop(args.event_id) else "FAILED")
108
126
  elif args.command == "stop-all":