hapbeat-python-sdk 0.2.0__py3-none-any.whl → 0.3.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 +14 -4
- hapbeat/cli.py +14 -2
- hapbeat/client.py +225 -4
- hapbeat/hapbeat.py +293 -78
- hapbeat/launchpad.py +2 -1
- hapbeat/netif.py +270 -0
- hapbeat/osc.py +4 -2
- hapbeat/protocol.py +165 -23
- hapbeat/stream.py +766 -0
- hapbeat/stream_lease.py +182 -0
- {hapbeat_python_sdk-0.2.0.dist-info → hapbeat_python_sdk-0.3.0.dist-info}/METADATA +17 -9
- hapbeat_python_sdk-0.3.0.dist-info/RECORD +20 -0
- {hapbeat_python_sdk-0.2.0.dist-info → hapbeat_python_sdk-0.3.0.dist-info}/WHEEL +1 -1
- hapbeat/clip.py +0 -121
- hapbeat_python_sdk-0.2.0.dist-info/RECORD +0 -18
- {hapbeat_python_sdk-0.2.0.dist-info → hapbeat_python_sdk-0.3.0.dist-info}/entry_points.txt +0 -0
- {hapbeat_python_sdk-0.2.0.dist-info → hapbeat_python_sdk-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {hapbeat_python_sdk-0.2.0.dist-info → hapbeat_python_sdk-0.3.0.dist-info}/top_level.txt +0 -0
hapbeat/hapbeat.py
CHANGED
|
@@ -24,6 +24,7 @@ or as a context manager::
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
26
|
import logging
|
|
27
|
+
import secrets
|
|
27
28
|
import threading
|
|
28
29
|
import time
|
|
29
30
|
from dataclasses import dataclass, field
|
|
@@ -32,8 +33,24 @@ from typing import Optional, Union
|
|
|
32
33
|
|
|
33
34
|
from . import protocol
|
|
34
35
|
from .client import DEFAULT_BROADCAST, DEFAULT_PORT, UdpClient
|
|
35
|
-
from .clip import ClipStreamer
|
|
36
36
|
from .eventmap import EventDef, EventMap
|
|
37
|
+
from .stream import (
|
|
38
|
+
LEGACY_STREAM_MODE,
|
|
39
|
+
EndpointStreamMode,
|
|
40
|
+
StreamEndpoint,
|
|
41
|
+
StreamHub,
|
|
42
|
+
StreamPlayback,
|
|
43
|
+
)
|
|
44
|
+
from .stream_lease import (
|
|
45
|
+
PENDING_PING_MAX_AGE,
|
|
46
|
+
V2_LEASE_REFRESH_INTERVAL,
|
|
47
|
+
EndpointClass,
|
|
48
|
+
EndpointLeaseState,
|
|
49
|
+
PendingPing,
|
|
50
|
+
PongLeaseDecision,
|
|
51
|
+
StreamSessionIdentity,
|
|
52
|
+
evaluate_pong_lease,
|
|
53
|
+
)
|
|
37
54
|
from .wav import WavPcm, read_wav_pcm16
|
|
38
55
|
|
|
39
56
|
logger = logging.getLogger("hapbeat")
|
|
@@ -47,6 +64,7 @@ class Device:
|
|
|
47
64
|
name: str = ""
|
|
48
65
|
address: str = ""
|
|
49
66
|
firmware_version: str = ""
|
|
67
|
+
port: int = DEFAULT_PORT
|
|
50
68
|
last_seen: float = field(default_factory=time.monotonic)
|
|
51
69
|
|
|
52
70
|
|
|
@@ -54,6 +72,14 @@ def _now_us() -> int:
|
|
|
54
72
|
return int(time.time() * 1_000_000)
|
|
55
73
|
|
|
56
74
|
|
|
75
|
+
def _new_client_incarnation() -> int:
|
|
76
|
+
"""A fresh nonzero uint64 (stream-session-v2.md client_incarnation)."""
|
|
77
|
+
while True:
|
|
78
|
+
value = secrets.randbits(64)
|
|
79
|
+
if value:
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
57
83
|
class Hapbeat:
|
|
58
84
|
"""A connection to Hapbeat devices over Wi-Fi UDP.
|
|
59
85
|
|
|
@@ -93,6 +119,13 @@ class Hapbeat:
|
|
|
93
119
|
self._seq_lock = threading.Lock()
|
|
94
120
|
self._devices: dict[str, Device] = {}
|
|
95
121
|
self._devices_lock = threading.Lock()
|
|
122
|
+
# Stream session v2 (contracts stream-session-v2.md), all guarded by
|
|
123
|
+
# _devices_lock. A fresh incarnation per open(); each device IP's
|
|
124
|
+
# class / lease comes only from matched replies to our extended PING.
|
|
125
|
+
self._client_incarnation = 0
|
|
126
|
+
self._pending_pings: dict[int, PendingPing] = {}
|
|
127
|
+
self._last_ping_timestamp_us = 0
|
|
128
|
+
self._stream_states: dict[str, EndpointLeaseState] = {}
|
|
96
129
|
self._keepalive = keepalive
|
|
97
130
|
self._keepalive_interval = keepalive_interval
|
|
98
131
|
self._keepalive_stop: Optional[threading.Event] = None
|
|
@@ -110,14 +143,15 @@ class Hapbeat:
|
|
|
110
143
|
device_ttl if device_ttl is not None
|
|
111
144
|
else max(5.0, keepalive_interval * 3.0)
|
|
112
145
|
)
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
146
|
+
# One deep module owns all logical sources and endpoint wire sessions.
|
|
147
|
+
# STREAM_DATA/END have no target, so every session is exact-unicast to
|
|
148
|
+
# one PONG-reported endpoint; unresolved sources remain Deferred.
|
|
149
|
+
self._stream_hub = StreamHub(
|
|
150
|
+
self,
|
|
151
|
+
self._stream_endpoints,
|
|
152
|
+
send_ahead_sec=stream_send_ahead,
|
|
153
|
+
mode_provider=self._stream_mode,
|
|
154
|
+
)
|
|
121
155
|
self._clip_cache: dict[str, WavPcm] = {}
|
|
122
156
|
|
|
123
157
|
# ── Lifecycle ───────────────────────────────────────────────────
|
|
@@ -126,6 +160,13 @@ class Hapbeat:
|
|
|
126
160
|
may both call it, so a second call while open is a no-op."""
|
|
127
161
|
if self._opened:
|
|
128
162
|
return self
|
|
163
|
+
# A (re)connect is an explicit stream-ownership (re)acquisition: a new
|
|
164
|
+
# incarnation, no outstanding requests of the old one. v2 states of the
|
|
165
|
+
# old incarnation stop being usable; legacy devices keep their class.
|
|
166
|
+
with self._devices_lock:
|
|
167
|
+
self._client_incarnation = _new_client_incarnation()
|
|
168
|
+
self._pending_pings.clear()
|
|
169
|
+
self._stream_hub.open()
|
|
129
170
|
self._opened = True
|
|
130
171
|
self._client.add_pong_listener(self._on_pong)
|
|
131
172
|
self._client.open()
|
|
@@ -137,7 +178,7 @@ class Hapbeat:
|
|
|
137
178
|
if not self._opened:
|
|
138
179
|
return
|
|
139
180
|
self._opened = False
|
|
140
|
-
self.
|
|
181
|
+
self._stream_hub.close()
|
|
141
182
|
# Tell the device this app is leaving so the OLED clears.
|
|
142
183
|
if self.app_name:
|
|
143
184
|
try:
|
|
@@ -224,7 +265,8 @@ class Hapbeat:
|
|
|
224
265
|
*,
|
|
225
266
|
target: Optional[str] = None,
|
|
226
267
|
target_time_us: int = 0,
|
|
227
|
-
|
|
268
|
+
pan: float = 0.0,
|
|
269
|
+
) -> Union[StreamPlayback, bool]:
|
|
228
270
|
"""Play a haptic event by id. The EventMap decides the mode.
|
|
229
271
|
|
|
230
272
|
- **command** event (manifest ``events``) → a PLAY instruction; the
|
|
@@ -235,7 +277,9 @@ class Hapbeat:
|
|
|
235
277
|
Either way the caller writes the same one-liner. ``gain`` is the wire
|
|
236
278
|
gain (0..1); when omitted, the bound :class:`EventMap` supplies the
|
|
237
279
|
per-event default (its manifest ``intensity``), else ``1.0``.
|
|
238
|
-
``
|
|
280
|
+
``pan`` is the left/right balance (-1 left, 0 centre, +1 right).
|
|
281
|
+
``target_time_us`` applies to command mode only. ``pan`` applies to
|
|
282
|
+
both modes; clip-mode balance is mixed into the endpoint PCM.
|
|
239
283
|
"""
|
|
240
284
|
ev = self.event_map.get(event_id) if self.event_map else None
|
|
241
285
|
if gain is None:
|
|
@@ -244,7 +288,7 @@ class Hapbeat:
|
|
|
244
288
|
target_eff = self._effective_target(ev, target)
|
|
245
289
|
|
|
246
290
|
if ev is not None and ev.streaming:
|
|
247
|
-
return self.play_clip(event_id, gain, target=target_eff)
|
|
291
|
+
return self.play_clip(event_id, gain, target=target_eff, pan=pan)
|
|
248
292
|
|
|
249
293
|
pkt = protocol.build_play(
|
|
250
294
|
self._next_seq(),
|
|
@@ -252,15 +296,16 @@ class Hapbeat:
|
|
|
252
296
|
target=target_eff,
|
|
253
297
|
target_time_us=target_time_us,
|
|
254
298
|
gain=gain,
|
|
299
|
+
pan=pan,
|
|
255
300
|
)
|
|
256
301
|
return self._send_routed(pkt, target_eff)
|
|
257
302
|
|
|
258
303
|
def stop(self, event_id: str, *, target: Optional[str] = None) -> bool:
|
|
259
|
-
"""Stop one event id. Clip events
|
|
260
|
-
events send a STOP."""
|
|
304
|
+
"""Stop one event id. Clip events stop matching logical sources;
|
|
305
|
+
command events send a STOP."""
|
|
261
306
|
ev = self.event_map.get(event_id) if self.event_map else None
|
|
262
307
|
if ev is not None and ev.streaming:
|
|
263
|
-
self.
|
|
308
|
+
self._stream_hub.stop_key(event_id, target=target)
|
|
264
309
|
return True
|
|
265
310
|
target_eff = self._effective_target(ev, target)
|
|
266
311
|
pkt = protocol.build_stop(self._next_seq(), event_id, target=target_eff)
|
|
@@ -268,14 +313,63 @@ class Hapbeat:
|
|
|
268
313
|
|
|
269
314
|
def stop_all(self, *, target: Optional[str] = None) -> bool:
|
|
270
315
|
"""Stop every event on matching devices (and any active clip stream)."""
|
|
271
|
-
|
|
316
|
+
if target is None:
|
|
317
|
+
self._stream_hub.stop_all()
|
|
318
|
+
else:
|
|
319
|
+
self._stream_hub.stop_target(target)
|
|
272
320
|
target_eff = self._resolve_target(target)
|
|
273
321
|
pkt = protocol.build_stop_all(self._next_seq(), target=target_eff)
|
|
274
322
|
return self._send_routed(pkt, target_eff)
|
|
275
323
|
|
|
276
324
|
def ping(self) -> bool:
|
|
277
|
-
"""Broadcast a PING (keep-alive / discovery probe).
|
|
278
|
-
|
|
325
|
+
"""Broadcast a PING (keep-alive / discovery probe).
|
|
326
|
+
|
|
327
|
+
Goes out on every local subnet until a device answers — this is what
|
|
328
|
+
finds a Hapbeat that the limited broadcast cannot reach on a multi-homed
|
|
329
|
+
host. Safe to duplicate: a device answers each PING independently.
|
|
330
|
+
|
|
331
|
+
It is the extended (16-byte) PING of stream-session-v2.md: its matched
|
|
332
|
+
replies classify each device (v2 lease or legacy firmware) for clip
|
|
333
|
+
streaming.
|
|
334
|
+
"""
|
|
335
|
+
seq = self._next_seq()
|
|
336
|
+
with self._devices_lock:
|
|
337
|
+
if self._client_incarnation == 0:
|
|
338
|
+
self._client_incarnation = _new_client_incarnation()
|
|
339
|
+
# strictly increasing: class / lease adoption is ordered by the
|
|
340
|
+
# echoed timestamp, and two immediate PINGs may read the same clock
|
|
341
|
+
timestamp_us = max(_now_us(), self._last_ping_timestamp_us + 1)
|
|
342
|
+
self._last_ping_timestamp_us = timestamp_us
|
|
343
|
+
now = time.monotonic()
|
|
344
|
+
self._pending_pings = {
|
|
345
|
+
pending_seq: pending
|
|
346
|
+
for pending_seq, pending in self._pending_pings.items()
|
|
347
|
+
if now - pending.sent_at <= PENDING_PING_MAX_AGE
|
|
348
|
+
}
|
|
349
|
+
self._pending_pings[seq] = PendingPing(
|
|
350
|
+
timestamp_us, self._client_incarnation, now
|
|
351
|
+
)
|
|
352
|
+
incarnation = self._client_incarnation
|
|
353
|
+
return self._client.send_discovery(
|
|
354
|
+
protocol.build_ping(seq, timestamp_us, incarnation))
|
|
355
|
+
|
|
356
|
+
def reacquire_stream_ownership(self) -> bool:
|
|
357
|
+
"""Retake stream ownership after a device reported this client's lease
|
|
358
|
+
as superseded by another writer (stream-session-v2.md).
|
|
359
|
+
|
|
360
|
+
Starts a new client incarnation and re-PINGs: sessions under the old
|
|
361
|
+
incarnation's leases end, and every v2 device issues a fresh lease on
|
|
362
|
+
its matched reply, after which deferred sources resume. Call it once
|
|
363
|
+
the other writer has stopped. Returns the PING send result, or False
|
|
364
|
+
when the connection is not open.
|
|
365
|
+
"""
|
|
366
|
+
if not self._opened:
|
|
367
|
+
return False
|
|
368
|
+
with self._devices_lock:
|
|
369
|
+
self._client_incarnation = _new_client_incarnation()
|
|
370
|
+
self._pending_pings.clear()
|
|
371
|
+
self._stream_hub.reconcile()
|
|
372
|
+
return self.ping()
|
|
279
373
|
|
|
280
374
|
def connect_status(self, *, connected: bool = True) -> bool:
|
|
281
375
|
"""Announce connection state so the device OLED shows the app name."""
|
|
@@ -286,7 +380,9 @@ class Hapbeat:
|
|
|
286
380
|
app_name=self.app_name,
|
|
287
381
|
device_name=self.device_name,
|
|
288
382
|
)
|
|
289
|
-
|
|
383
|
+
# Idempotent display state, so it can share the discovery fan-out and
|
|
384
|
+
# clear "app not connected" without waiting for a PONG first.
|
|
385
|
+
return self._client.send_discovery(pkt)
|
|
290
386
|
|
|
291
387
|
# ── Clip streaming (level-2) ─────────────────────────────────────
|
|
292
388
|
def play_clip(
|
|
@@ -295,7 +391,9 @@ class Hapbeat:
|
|
|
295
391
|
gain: Optional[float] = None,
|
|
296
392
|
*,
|
|
297
393
|
target: Optional[str] = None,
|
|
298
|
-
|
|
394
|
+
pan: float = 0.0,
|
|
395
|
+
loop: Optional[bool] = None,
|
|
396
|
+
) -> Union[StreamPlayback, bool]:
|
|
299
397
|
"""Stream a clip-mode event's WAV to the device.
|
|
300
398
|
|
|
301
399
|
Usually you call :meth:`play`, which routes clip events here. The WAV
|
|
@@ -310,14 +408,16 @@ class Hapbeat:
|
|
|
310
408
|
wav = self._load_clip(event_id, ev)
|
|
311
409
|
if wav is None:
|
|
312
410
|
return False
|
|
313
|
-
self.
|
|
411
|
+
return self._stream_hub.play_pcm(
|
|
314
412
|
wav.data,
|
|
315
413
|
sample_rate=wav.sample_rate,
|
|
316
414
|
channels=wav.channels,
|
|
317
415
|
gain=max(0.0, min(1.0, float(gain))),
|
|
416
|
+
pan=max(-1.0, min(1.0, float(pan))),
|
|
417
|
+
loop=ev.loop if loop is None else loop,
|
|
318
418
|
target=self._effective_target(ev, target),
|
|
419
|
+
key=event_id,
|
|
319
420
|
)
|
|
320
|
-
return True
|
|
321
421
|
|
|
322
422
|
def play_clip_file(
|
|
323
423
|
self,
|
|
@@ -325,21 +425,24 @@ class Hapbeat:
|
|
|
325
425
|
gain: float = 1.0,
|
|
326
426
|
*,
|
|
327
427
|
target: Optional[str] = None,
|
|
328
|
-
|
|
428
|
+
pan: float = 0.0,
|
|
429
|
+
loop: bool = False,
|
|
430
|
+
) -> Union[StreamPlayback, bool]:
|
|
329
431
|
"""Stream an arbitrary PCM16 WAV file (not from a manifest)."""
|
|
330
432
|
try:
|
|
331
433
|
wav = read_wav_pcm16(path)
|
|
332
434
|
except (OSError, ValueError) as exc:
|
|
333
435
|
logger.warning("cannot read clip %s: %s", path, exc)
|
|
334
436
|
return False
|
|
335
|
-
self.
|
|
437
|
+
return self._stream_hub.play_pcm(
|
|
336
438
|
wav.data,
|
|
337
439
|
sample_rate=wav.sample_rate,
|
|
338
440
|
channels=wav.channels,
|
|
339
441
|
gain=max(0.0, min(1.0, float(gain))),
|
|
442
|
+
pan=max(-1.0, min(1.0, float(pan))),
|
|
443
|
+
loop=loop,
|
|
340
444
|
target=self._resolve_target(target),
|
|
341
445
|
)
|
|
342
|
-
return True
|
|
343
446
|
|
|
344
447
|
def stream_pcm(
|
|
345
448
|
self,
|
|
@@ -348,15 +451,22 @@ class Hapbeat:
|
|
|
348
451
|
sample_rate: int = 16000,
|
|
349
452
|
channels: int = 1,
|
|
350
453
|
gain: float = 1.0,
|
|
454
|
+
pan: float = 0.0,
|
|
455
|
+
loop: bool = False,
|
|
351
456
|
target: Optional[str] = None,
|
|
352
|
-
) ->
|
|
353
|
-
"""
|
|
354
|
-
|
|
355
|
-
|
|
457
|
+
) -> StreamPlayback:
|
|
458
|
+
"""Register an ad-hoc PCM16 source in the shared endpoint mixer.
|
|
459
|
+
|
|
460
|
+
The returned handle adjusts or stops only this source. Multiple calls
|
|
461
|
+
may run concurrently, including calls aimed at different targets.
|
|
462
|
+
"""
|
|
463
|
+
return self._stream_hub.play_pcm(
|
|
356
464
|
pcm,
|
|
357
465
|
sample_rate=sample_rate,
|
|
358
466
|
channels=max(1, channels),
|
|
359
467
|
gain=max(0.0, min(1.0, float(gain))),
|
|
468
|
+
pan=max(-1.0, min(1.0, float(pan))),
|
|
469
|
+
loop=loop,
|
|
360
470
|
target=self._resolve_target(target),
|
|
361
471
|
)
|
|
362
472
|
|
|
@@ -399,63 +509,121 @@ class Hapbeat:
|
|
|
399
509
|
self._clip_cache[event_id] = wav
|
|
400
510
|
return wav
|
|
401
511
|
|
|
402
|
-
# ──
|
|
403
|
-
def
|
|
404
|
-
|
|
512
|
+
# ── EndpointStreamSink (called only by StreamHub) ───────────────
|
|
513
|
+
def _stream_endpoints(self) -> list[StreamEndpoint]:
|
|
514
|
+
return [
|
|
515
|
+
StreamEndpoint(ip=device.ip, address=device.address, port=device.port)
|
|
516
|
+
for device in self._alive_devices()
|
|
517
|
+
if device.address
|
|
518
|
+
]
|
|
405
519
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
520
|
+
def _stream_mode(self, endpoint: StreamEndpoint) -> Optional[EndpointStreamMode]:
|
|
521
|
+
"""Legacy, v2 under its usable lease, or None (unknown class, invalid
|
|
522
|
+
or superseded lease, lease of an old incarnation): sources defer."""
|
|
523
|
+
with self._devices_lock:
|
|
524
|
+
state = self._stream_states.get(endpoint.ip)
|
|
525
|
+
incarnation = self._client_incarnation
|
|
526
|
+
if state is None:
|
|
527
|
+
return None
|
|
528
|
+
if state.endpoint_class is EndpointClass.LEGACY:
|
|
529
|
+
return LEGACY_STREAM_MODE
|
|
530
|
+
lease = state.usable_v2_lease(incarnation)
|
|
531
|
+
return EndpointStreamMode(lease) if lease is not None else None
|
|
532
|
+
|
|
533
|
+
# v1 stream: pre-v2 firmware only (exact unicast, END without payload)
|
|
534
|
+
def stream_begin_endpoint(self, endpoint: StreamEndpoint) -> None:
|
|
535
|
+
packet = protocol.build_stream_begin(
|
|
536
|
+
self._next_seq(),
|
|
537
|
+
sample_rate=16000,
|
|
538
|
+
channels=2,
|
|
539
|
+
fmt=protocol.AUDIO_FORMAT_PCM16,
|
|
540
|
+
total_samples=0,
|
|
541
|
+
gain=1.0,
|
|
542
|
+
target=endpoint.address,
|
|
543
|
+
)
|
|
544
|
+
self._client.send(packet, endpoint.ip)
|
|
409
545
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
]
|
|
546
|
+
def stream_data_endpoint(
|
|
547
|
+
self, endpoint: StreamEndpoint, offset: int, data: bytes
|
|
548
|
+
) -> None:
|
|
549
|
+
packet = protocol.build_stream_data(self._next_seq(), offset, data)
|
|
550
|
+
self._client.send(packet, endpoint.ip)
|
|
427
551
|
|
|
428
|
-
def
|
|
429
|
-
|
|
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)
|
|
552
|
+
def stream_end_endpoint(self, endpoint: StreamEndpoint) -> None:
|
|
553
|
+
self._client.send(protocol.build_stream_end(self._next_seq()), endpoint.ip)
|
|
435
554
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
self
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
555
|
+
# stream session v2: envelope on every packet
|
|
556
|
+
def stream_begin_v2(
|
|
557
|
+
self, endpoint: StreamEndpoint, identity: StreamSessionIdentity
|
|
558
|
+
) -> None:
|
|
559
|
+
packet = protocol.build_stream_begin_v2(
|
|
560
|
+
self._next_seq(),
|
|
561
|
+
identity.lease.boot_id,
|
|
562
|
+
identity.lease.ticket,
|
|
563
|
+
identity.generation,
|
|
564
|
+
sample_rate=16000,
|
|
565
|
+
channels=2,
|
|
566
|
+
fmt=protocol.AUDIO_FORMAT_PCM16,
|
|
567
|
+
total_samples=0,
|
|
568
|
+
gain=1.0,
|
|
569
|
+
target=endpoint.address,
|
|
570
|
+
)
|
|
571
|
+
self._client.send(packet, endpoint.ip)
|
|
444
572
|
|
|
445
|
-
def
|
|
446
|
-
self
|
|
573
|
+
def stream_data_v2(
|
|
574
|
+
self, endpoint: StreamEndpoint, identity: StreamSessionIdentity,
|
|
575
|
+
offset: int, data: bytes,
|
|
576
|
+
) -> None:
|
|
577
|
+
packet = protocol.build_stream_data_v2(
|
|
578
|
+
self._next_seq(),
|
|
579
|
+
identity.lease.boot_id,
|
|
580
|
+
identity.lease.ticket,
|
|
581
|
+
identity.generation,
|
|
582
|
+
offset,
|
|
583
|
+
data,
|
|
584
|
+
)
|
|
585
|
+
self._client.send(packet, endpoint.ip)
|
|
447
586
|
|
|
448
|
-
def
|
|
449
|
-
self
|
|
587
|
+
def stream_end_v2(
|
|
588
|
+
self, endpoint: StreamEndpoint, identity: StreamSessionIdentity
|
|
589
|
+
) -> None:
|
|
590
|
+
packet = protocol.build_stream_end_v2(
|
|
591
|
+
self._next_seq(),
|
|
592
|
+
identity.lease.boot_id,
|
|
593
|
+
identity.lease.ticket,
|
|
594
|
+
identity.generation,
|
|
595
|
+
)
|
|
596
|
+
self._client.send(packet, endpoint.ip)
|
|
450
597
|
|
|
451
598
|
# ── Discovery ───────────────────────────────────────────────────
|
|
452
|
-
def discover(self, timeout: float = 1.0) -> list[Device]:
|
|
453
|
-
"""Broadcast a PING and collect devices that reply within ``timeout``.
|
|
599
|
+
def discover(self, timeout: float = 1.0, *, stop_early: bool = False) -> list[Device]:
|
|
600
|
+
"""Broadcast a PING and collect devices that reply within ``timeout``.
|
|
601
|
+
|
|
602
|
+
``stop_early`` returns as soon as the first device answers instead of
|
|
603
|
+
waiting the whole window. Use it when the point is to *route* the next
|
|
604
|
+
packet rather than to inventory the LAN — a one-shot command needs one
|
|
605
|
+
reply to know which subnet to send on, and on a quiet LAN that arrives
|
|
606
|
+
in a few milliseconds.
|
|
607
|
+
"""
|
|
454
608
|
before = time.monotonic()
|
|
455
609
|
self.ping()
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
610
|
+
|
|
611
|
+
def replied() -> list[Device]:
|
|
612
|
+
with self._devices_lock:
|
|
613
|
+
return [d for d in self._devices.values()
|
|
614
|
+
if d.last_seen >= before - 0.05]
|
|
615
|
+
|
|
616
|
+
deadline = before + max(0.0, timeout)
|
|
617
|
+
if not stop_early:
|
|
618
|
+
time.sleep(max(0.0, timeout))
|
|
619
|
+
return replied()
|
|
620
|
+
|
|
621
|
+
while time.monotonic() < deadline:
|
|
622
|
+
found = replied()
|
|
623
|
+
if found:
|
|
624
|
+
return found
|
|
625
|
+
time.sleep(0.01)
|
|
626
|
+
return replied()
|
|
459
627
|
|
|
460
628
|
@property
|
|
461
629
|
def devices(self) -> list[Device]:
|
|
@@ -464,12 +632,44 @@ class Hapbeat:
|
|
|
464
632
|
|
|
465
633
|
def _on_pong(self, pong: dict, ip: str) -> None:
|
|
466
634
|
with self._devices_lock:
|
|
635
|
+
previous = self._stream_states.get(ip)
|
|
636
|
+
decision, state = evaluate_pong_lease(
|
|
637
|
+
pong, self._pending_pings, self._client_incarnation, previous
|
|
638
|
+
)
|
|
639
|
+
if decision is PongLeaseDecision.DISCARD:
|
|
640
|
+
# a reply for a retired request / old incarnation: must not roll
|
|
641
|
+
# the device's address or liveness back either
|
|
642
|
+
return
|
|
643
|
+
if decision is PongLeaseDecision.ACCEPT:
|
|
644
|
+
self._stream_states[ip] = state
|
|
467
645
|
dev = self._devices.get(ip) or Device(ip=ip)
|
|
468
646
|
dev.name = pong.get("device_name", dev.name)
|
|
469
647
|
dev.address = pong.get("address", dev.address)
|
|
470
648
|
dev.firmware_version = pong.get("firmware_version", dev.firmware_version)
|
|
471
649
|
dev.last_seen = time.monotonic()
|
|
472
650
|
self._devices[ip] = dev
|
|
651
|
+
if decision is PongLeaseDecision.ACCEPT:
|
|
652
|
+
self._log_stream_state_change(ip, previous, state)
|
|
653
|
+
self._stream_hub.reconcile()
|
|
654
|
+
|
|
655
|
+
@staticmethod
|
|
656
|
+
def _log_stream_state_change(
|
|
657
|
+
ip: str,
|
|
658
|
+
previous: Optional[EndpointLeaseState],
|
|
659
|
+
state: EndpointLeaseState,
|
|
660
|
+
) -> None:
|
|
661
|
+
if previous is None or previous.endpoint_class is not state.endpoint_class:
|
|
662
|
+
logger.info(
|
|
663
|
+
"stream receiver %s classified as %s", ip,
|
|
664
|
+
"stream session v2" if state.endpoint_class is EndpointClass.V2
|
|
665
|
+
else "legacy (pre-v2 firmware)",
|
|
666
|
+
)
|
|
667
|
+
if state.lease_superseded and not (previous and previous.lease_superseded):
|
|
668
|
+
logger.warning(
|
|
669
|
+
"stream lease at %s was superseded by another writer; streams "
|
|
670
|
+
"to it are deferred. Call reacquire_stream_ownership() after "
|
|
671
|
+
"the other writer has stopped.", ip,
|
|
672
|
+
)
|
|
473
673
|
|
|
474
674
|
# ── Keep-alive thread ───────────────────────────────────────────
|
|
475
675
|
def _start_keepalive(self) -> None:
|
|
@@ -487,7 +687,7 @@ class Hapbeat:
|
|
|
487
687
|
self.connect_status(connected=True)
|
|
488
688
|
|
|
489
689
|
def loop(stop: threading.Event) -> None:
|
|
490
|
-
while not stop.wait(self.
|
|
690
|
+
while not stop.wait(self._keepalive_wait()):
|
|
491
691
|
self.ping()
|
|
492
692
|
if self.app_name:
|
|
493
693
|
self.connect_status(connected=True)
|
|
@@ -498,6 +698,21 @@ class Hapbeat:
|
|
|
498
698
|
)
|
|
499
699
|
self._keepalive_thread.start()
|
|
500
700
|
|
|
701
|
+
def _keepalive_wait(self) -> float:
|
|
702
|
+
"""The keep-alive interval, capped unless every known device is
|
|
703
|
+
legacy: a v2 device lease expires after 15 s without PING, and a
|
|
704
|
+
device not yet classified (or none heard yet) may turn out to be v2."""
|
|
705
|
+
with self._devices_lock:
|
|
706
|
+
states = [self._stream_states.get(ip) for ip in self._devices]
|
|
707
|
+
states.extend(self._stream_states.values())
|
|
708
|
+
all_legacy = bool(states) and all(
|
|
709
|
+
state is not None and state.endpoint_class is EndpointClass.LEGACY
|
|
710
|
+
for state in states
|
|
711
|
+
)
|
|
712
|
+
if all_legacy:
|
|
713
|
+
return self._keepalive_interval
|
|
714
|
+
return min(self._keepalive_interval, V2_LEASE_REFRESH_INTERVAL)
|
|
715
|
+
|
|
501
716
|
def _stop_keepalive(self) -> None:
|
|
502
717
|
if self._keepalive_stop is not None:
|
|
503
718
|
self._keepalive_stop.set()
|
hapbeat/launchpad.py
CHANGED
|
@@ -131,7 +131,8 @@ class App:
|
|
|
131
131
|
if not event:
|
|
132
132
|
return {"ok": False, "error": "event id is empty"}
|
|
133
133
|
ok = self.hb.play(event, _num(p, "gain", 0.5, 0, 1),
|
|
134
|
-
target=str(p.get("target", ""))
|
|
134
|
+
target=str(p.get("target", "")),
|
|
135
|
+
pan=_num(p, "pan", 0.0, -1, 1))
|
|
135
136
|
return {"ok": bool(ok)}
|
|
136
137
|
|
|
137
138
|
def stop_all(self) -> dict:
|