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/netif.py ADDED
@@ -0,0 +1,270 @@
1
+ """Local IPv4 subnet discovery — the input to subnet-directed broadcast.
2
+
3
+ Why this exists
4
+ ---------------
5
+ ``255.255.255.255`` (limited broadcast) leaves a multi-homed host through the
6
+ *single* interface with the lowest routing metric. Installing Hyper-V, WSL2 or
7
+ Docker adds a virtual switch that is permanently "connected" — no Ethernet
8
+ cable required — and Windows may well rank it above Wi-Fi. When that happens
9
+ the packet never reaches the network the Hapbeat is on and the SDK discovers
10
+ nothing at all. A subnet-directed address (``192.168.0.255``) is instead
11
+ resolved through the directly-connected route for that subnet, so the metric
12
+ never enters into it.
13
+
14
+ Computing that address needs each interface's real netmask, and the standard
15
+ library has no portable way to ask for one. So this module calls the platform
16
+ API directly through :mod:`ctypes`:
17
+
18
+ - **Windows** — ``GetAdaptersAddresses`` (iphlpapi), whose per-address
19
+ ``OnLinkPrefixLength`` is the prefix we want.
20
+ - **Linux / macOS / BSD** — ``getifaddrs`` (libc), which hands back ``ifa_netmask``
21
+ alongside each address.
22
+
23
+ Everything here is best-effort by design: any platform this cannot query
24
+ returns an empty list, and the caller falls back to the limited broadcast it
25
+ has always used. Struct layouts follow the `ifaddr <https://pypi.org/project/ifaddr/>`_
26
+ package (MIT), which is the reference implementation for this on both platforms.
27
+
28
+ The SDK has no runtime dependencies, which is why the layouts are restated here
29
+ instead of taking that package as a dependency.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import ctypes
35
+ import logging
36
+ import platform
37
+ import socket
38
+ from typing import List, Tuple
39
+
40
+ logger = logging.getLogger("hapbeat")
41
+
42
+ # (address, prefix length), e.g. ("192.168.0.205", 24)
43
+ Subnet = Tuple[str, int]
44
+
45
+ _IS_WINDOWS = platform.system() == "Windows"
46
+ _IS_BSD = platform.system() == "Darwin" or "BSD" in platform.system()
47
+
48
+
49
+ # ── sockaddr (BSD keeps a length byte where the others start the family) ──
50
+ if _IS_BSD:
51
+
52
+ class _sockaddr(ctypes.Structure):
53
+ _fields_ = [
54
+ ("sa_len", ctypes.c_uint8),
55
+ ("sa_family", ctypes.c_uint8),
56
+ ("sa_data", ctypes.c_uint8 * 14),
57
+ ]
58
+
59
+ class _sockaddr_in(ctypes.Structure):
60
+ _fields_ = [
61
+ ("sin_len", ctypes.c_uint8),
62
+ ("sin_family", ctypes.c_uint8),
63
+ ("sin_port", ctypes.c_uint16),
64
+ ("sin_addr", ctypes.c_uint8 * 4),
65
+ ("sin_zero", ctypes.c_uint8 * 8),
66
+ ]
67
+
68
+ else:
69
+
70
+ class _sockaddr(ctypes.Structure): # type: ignore[no-redef]
71
+ _fields_ = [
72
+ ("sa_family", ctypes.c_uint16),
73
+ ("sa_data", ctypes.c_uint8 * 14),
74
+ ]
75
+
76
+ class _sockaddr_in(ctypes.Structure): # type: ignore[no-redef]
77
+ _fields_ = [
78
+ ("sin_family", ctypes.c_uint16),
79
+ ("sin_port", ctypes.c_uint16),
80
+ ("sin_addr", ctypes.c_uint8 * 4),
81
+ ("sin_zero", ctypes.c_uint8 * 8),
82
+ ]
83
+
84
+
85
+ def _ipv4_of(sockaddr_ptr) -> str:
86
+ """Dotted-quad for an ``AF_INET`` sockaddr, or "" for anything else."""
87
+ if not sockaddr_ptr or sockaddr_ptr[0].sa_family != socket.AF_INET:
88
+ return ""
89
+ sin = ctypes.cast(sockaddr_ptr, ctypes.POINTER(_sockaddr_in))
90
+ return ".".join(str(b) for b in sin[0].sin_addr)
91
+
92
+
93
+ def _prefix_of_mask(mask: str) -> int:
94
+ """Prefix length for a dotted-quad mask. -1 if it is not contiguous.
95
+
96
+ A non-contiguous mask is not something a modern stack hands out, but
97
+ accepting one would produce a broadcast address that reaches the wrong
98
+ hosts, so it is rejected rather than guessed at.
99
+ """
100
+ try:
101
+ parts = [int(p) for p in mask.split(".")]
102
+ except ValueError:
103
+ return -1
104
+ if len(parts) != 4 or any(p < 0 or p > 255 for p in parts):
105
+ return -1
106
+ value = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]
107
+ if value == 0:
108
+ return -1
109
+ # Contiguous means the complement is all-ones in the low bits.
110
+ inverted = ~value & 0xFFFFFFFF
111
+ if inverted & (inverted + 1):
112
+ return -1
113
+ return 32 - inverted.bit_length()
114
+
115
+
116
+ # ── Windows: GetAdaptersAddresses ────────────────────────────────────
117
+ class _SOCKET_ADDRESS(ctypes.Structure):
118
+ _fields_ = [
119
+ ("lpSockaddr", ctypes.POINTER(_sockaddr)),
120
+ ("iSockaddrLength", ctypes.c_int),
121
+ ]
122
+
123
+
124
+ class _IP_ADAPTER_UNICAST_ADDRESS(ctypes.Structure):
125
+ pass
126
+
127
+
128
+ _IP_ADAPTER_UNICAST_ADDRESS._fields_ = [
129
+ ("Length", ctypes.c_ulong),
130
+ ("Flags", ctypes.c_uint32),
131
+ ("Next", ctypes.POINTER(_IP_ADAPTER_UNICAST_ADDRESS)),
132
+ ("Address", _SOCKET_ADDRESS),
133
+ ("PrefixOrigin", ctypes.c_uint),
134
+ ("SuffixOrigin", ctypes.c_uint),
135
+ ("DadState", ctypes.c_uint),
136
+ ("ValidLifetime", ctypes.c_ulong),
137
+ ("PreferredLifetime", ctypes.c_ulong),
138
+ ("LeaseLifetime", ctypes.c_ulong),
139
+ ("OnLinkPrefixLength", ctypes.c_uint8),
140
+ ]
141
+
142
+
143
+ class _IP_ADAPTER_ADDRESSES(ctypes.Structure):
144
+ pass
145
+
146
+
147
+ _IP_ADAPTER_ADDRESSES._fields_ = [
148
+ ("Length", ctypes.c_ulong),
149
+ ("IfIndex", ctypes.c_uint32),
150
+ ("Next", ctypes.POINTER(_IP_ADAPTER_ADDRESSES)),
151
+ ("AdapterName", ctypes.c_char_p),
152
+ ("FirstUnicastAddress", ctypes.POINTER(_IP_ADAPTER_UNICAST_ADDRESS)),
153
+ ("FirstAnycastAddress", ctypes.c_void_p),
154
+ ("FirstMulticastAddress", ctypes.c_void_p),
155
+ ("FirstDnsServerAddress", ctypes.c_void_p),
156
+ ("DnsSuffix", ctypes.c_wchar_p),
157
+ ("Description", ctypes.c_wchar_p),
158
+ ("FriendlyName", ctypes.c_wchar_p),
159
+ ]
160
+
161
+ _ERROR_BUFFER_OVERFLOW = 111
162
+
163
+
164
+ def _windows_subnets() -> List[Subnet]:
165
+ iphlpapi = ctypes.windll.Iphlpapi # type: ignore[attr-defined]
166
+
167
+ size = ctypes.c_ulong(15 * 1024)
168
+ buffer = ctypes.create_string_buffer(size.value)
169
+ retval = _ERROR_BUFFER_OVERFLOW
170
+ # The documented protocol: on overflow the call writes the size it needs.
171
+ # Bounded so a pathological answer cannot spin here.
172
+ for _ in range(5):
173
+ buffer = ctypes.create_string_buffer(size.value)
174
+ retval = iphlpapi.GetAdaptersAddresses(
175
+ ctypes.c_ulong(socket.AF_INET),
176
+ ctypes.c_ulong(0),
177
+ None,
178
+ ctypes.byref(buffer),
179
+ ctypes.byref(size),
180
+ )
181
+ if retval != _ERROR_BUFFER_OVERFLOW:
182
+ break
183
+ if retval != 0:
184
+ raise OSError(f"GetAdaptersAddresses failed ({retval})")
185
+
186
+ out: List[Subnet] = []
187
+ adapter = _IP_ADAPTER_ADDRESSES.from_buffer(buffer)
188
+ while True:
189
+ # Adapters with no configured IPv4 address are simply absent from an
190
+ # AF_INET enumeration, so no operational-status filtering is needed
191
+ # here; 127/8 is dropped by the caller and duplicates are merged there.
192
+ address_ptr = adapter.FirstUnicastAddress
193
+ while address_ptr:
194
+ address = address_ptr[0]
195
+ ip = _ipv4_of(address.Address.lpSockaddr)
196
+ prefix = address.OnLinkPrefixLength
197
+ if ip and 0 < prefix <= 32:
198
+ out.append((ip, prefix))
199
+ address_ptr = address.Next
200
+ if not adapter.Next:
201
+ break
202
+ adapter = adapter.Next[0]
203
+ return out
204
+
205
+
206
+ # ── POSIX: getifaddrs ────────────────────────────────────────────────
207
+ class _ifaddrs(ctypes.Structure):
208
+ pass
209
+
210
+
211
+ _ifaddrs._fields_ = [
212
+ ("ifa_next", ctypes.POINTER(_ifaddrs)),
213
+ ("ifa_name", ctypes.c_char_p),
214
+ ("ifa_flags", ctypes.c_uint),
215
+ ("ifa_addr", ctypes.POINTER(_sockaddr)),
216
+ ("ifa_netmask", ctypes.POINTER(_sockaddr)),
217
+ ]
218
+
219
+
220
+ def _posix_subnets() -> List[Subnet]:
221
+ import ctypes.util
222
+
223
+ libc_name = ctypes.util.find_library("c")
224
+ if libc_name is None:
225
+ raise OSError("libc not found")
226
+ libc = ctypes.CDLL(libc_name, use_errno=True)
227
+
228
+ head = ctypes.POINTER(_ifaddrs)()
229
+ if libc.getifaddrs(ctypes.byref(head)) != 0:
230
+ errno = ctypes.get_errno()
231
+ raise OSError(errno, "getifaddrs failed")
232
+
233
+ out: List[Subnet] = []
234
+ try:
235
+ node = head
236
+ while node:
237
+ entry = node[0]
238
+ # getifaddrs also reports link-layer entries (AF_PACKET / AF_LINK)
239
+ # and IPv6; _ipv4_of returns "" for those.
240
+ ip = _ipv4_of(entry.ifa_addr)
241
+ if ip and entry.ifa_netmask:
242
+ # On some systems the netmask sockaddr carries family 0.
243
+ # It sits on an IPv4 address, so read it as one.
244
+ if entry.ifa_netmask[0].sa_family == 0:
245
+ entry.ifa_netmask[0].sa_family = socket.AF_INET
246
+ mask = _ipv4_of(entry.ifa_netmask)
247
+ prefix = _prefix_of_mask(mask) if mask else -1
248
+ if prefix > 0:
249
+ out.append((ip, prefix))
250
+ node = entry.ifa_next
251
+ finally:
252
+ libc.freeifaddrs(head)
253
+ return out
254
+
255
+
256
+ def local_ipv4_subnets() -> List[Subnet]:
257
+ """Every local IPv4 address with its prefix length, loopback excluded.
258
+
259
+ Returns an empty list on any platform or failure this cannot handle —
260
+ callers treat that as "use the limited broadcast", which is the behaviour
261
+ that predates this module.
262
+ """
263
+ try:
264
+ found = _windows_subnets() if _IS_WINDOWS else _posix_subnets()
265
+ except Exception as exc: # noqa: BLE001 — never let discovery setup fail a session
266
+ logger.debug("could not enumerate local subnets (%s); "
267
+ "limited broadcast only", exc)
268
+ return []
269
+
270
+ return [(ip, prefix) for ip, prefix in found if not ip.startswith("127.")]
hapbeat/osc.py CHANGED
@@ -4,7 +4,7 @@ Lets any OSC-capable tool (TouchOSC, Max/MSP, TouchDesigner, a DAW, vvvv, ...)
4
4
  drive Hapbeat without writing code, using the addresses defined in
5
5
  ``hapbeat-contracts/specs/message-format.md`` §6::
6
6
 
7
- /hapbeat/play event_id [target] [target_time_us] [gain]
7
+ /hapbeat/play event_id [target] [target_time_us] [gain] [pan]
8
8
  /hapbeat/stop event_id [target]
9
9
  /hapbeat/stop-all [target]
10
10
  /hapbeat/ping
@@ -66,7 +66,9 @@ class OscBridge:
66
66
  target = str(args[1]) if len(args) >= 2 and args[1] != "" else None
67
67
  target_time = int(args[2]) if len(args) >= 3 else 0
68
68
  gain = float(args[3]) if len(args) >= 4 else None
69
- self.hb.play(event_id, gain, target=target, target_time_us=target_time)
69
+ pan = float(args[4]) if len(args) >= 5 else 0.0
70
+ self.hb.play(event_id, gain, target=target, target_time_us=target_time,
71
+ pan=pan)
70
72
 
71
73
  def _handle_stop(self, _address: str, *args) -> None:
72
74
  if not args:
hapbeat/protocol.py CHANGED
@@ -21,6 +21,9 @@ from typing import Optional
21
21
  # ── Header constants ────────────────────────────────────────────────
22
22
  MAGIC = 0x4842 # ASCII "HB"
23
23
  VERSION = 0x01
24
+ # STREAM_BEGIN/DATA/END to stream-session-v2 receivers carry header version 2
25
+ # (contracts stream-session-v2.md); every other command stays version 1.
26
+ STREAM_VERSION = 0x02
24
27
  HEADER_SIZE = 8
25
28
  MAX_PACKET_SIZE = 512 # command packets
26
29
  MAX_STREAM_PACKET_SIZE = 1472 # 1500 MTU - 20 IP - 8 UDP
@@ -43,19 +46,34 @@ CMD_STREAM_END = 0x32
43
46
  CMD_PONG = 0x11
44
47
  CMD_ERROR = 0xFF
45
48
 
49
+ # ── Stream session v2 (contracts stream-session-v2.md) ──────────────
50
+ STREAM_ENVELOPE_SIZE = 16 # device_boot_id(u64) lease_ticket(u32) generation(u32)
51
+ STREAM_LEASE_TAIL_MARKER = b"HBS2"
52
+ STREAM_LEASE_TAIL_SIZE = 32
53
+ STREAM_LEASE_TAIL_VERSION = 1
54
+ STREAM_LEASE_FLAG_VALID = 0x01
55
+ STREAM_LEASE_FLAG_SUPERSEDED = 0x02
56
+
57
+ # PONG ``lease_tail``: how the bytes after volume_wiper classify
58
+ LEASE_TAIL_ABSENT = "absent" # no HBS2 marker (pre-v2 firmware when matched)
59
+ LEASE_TAIL_VALID = "valid" # well-formed 32-byte HBS2 tail
60
+ LEASE_TAIL_MALFORMED = "malformed" # HBS2 marker, wrong length/version/reserved/flags
61
+
46
62
  # ── Audio formats (streaming) ───────────────────────────────────────
47
63
  AUDIO_FORMAT_PCM16 = 0
48
64
  AUDIO_FORMAT_IMA_ADPCM = 1
49
65
 
50
66
 
51
67
  # ── Header ──────────────────────────────────────────────────────────
52
- def build_header(command_type: int, seq: int, payload_length: int) -> bytes:
68
+ def build_header(
69
+ command_type: int, seq: int, payload_length: int, version: int = VERSION
70
+ ) -> bytes:
53
71
  """Build the 8-byte common header (little-endian).
