Skip to content

Commit ac5be2d

Browse files
committed
usbif: UAC descriptor parsing, the first half of Phase 5's host side
A UAC device does not announce its formats in its interface descriptors. It announces them in class-specific descriptors interleaved between them, so a host must walk the whole configuration blob before it can choose an alternate setting and start streaming. That walk is this module. It is Python, and in this package rather than the C module, for the reason the package docstring already states: Python configures and observes, C moves isochronous bytes. Choosing a format is configuration. Doing it here means a mis-parsed descriptor costs a re-run instead of a reflash, and lets the same parser be pointed at a descriptor captured from any host, including one that is not the board. Four things it gets right that a naive reader does not: Sample rates are three-byte little-endian. A four-byte read returns a plausible-looking wrong number rather than failing, which is the worst kind of parser bug. Alt 0 is excluded. By specification it carries no endpoint and exists so a device can be configured while consuming no bus bandwidth; offering it as a choosable stream would offer a format that is silent by design. Feedback endpoints are excluded. They carry rate corrections, not samples, and treating one as a stream produces a device that enumerates, "works", and is silent. A truncated or padded blob stops the walk rather than looping. Real hardware returns both. Eleven tests over a blob assembled byte by byte rather than captured, so they exercise the real layout -- the three-byte rates, the audio endpoint's 9-byte form, alt 0 present with no endpoint. Paired with usbif's new host_desc(dev_id), which hands Python a hosted device's whole active configuration descriptor including every class-specific descriptor. UVC will need exactly the same door.
1 parent 24e72bc commit ac5be2d

3 files changed

Lines changed: 345 additions & 0 deletions

File tree

lib/usbif/uac.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""USB Audio Class descriptor parsing, for the host role.
2+
3+
A UAC device does not announce its formats in its interface descriptors. It
4+
announces them in *class-specific* descriptors interleaved between them, and a
5+
host has to walk the whole configuration blob to learn what the device can do
6+
before it can choose an alternate setting and start streaming. That walk is
7+
this module.
8+
9+
It lives in Python, and in this package rather than in the C module, for the
10+
reason the package docstring already gives: **Python configures and observes;
11+
C moves isochronous bytes.** Choosing a format is configuration. Doing it here
12+
means a mis-parsed descriptor costs a re-run instead of a reflash, and means
13+
the same parser can be pointed at a descriptor captured from any host --
14+
including one from a machine that is not the board.
15+
16+
Only UAC 1.0 is handled, which is what class-compliant devices that work
17+
without a driver actually implement, and what the ESP32's full-speed host can
18+
carry. UAC 2.0's descriptors differ enough to be a separate reader rather than
19+
a flag on this one.
20+
21+
The shape of a configuration blob, for the reader who has not stared at one::
22+
23+
CONFIGURATION
24+
INTERFACE class=1 subclass=1 AudioControl
25+
CS_INTERFACE ... topology: terminals and units
26+
INTERFACE class=1 subclass=2 AudioStreaming, alt 0 -- always silent
27+
(no endpoint: alt 0 exists so a device can consume no bandwidth)
28+
INTERFACE class=1 subclass=2 AudioStreaming, alt 1
29+
CS_INTERFACE AS_GENERAL which terminal, which format tag
30+
CS_INTERFACE FORMAT_TYPE channels, bit depth, sample rates
31+
ENDPOINT isochronous address, packet size, interval
32+
INTERFACE ... alt 2, alt 3 the same again at other formats
33+
34+
Choosing a format therefore means choosing an *alternate setting*, which is
35+
why :func:`streams` returns one record per alt rather than one per interface.
36+
"""
37+
38+
try:
39+
from collections import namedtuple
40+
except ImportError: # pragma: no cover - ucollections on older firmware
41+
from ucollections import namedtuple
42+
43+
# Descriptor types
44+
DT_INTERFACE = 0x04
45+
DT_ENDPOINT = 0x05
46+
DT_CS_INTERFACE = 0x24
47+
DT_CS_ENDPOINT = 0x25
48+
49+
# Audio interface class and subclasses
50+
CLASS_AUDIO = 0x01
51+
SUBCLASS_AUDIOCONTROL = 0x01
52+
SUBCLASS_AUDIOSTREAMING = 0x02
53+
54+
# AudioStreaming class-specific interface descriptor subtypes
55+
AS_GENERAL = 0x01
56+
AS_FORMAT_TYPE = 0x02
57+
58+
# Endpoint attribute bits
59+
EP_XFER_MASK = 0x03
60+
EP_XFER_ISOC = 0x01
61+
EP_SYNC_MASK = 0x0C
62+
EP_SYNC_ASYNC = 0x04
63+
EP_SYNC_ADAPTIVE = 0x08
64+
EP_SYNC_SYNC = 0x0C
65+
EP_USAGE_MASK = 0x30
66+
EP_USAGE_FEEDBACK = 0x10
67+
68+
IN, OUT = "in", "out"
69+
70+
STREAM_FIELDS = ("interface", "alt", "endpoint", "direction", "rates",
71+
"channels", "bits", "frame_bytes", "max_packet", "interval",
72+
"sync", "terminal")
73+
74+
UacStream = namedtuple("UacStream", " ".join(STREAM_FIELDS)) # noqa: PYI024
75+
76+
77+
def descriptors(blob):
78+
"""Walk a configuration blob, yielding ``(length, type, memoryview)``.
79+
80+
Stops at the first zero-length descriptor rather than looping forever: a
81+
truncated or padded blob is a real thing to receive from real hardware,
82+
and a parser that hangs on one is worse than a parser that stops early.
83+
"""
84+
view = memoryview(blob)
85+
offset = 0
86+
end = len(view)
87+
while offset + 2 <= end:
88+
length = view[offset]
89+
if length < 2 or offset + length > end:
90+
return
91+
yield length, view[offset + 1], view[offset:offset + length]
92+
offset += length
93+
94+
95+
def _rates(body, offset, count):
96+
"""Sample rates from a FORMAT_TYPE_I descriptor.
97+
98+
``bSamFreqType`` is 0 for a continuous min..max pair, or a count of
99+
discrete rates. Both are three-byte little-endian, which is the detail
100+
that makes a naive 4-byte read return plausible nonsense.
101+
"""
102+
out = []
103+
for i in range(count if count else 2):
104+
base = offset + i * 3
105+
if base + 3 > len(body):
106+
break
107+
out.append(body[base] | (body[base + 1] << 8) | (body[base + 2] << 16))
108+
return tuple(out)
109+
110+
111+
def streams(blob):
112+
"""Every AudioStreaming alternate setting that can actually carry audio.
113+
114+
Alt 0 is deliberately excluded: by specification it has no endpoint and
115+
exists so a device can be configured while consuming no bus bandwidth.
116+
Returning it as a choosable stream would offer a format that is silent by
117+
design, which is the sort of thing that looks like a driver bug later.
118+
119+
Feedback endpoints are excluded for the same reason -- they carry rate
120+
corrections, not samples.
121+
"""
122+
found = []
123+
itf = alt = None
124+
pending = None
125+
126+
for length, dtype, body in descriptors(blob):
127+
if dtype == DT_INTERFACE and length >= 9:
128+
if pending is not None:
129+
found.append(pending)
130+
pending = None
131+
itf, alt = body[2], body[3]
132+
if body[5] == CLASS_AUDIO and body[6] == SUBCLASS_AUDIOSTREAMING and alt != 0:
133+
pending = {"interface": itf, "alt": alt, "terminal": None,
134+
"rates": (), "channels": 0, "bits": 0, "frame_bytes": 0,
135+
"endpoint": None, "direction": None,
136+
"max_packet": 0, "interval": 0, "sync": None}
137+
continue
138+
139+
if pending is None:
140+
continue
141+
142+
if dtype == DT_CS_INTERFACE and length >= 3:
143+
subtype = body[2]
144+
if subtype == AS_GENERAL and length >= 7:
145+
pending["terminal"] = body[3]
146+
elif subtype == AS_FORMAT_TYPE and length >= 8:
147+
pending["channels"] = body[4]
148+
pending["frame_bytes"] = body[5] # bSubframeSize
149+
pending["bits"] = body[6] # bBitResolution
150+
pending["rates"] = _rates(body, 8, body[7])
151+
elif dtype == DT_ENDPOINT and length >= 7:
152+
attrs = body[3]
153+
if (attrs & EP_XFER_MASK) != EP_XFER_ISOC:
154+
continue
155+
if (attrs & EP_USAGE_MASK) == EP_USAGE_FEEDBACK:
156+
continue # rate corrections, not audio
157+
address = body[2]
158+
pending["endpoint"] = address
159+
pending["direction"] = IN if address & 0x80 else OUT
160+
pending["max_packet"] = body[4] | (body[5] << 8)
161+
pending["interval"] = body[6]
162+
sync = attrs & EP_SYNC_MASK
163+
pending["sync"] = {EP_SYNC_ASYNC: "async", EP_SYNC_ADAPTIVE: "adaptive",
164+
EP_SYNC_SYNC: "sync"}.get(sync, "none")
165+
166+
if pending is not None:
167+
found.append(pending)
168+
169+
return tuple(UacStream(**s) for s in found if s["endpoint"] is not None)
170+
171+
172+
def has_audio(blob):
173+
"""True if this configuration offers any AudioStreaming interface."""
174+
for length, dtype, body in descriptors(blob):
175+
if dtype == DT_INTERFACE and length >= 9 \
176+
and body[5] == CLASS_AUDIO and body[6] == SUBCLASS_AUDIOSTREAMING:
177+
return True
178+
return False
179+
180+
181+
def choose(streams_found, direction, rate=None, channels=None, bits=None):
182+
"""Pick the best stream for a direction, or ``None``.
183+
184+
Preference order, most specific first: an exact match on everything the
185+
caller asked for, then the highest rate available, then the widest bit
186+
depth. A caller that asks for nothing gets the device's best offer, which
187+
is what "select it like any other output" should mean.
188+
"""
189+
candidates = [s for s in streams_found if s.direction == direction]
190+
if rate is not None:
191+
candidates = [s for s in candidates if rate in s.rates]
192+
if channels is not None:
193+
candidates = [s for s in candidates if s.channels == channels]
194+
if bits is not None:
195+
candidates = [s for s in candidates if s.bits == bits]
196+
if not candidates:
197+
return None
198+
return max(candidates, key=lambda s: (max(s.rates) if s.rates else 0, s.bits,
199+
s.channels))
200+
201+
202+
def describe(stream):
203+
"""One-line human description of a stream, for logs and REPL use."""
204+
rates = "/".join(str(r) for r in stream.rates) if stream.rates else "?"
205+
return "itf {} alt {}: {} {} Hz x {}ch x {}bit, ep {:#04x} {} {}B/{}ms".format(
206+
stream.interface, stream.alt, stream.direction, rates, stream.channels,
207+
stream.bits, stream.endpoint, stream.sync, stream.max_packet,
208+
stream.interval)

pydevices-desktop.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/usbif/linux_usb.py" = "/lib/usbif/linux_usb.py"
6363
"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/usbif/native_midi.py" = "/lib/usbif/native_midi.py"
6464
"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/usbif/native_usb.py" = "/lib/usbif/native_usb.py"
65+
"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/usbif/uac.py" = "/lib/usbif/uac.py"
6566
"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/usbif/win_midi.py" = "/lib/usbif/win_midi.py"
6667
"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/usbif/win_usb.py" = "/lib/usbif/win_usb.py"
6768
"https://raw.githubusercontent.com/PyDevices/pydevices/main/utils/usdl2.py" = "/lib/usdl2.py"

tests/test_usbif.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,3 +1014,139 @@ def test_ids_round_trip(self):
10141014
self._install()
10151015
self.assertEqual(self.mod._split_id("dev:midi"), ("dev", None))
10161016
self.assertEqual(self.mod._split_id("host:12"), ("host", 12))
1017+
1018+
1019+
def _uac_blob(rates=(48000,), channels=1, bits=16, ep=0x81, attrs=0x05,
1020+
max_packet=192, extra_alt=None):
1021+
"""A realistic UAC 1.0 configuration blob.
1022+
1023+
Assembled byte by byte rather than captured, so the test exercises the
1024+
real descriptor layout including the parts a naive reader gets wrong: the
1025+
three-byte sample rates, the audio endpoint's 9-byte form, and alt 0
1026+
existing with no endpoint at all.
1027+
"""
1028+
def itf(number, alt, n_eps, subclass):
1029+
return bytes([9, 0x04, number, alt, n_eps, 0x01, subclass, 0x00, 0x00])
1030+
1031+
def rate_bytes(value):
1032+
return bytes([value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF])
1033+
1034+
parts = [bytes([9, 0x02, 0, 0, 2, 1, 0, 0x80, 50])] # CONFIGURATION
1035+
parts.append(itf(0, 0, 0, 0x01)) # AudioControl
1036+
parts.append(bytes([9, 0x24, 0x01, 0x00, 0x01, 9, 0, 1, 1])) # CS header
1037+
parts.append(itf(1, 0, 0, 0x02)) # AS alt 0: silent
1038+
parts.append(itf(1, 1, 1, 0x02)) # AS alt 1
1039+
parts.append(bytes([7, 0x24, AS_GENERAL_SUBTYPE, 0x02, 1, 0x01, 0x00]))
1040+
fmt = bytes([8 + 3 * len(rates), 0x24, 0x02, 0x01, channels,
1041+
bits // 8, bits, len(rates)])
1042+
for value in rates:
1043+
fmt += rate_bytes(value)
1044+
parts.append(fmt)
1045+
parts.append(bytes([9, 0x05, ep, attrs, max_packet & 0xFF,
1046+
(max_packet >> 8) & 0xFF, 0x01, 0x00, 0x00]))
1047+
if extra_alt:
1048+
parts.extend(extra_alt)
1049+
blob = b"".join(parts)
1050+
return blob[:2] + bytes([len(blob) & 0xFF, (len(blob) >> 8) & 0xFF]) + blob[4:]
1051+
1052+
1053+
AS_GENERAL_SUBTYPE = 0x01
1054+
1055+
1056+
class TestUacDescriptorParsing(unittest.TestCase):
1057+
"""UAC 1.0 descriptor reading -- how a host learns what a device can do.
1058+
1059+
A UAC device announces its formats in class-specific descriptors
1060+
interleaved between its interfaces, so choosing a format means walking the
1061+
whole configuration blob and then choosing an alternate setting. These
1062+
assertions cover the parts that fail quietly rather than loudly.
1063+
"""
1064+
1065+
def test_a_simple_microphone_parses(self):
1066+
from usbif import uac
1067+
1068+
found = uac.streams(_uac_blob())
1069+
self.assertEqual(len(found), 1)
1070+
stream = found[0]
1071+
self.assertEqual(stream.direction, uac.IN)
1072+
self.assertEqual(stream.rates, (48000,))
1073+
self.assertEqual(stream.channels, 1)
1074+
self.assertEqual(stream.bits, 16)
1075+
self.assertEqual(stream.endpoint, 0x81)
1076+
self.assertEqual(stream.max_packet, 192)
1077+
self.assertEqual(stream.sync, "async")
1078+
1079+
def test_sample_rates_are_three_byte_little_endian(self):
1080+
# A four-byte read here returns a plausible-looking wrong number
1081+
# rather than failing, which is the worst kind of parser bug.
1082+
from usbif import uac
1083+
1084+
(stream,) = uac.streams(_uac_blob(rates=(44100, 48000, 96000)))
1085+
self.assertEqual(stream.rates, (44100, 48000, 96000))
1086+
1087+
def test_alt_zero_is_not_offered_as_a_stream(self):
1088+
# By specification alt 0 has no endpoint and exists so a device can be
1089+
# configured while using no bandwidth. Offering it would be offering a
1090+
# format that is silent by design.
1091+
from usbif import uac
1092+
1093+
for stream in uac.streams(_uac_blob()):
1094+
self.assertNotEqual(stream.alt, 0)
1095+
1096+
def test_a_feedback_endpoint_is_not_mistaken_for_audio(self):
1097+
# Feedback endpoints carry rate corrections, not samples. Treating one
1098+
# as a stream would produce a device that "works" and is silent.
1099+
from usbif import uac
1100+
1101+
found = uac.streams(_uac_blob(ep=0x82, attrs=0x11)) # isoc | feedback
1102+
self.assertEqual(found, ())
1103+
1104+
def test_a_bulk_endpoint_is_ignored(self):
1105+
from usbif import uac
1106+
1107+
self.assertEqual(uac.streams(_uac_blob(attrs=0x02)), ())
1108+
1109+
def test_an_output_endpoint_is_reported_as_out(self):
1110+
from usbif import uac
1111+
1112+
(stream,) = uac.streams(_uac_blob(ep=0x02))
1113+
self.assertEqual(stream.direction, uac.OUT)
1114+
1115+
def test_has_audio_detects_a_streaming_interface(self):
1116+
from usbif import uac
1117+
1118+
self.assertTrue(uac.has_audio(_uac_blob()))
1119+
self.assertFalse(uac.has_audio(bytes([9, 0x02, 9, 0, 0, 1, 0, 0x80, 50])))
1120+
1121+
def test_choose_prefers_the_best_offer_when_asked_for_nothing(self):
1122+
from usbif import uac
1123+
1124+
found = uac.streams(_uac_blob(rates=(8000, 48000)))
1125+
picked = uac.choose(found, uac.IN)
1126+
self.assertEqual(max(picked.rates), 48000)
1127+
1128+
def test_choose_filters_on_what_the_caller_asked_for(self):
1129+
from usbif import uac
1130+
1131+
found = uac.streams(_uac_blob(rates=(44100, 48000)))
1132+
self.assertIsNotNone(uac.choose(found, uac.IN, rate=44100))
1133+
self.assertIsNone(uac.choose(found, uac.IN, rate=192000))
1134+
self.assertIsNone(uac.choose(found, uac.OUT))
1135+
1136+
def test_a_truncated_blob_stops_rather_than_looping(self):
1137+
# Real hardware returns short and padded descriptors. A parser that
1138+
# hangs on one is worse than a parser that stops early.
1139+
from usbif import uac
1140+
1141+
blob = _uac_blob()
1142+
self.assertEqual(uac.streams(blob[:len(blob) // 2]), ())
1143+
self.assertEqual(list(uac.descriptors(b"\x00\x00")), [])
1144+
self.assertEqual(list(uac.descriptors(b"")), [])
1145+
1146+
def test_describe_names_the_essentials(self):
1147+
from usbif import uac
1148+
1149+
(stream,) = uac.streams(_uac_blob())
1150+
text = uac.describe(stream)
1151+
for fragment in ("48000", "1ch", "16bit", "in"):
1152+
self.assertIn(fragment, text)

0 commit comments

Comments
 (0)