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/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
- # 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
118
- # Clip streaming (level-2). The streamer calls this object's
119
- # stream_begin/stream_data/stream_end (StreamSink protocol).
120
- self._clip = ClipStreamer(self, send_ahead_sec=stream_send_ahead)
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._clip.stop() # end any in-flight clip stream
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
- ) -> bool:
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
- ``target_time_us`` applies to command mode only.
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 end the active stream; command
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._clip.stop()
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
- self._clip.stop()
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
- return self._client.send(protocol.build_ping(self._next_seq(), _now_us()))
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
- return self._client.send(pkt)
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
- ) -> bool:
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._clip.play(
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
- ) -> bool:
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._clip.play(
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
- ) -> None:
353
- """Stream an ad-hoc PCM16 buffer (e.g. synthesized cues). ``channels=2``
354
- with per-channel amplitude conveys L/R balance (PLAY has no pan)."""
355
- self._clip.play(
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
- # ── StreamSink (called by ClipStreamer) ─────────────────────────
403
- def _snapshot_stream_destinations(self, target: str) -> None:
404
- """Pick the destinations for one stream session.
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
- 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).
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
- 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
- ]
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 _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)
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
- def stream_begin(self, *, sample_rate: int, channels: int,
437
- total_samples: int, gain: float, target: str) -> None:
438
- self._snapshot_stream_destinations(target)
439
- self._send_stream(protocol.build_stream_begin(
440
- self._next_seq(), sample_rate=sample_rate, channels=channels,
441
- fmt=protocol.AUDIO_FORMAT_PCM16, total_samples=total_samples,
442
- gain=gain, target=target,
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 stream_data(self, offset: int, data: bytes) -> None:
446
- self._send_stream(protocol.build_stream_data(self._next_seq(), offset, data))
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 stream_end(self) -> None:
449
- self._send_stream(protocol.build_stream_end(self._next_seq()))
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
- time.sleep(max(0.0, timeout))
457
- with self._devices_lock:
458
- return [d for d in self._devices.values() if d.last_seen >= before - 0.05]
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._keepalive_interval):
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: