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/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
|
-
|
|
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(
|
|
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,
|
|
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("<
|
|
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(
|
|
132
|
-
|
|
133
|
-
|
|
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 =
|
|
176
|
-
|
|
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
|
-
|
|
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,
|
|
257
|
-
|
|
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
|
-
|
|
284
|
-
out["volume_steps"] = rest[2]
|
|
426
|
+
_classify_lease_tail(rest[2:], out)
|
|
285
427
|
return out
|
|
286
428
|
|
|
287
429
|
|