54
72
 
55
73
  Layout: magic(u16) version(u8) command_type(u8) seq(u16) payload_length(u16)
56
74
  """
57
75
  return struct.pack(
58
- "<HBBHH", MAGIC, VERSION, command_type, seq & 0xFFFF, payload_length
76
+ "<HBBHH", MAGIC, version, command_type, seq & 0xFFFF, payload_length
59
77
  )
60
78
 
61
79
 
@@ -100,18 +118,27 @@ def build_play(
100
118
  target: str = "",
101
119
  target_time_us: int = 0,
102
120
  gain: float = 1.0,
121
+ pan: float = 0.0,
103
122
  ) -> bytes:
104
123
  """PLAY (0x01).
105
124
 
106
125
  Wire payload (see contracts/specs/device-addressing.md §5.1):
107
126
  event_id(null-term) + target(null-term) + target_time(i64) + gain(f32)
127
+ + pan(f32)
108
128
 
109
129
  ``target=""`` broadcasts to every device.
130
+
131
+ ``pan`` is the left/right balance (-1 left, 0 centre, +1 right) applied by
132
+ the device mixer as linear balance (contracts message-format.md §0x01,
133
+ DEC-055). It is the *trailing optional* field of the payload — receivers
134
+ that predate it treat its absence as 0.0 — but we always write it, and
135
+ clamp to [-1, 1] here so an out-of-range value can never reach the wire.
110
136
  """
137
+ pan = max(-1.0, min(1.0, float(pan)))
111
138
  payload = (
112
139
  event_id.encode("utf-8") + b"\x00"
113
140
  + target.encode("utf-8") + b"\x00"
114
- + struct.pack("<qf", int(target_time_us), float(gain))
141
+ + struct.pack("<qff", int(target_time_us), float(gain), pan)
115
142
  )
116
143
  return build_packet(CMD_PLAY, seq, payload)
117
144
 
@@ -128,9 +155,21 @@ def build_stop_all(seq: int, *, target: str = "") -> bytes:
128
155
  return build_packet(CMD_STOP_ALL, seq, payload)
129
156
 
130
157
 
131
- def build_ping(seq: int, timestamp_us: int) -> bytes:
132
- """PING (0x10). Payload: timestamp(i64, microseconds)."""
133
- return build_packet(CMD_PING, seq, struct.pack("<q", int(timestamp_us)))
158
+ def build_ping(
159
+ seq: int, timestamp_us: int, client_incarnation: Optional[int] = None
160
+ ) -> bytes:
161
+ """PING (0x10). Payload: timestamp(i64, microseconds).
162
+
163
+ With ``client_incarnation`` (nonzero u64) this is the 16-byte extended PING
164
+ of stream-session-v2.md: v2 receivers answer with an HBS2 lease tail,
165
+ pre-v2 firmware ignores the extra bytes and answers an ordinary PONG.
166
+ """
167
+ payload = struct.pack("<q", int(timestamp_us))
168
+ if client_incarnation is not None:
169
+ if not 0 < client_incarnation <= 0xFFFFFFFFFFFFFFFF:
170
+ raise ValueError("client_incarnation must be a nonzero uint64")
171
+ payload += struct.pack("<Q", client_incarnation)
172
+ return build_packet(CMD_PING, seq, payload)
134
173
 
135
174
 
136
175
  def build_connect_status(
@@ -157,6 +196,35 @@ def build_connect_status(
157
196
 
158
197
 
159
198
  # ── Streaming builders (clip-mode playback) ─────────────────────────
199
+ #
200
+ # v1 (``build_stream_*``) is for pre-v2 firmware only: header version 1, no
201
+ # envelope, END without payload (contracts message-format.md "v1 stream").
202
+ # v2 (``build_stream_*_v2``) starts every payload with the 16-byte
203
+ # device_boot_id / lease_ticket / generation envelope, header version 2.
204
+ def _stream_begin_body(
205
+ sample_rate: int, channels: int, fmt: int, total_samples: int,
206
+ gain: float, target: str,
207
+ ) -> bytes:
208
+ body = struct.pack(
209
+ "<HBBIf", sample_rate, channels, fmt, total_samples, float(gain)
210
+ )
211
+ if target:
212
+ body += target.encode("utf-8") + b"\x00"
213
+ return body
214
+
215
+
216
+ def _build_stream_packet(
217
+ command_type: int, seq: int, payload: bytes, version: int
218
+ ) -> bytes:
219
+ """Stream packets use the larger MTU-safe size cap."""
220
+ total = HEADER_SIZE + len(payload)
221
+ if total > MAX_STREAM_PACKET_SIZE:
222
+ raise ValueError(
223
+ f"stream packet {total} exceeds MTU cap {MAX_STREAM_PACKET_SIZE}"
224
+ )
225
+ return build_header(command_type, seq, len(payload), version) + payload
226
+
227
+
160
228
  def build_stream_begin(
161
229
  seq: int,
162
230
  *,
@@ -167,35 +235,78 @@ def build_stream_begin(
167
235
  gain: float = 1.0,
168
236
  target: str = "",
169
237
  ) -> bytes:
170
- """STREAM_BEGIN (0x30).
238
+ """STREAM_BEGIN (0x30), v1 (pre-v2 firmware).
171
239
 
172
240
  Default format is PCM16 — clip streaming sends uncompressed 16-bit PCM
173
241
  (ADPCM is reserved but not produced by the SDK).
174
242
  """
175
- payload = struct.pack(
176
- "<HBBIf", sample_rate, channels, fmt, total_samples, float(gain)
243
+ payload = _stream_begin_body(
244
+ sample_rate, channels, fmt, total_samples, gain, target
177
245
  )
178
- if target:
179
- payload += target.encode("utf-8") + b"\x00"
180
246
  return build_packet(CMD_STREAM_BEGIN, seq, payload)
181
247
 
182
248
 
183
249
  def build_stream_data(seq: int, offset: int, data: bytes) -> bytes:
184
- """STREAM_DATA (0x31). Uses the larger MTU-safe size cap."""
250
+ """STREAM_DATA (0x31), v1. Uses the larger MTU-safe size cap."""
185
251
  payload = struct.pack("<I", offset) + data
186
- total = HEADER_SIZE + len(payload)
187
- if total > MAX_STREAM_PACKET_SIZE:
188
- raise ValueError(
189
- f"stream packet {total} exceeds MTU cap {MAX_STREAM_PACKET_SIZE}"
190
- )
191
- return build_header(CMD_STREAM_DATA, seq, len(payload)) + payload
252
+ return _build_stream_packet(CMD_STREAM_DATA, seq, payload, VERSION)
192
253
 
193
254
 
194
255
  def build_stream_end(seq: int) -> bytes:
195
- """STREAM_END (0x32). No payload."""
256
+ """STREAM_END (0x32), v1. No payload."""
196
257
  return build_packet(CMD_STREAM_END, seq, b"")
197
258
 
198
259
 
260
+ def build_stream_envelope(boot_id: int, lease_ticket: int, generation: int) -> bytes:
261
+ """The 16-byte v2 stream envelope. Every field must be nonzero."""
262
+ if not 0 < boot_id <= 0xFFFFFFFFFFFFFFFF:
263
+ raise ValueError("device_boot_id must be a nonzero uint64")
264
+ if not 0 < lease_ticket <= 0xFFFFFFFF:
265
+ raise ValueError("lease_ticket must be a nonzero uint32")
266
+ if not 0 < generation <= 0xFFFFFFFF:
267
+ raise ValueError("generation must be a nonzero uint32")
268
+ return struct.pack("<QII", boot_id, lease_ticket, generation)
269
+
270
+
271
+ def build_stream_begin_v2(
272
+ seq: int,
273
+ boot_id: int,
274
+ lease_ticket: int,
275
+ generation: int,
276
+ *,
277
+ sample_rate: int = 16000,
278
+ channels: int = 1,
279
+ fmt: int = AUDIO_FORMAT_PCM16,
280
+ total_samples: int = 0,
281
+ gain: float = 1.0,
282
+ target: str = "",
283
+ ) -> bytes:
284
+ """STREAM_BEGIN (0x30), v2: envelope + the v1 BEGIN body."""
285
+ payload = build_stream_envelope(boot_id, lease_ticket, generation)
286
+ payload += _stream_begin_body(
287
+ sample_rate, channels, fmt, total_samples, gain, target
288
+ )
289
+ return _build_stream_packet(CMD_STREAM_BEGIN, seq, payload, STREAM_VERSION)
290
+
291
+
292
+ def build_stream_data_v2(
293
+ seq: int, boot_id: int, lease_ticket: int, generation: int,
294
+ offset: int, data: bytes,
295
+ ) -> bytes:
296
+ """STREAM_DATA (0x31), v2: envelope + byte offset(u32) + audio."""
297
+ payload = build_stream_envelope(boot_id, lease_ticket, generation)
298
+ payload += struct.pack("<I", offset & 0xFFFFFFFF) + data
299
+ return _build_stream_packet(CMD_STREAM_DATA, seq, payload, STREAM_VERSION)
300
+
301
+
302
+ def build_stream_end_v2(
303
+ seq: int, boot_id: int, lease_ticket: int, generation: int
304
+ ) -> bytes:
305
+ """STREAM_END (0x32), v2: exactly the envelope, no body."""
306
+ payload = build_stream_envelope(boot_id, lease_ticket, generation)
307
+ return _build_stream_packet(CMD_STREAM_END, seq, payload, STREAM_VERSION)
308
+
309
+
199
310
  # ── Device addressing ───────────────────────────────────────────────
200
311
  def address_matches(target: Optional[str], device_address: Optional[str]) -> bool:
201
312
  """Does ``device_address`` accept a packet aimed at ``target``?
@@ -249,12 +360,44 @@ def _read_cstr(buf: bytes) -> tuple[str, bytes]:
249
360
  return buf[:idx].decode("utf-8", errors="replace"), buf[idx + 1:]
250
361
 
251
362
 
363
+ def _classify_lease_tail(tail: bytes, out: dict) -> None:
364
+ """Classify the bytes after volume_wiper (stream-session-v2.md "Legacy
365
+ receiver fallback"): anything that is not the full 4-byte HBS2 marker
366
+ (including a truncated prefix such as "HB") -> absent; the full marker
367
+ with a wrong length / version / reserved / flags -> malformed; otherwise
368
+ the valid 32-byte lease tail."""
369
+ if tail[:4] != STREAM_LEASE_TAIL_MARKER:
370
+ out["lease_tail"] = LEASE_TAIL_ABSENT
371
+ return
372
+ known_flags = STREAM_LEASE_FLAG_VALID | STREAM_LEASE_FLAG_SUPERSEDED
373
+ if (
374
+ len(tail) != STREAM_LEASE_TAIL_SIZE
375
+ or tail[4] != STREAM_LEASE_TAIL_VERSION
376
+ or tail[5] & ~known_flags
377
+ or struct.unpack_from("<H", tail, 6)[0] != 0
378
+ ):
379
+ out["lease_tail"] = LEASE_TAIL_MALFORMED
380
+ return
381
+ flags = tail[5]
382
+ incarnation, boot_id, ticket, high_water = struct.unpack_from("<QQII", tail, 8)
383
+ out["lease_tail"] = LEASE_TAIL_VALID
384
+ out["echoed_client_incarnation"] = incarnation
385
+ out["device_boot_id"] = boot_id
386
+ out["lease_ticket"] = ticket
387
+ out["high_water_ticket"] = high_water
388
+ out["lease_valid"] = bool(flags & STREAM_LEASE_FLAG_VALID) and boot_id != 0 and ticket != 0
389
+ out["lease_superseded"] = bool(flags & STREAM_LEASE_FLAG_SUPERSEDED)
390
+
391
+
252
392
  def parse_pong(data: bytes) -> Optional[dict]:
253
393
  """Parse a PONG (0x11).
254
394
 
255
395
  Bridge form is 16 bytes (timestamp, server_time). Devices append an
256
- extended form (name / address / firmware_version, plus optional
257
- trailing volume bytes). Returns ``None`` if not a valid PONG.
396
+ extended form (name / address / firmware_version, then volume_level and
397
+ volume_wiper). A v2 receiver's direct reply to an extended PING ends with
398
+ the 32-byte HBS2 stream lease tail; ``lease_tail`` says how the bytes
399
+ after volume_wiper classify (``LEASE_TAIL_*``). Returns ``None`` if not a
400
+ valid PONG.
258
401
  """
259
402
  hdr = parse_header(data)
260
403
  if hdr is None or hdr["command_type"] != CMD_PONG:
@@ -280,8 +423,7 @@ def parse_pong(data: bytes) -> Optional[dict]:
280
423
  out["volume_level"] = rest[0]
281
424
  if len(rest) >= 2:
282
425
  out["volume_wiper"] = rest[1]
283
- if len(rest) >= 3:
284
- out["volume_steps"] = rest[2]
426
+ _classify_lease_tail(rest[2:], out)
285
427
  return out
286
428
 
287
429