Merge usb-superspeed-hub: hubs work on real hardware (M22)

USB hub support, root-caused and validated on the user's real machine:
a Keychron keyboard and ROG mouse now enumerate through a Genesys
compound USB3 hub (its USB2 companion + transaction translators) and
type on danos.

Fixes found by reading logs off the stick, each invisible to QEMU:
- SuperSpeed hubs have change bits a USB2 hub lacks; not clearing them
  spun the driver. Clear them (gated on speed) + a per-tick service cap.
- The boot scan runs ~3 ms after the controller reset, too early for a
  USB2 connection to debounce; the SuperSpeed devices train instantly
  and hid the problem. Poll the root ports on the tick (edge-triggered).
- Full-speed devices failed Address Device with a USB Transaction Error:
  USB 2.0 requires a 10 ms reset-recovery interval before SET_ADDRESS.
  Wait it out, and retry Address Device on a transaction error.

Plus richer logs: readable PORTSC decode, USB link-state and speed
names, device string descriptors (maker/product), and a readable name
for every class/subclass/protocol enum in usb-ids. Full suite 92/92.

Open follow-up: 'port 11 enumeration failed' — a third device that did
not come up; to be investigated separately.
This commit is contained in:
Daniel Samson
2026-07-21 23:48:29 +01:00
4 changed files with 390 additions and 20 deletions
+235
View File
@@ -70,6 +70,104 @@ pub const Class = enum(u8) {
_,
};
/// A human-readable name for a device/interface class code, for logs. Unknown
/// codes fall through to "class 0xNN".
pub fn className(class: u8) []const u8 {
return switch (@as(Class, @enumFromInt(class))) {
.per_interface => "per-interface",
.audio => "Audio",
.communications => "Communications",
.hid => "HID",
.physical => "Physical",
.image => "Image",
.printer => "Printer",
.mass_storage => "Mass Storage",
.hub => "Hub",
.cdc_data => "CDC Data",
.smart_card => "Smart Card",
.content_security => "Content Security",
.video => "Video",
.personal_healthcare => "Personal Healthcare",
.audio_video => "Audio/Video",
.billboard => "Billboard",
.type_c_bridge => "Type-C Bridge",
.bulk_display => "Bulk Display",
.mctp => "MCTP",
.i3c => "I3C",
.diagnostic => "Diagnostic",
.wireless_controller => "Wireless Controller",
.miscellaneous => "Miscellaneous",
.application_specific => "Application-specific",
.vendor_specific => "Vendor-specific",
_ => "Unknown",
};
}
/// The USB speed class (as xHCI reports it in PORTSC/slot contexts) named.
pub fn speedName(speed: u32) []const u8 {
return switch (speed) {
1 => "Full-speed",
2 => "Low-speed",
3 => "High-speed",
4 => "SuperSpeed",
5 => "SuperSpeedPlus",
else => "unknown-speed",
};
}
/// A USB3 Port Link State (xHCI PORTSC PLS field) named.
pub fn linkStateName(pls: u32) []const u8 {
return switch (pls) {
0 => "U0",
1 => "U1",
2 => "U2",
3 => "U3-suspended",
4 => "Disabled",
5 => "RxDetect",
6 => "Inactive",
7 => "Polling",
8 => "Recovery",
9 => "HotReset",
10 => "Compliance",
11 => "Test",
15 => "Resume",
else => "reserved",
};
}
/// The most useful readable name for an interface's (class, subclass, protocol)
/// triple, decoding the well-known combinations recognizable in a log — e.g.
/// "HID boot keyboard", "Mass Storage SCSI Bulk-Only", "Bluetooth". Falls back
/// to the class name (and then "Unknown") for codes without a spelled-out combo.
pub fn interfaceName(class: u8, subclass: u8, protocol: u8) []const u8 {
return switch (@as(Class, @enumFromInt(class))) {
.hid => if (subclass == @intFromEnum(hid.SubClass.boot)) switch (@as(hid.Protocol, @enumFromInt(protocol))) {
.keyboard => "HID boot keyboard",
.mouse => "HID boot mouse",
else => "HID boot device",
} else "HID",
.mass_storage => switch (@as(mass_storage.Protocol, @enumFromInt(protocol))) {
.bulk_only => "Mass Storage (Bulk-Only)",
.uas => "Mass Storage (UAS)",
else => "Mass Storage",
},
.hub => switch (@as(hub.Protocol, @enumFromInt(protocol))) {
.super_speed => "Hub (SuperSpeed)",
.hi_speed_multi_tt => "Hub (Hi-Speed multi-TT)",
.hi_speed_single_tt => "Hub (Hi-Speed single-TT)",
else => "Hub",
},
.wireless_controller => if (subclass == @intFromEnum(wireless_controller.SubClass.radio_frequency))
wireless_controller.protocolName(protocol)
else
"Wireless Controller",
.communications => communications.subclassName(subclass),
.application_specific => application_specific.subclassName(subclass),
.miscellaneous => "Miscellaneous",
else => className(class),
};
}
// Subclass and protocol codes qualified by Class.hub. Hubs have no subclass codes; the
// protocol distinguishes the hub's transaction-translator arrangement.
pub const hub = struct {
@@ -84,6 +182,16 @@ pub const hub = struct {
super_speed = 0x03,
_,
};
pub fn protocolName(protocol: u8) []const u8 {
return switch (@as(Protocol, @enumFromInt(protocol))) {
.full_speed => "full-speed",
.hi_speed_single_tt => "Hi-Speed single-TT",
.hi_speed_multi_tt => "Hi-Speed multi-TT",
.super_speed => "SuperSpeed",
_ => "unknown",
};
}
};
// Subclass and protocol codes qualified by Class.hid.
@@ -104,6 +212,23 @@ pub const hid = struct {
mouse = 0x02,
_,
};
pub fn subclassName(subclass: u8) []const u8 {
return switch (@as(SubClass, @enumFromInt(subclass))) {
.none => "none",
.boot => "boot",
_ => "unknown",
};
}
pub fn protocolName(protocol: u8) []const u8 {
return switch (@as(Protocol, @enumFromInt(protocol))) {
.none => "none",
.keyboard => "keyboard",
.mouse => "mouse",
_ => "unknown",
};
}
};
// Subclass and protocol codes qualified by Class.mass_storage. The subclass identifies the
@@ -147,6 +272,33 @@ pub const mass_storage = struct {
vendor_specific = 0xFF,
_,
};
pub fn subclassName(subclass: u8) []const u8 {
return switch (@as(SubClass, @enumFromInt(subclass))) {
.not_reported => "SCSI (not reported)",
.rbc => "RBC",
.atapi => "ATAPI",
.qic_157 => "QIC-157",
.ufi => "UFI",
.sff_8070i => "SFF-8070i",
.scsi => "SCSI",
.lsd_fs => "LSD FS",
.ieee_1667 => "IEEE 1667",
.vendor_specific => "vendor-specific",
_ => "unknown",
};
}
pub fn protocolName(protocol: u8) []const u8 {
return switch (@as(Protocol, @enumFromInt(protocol))) {
.cbi_completion_interrupt => "CBI",
.cbi => "CBI (no completion IRQ)",
.bulk_only => "Bulk-Only",
.uas => "UAS",
.vendor_specific => "vendor-specific",
_ => "unknown",
};
}
};
// Subclass and protocol codes qualified by Class.communications (CDC). The protocol codes
@@ -182,6 +334,25 @@ pub const communications = struct {
network_control = 0x0D,
_,
};
pub fn subclassName(subclass: u8) []const u8 {
return switch (@as(SubClass, @enumFromInt(subclass))) {
.direct_line => "Direct Line",
.abstract_control => "Abstract Control (modem/serial)",
.telephone => "Telephone",
.multi_channel => "Multi-Channel",
.capi => "CAPI",
.ethernet => "Ethernet",
.atm => "ATM",
.wireless_handset => "Wireless Handset",
.device_management => "Device Management",
.mobile_direct_line => "Mobile Direct Line",
.obex => "OBEX",
.ethernet_emulation => "Ethernet Emulation",
.network_control => "Network Control",
_ => "unknown",
};
}
};
// Subclass and protocol codes qualified by Class.wireless_controller.
@@ -204,6 +375,23 @@ pub const wireless_controller = struct {
bluetooth_amp = 0x04,
_,
};
pub fn subclassName(subclass: u8) []const u8 {
return switch (@as(SubClass, @enumFromInt(subclass))) {
.radio_frequency => "RF",
_ => "unknown",
};
}
pub fn protocolName(protocol: u8) []const u8 {
return switch (@as(Protocol, @enumFromInt(protocol))) {
.bluetooth => "Bluetooth",
.ultra_wideband => "Ultra-Wideband",
.remote_ndis => "Remote NDIS",
.bluetooth_amp => "Bluetooth AMP",
_ => "unknown",
};
}
};
// Subclass and protocol codes qualified by Class.miscellaneous.
@@ -221,6 +409,20 @@ pub const miscellaneous = struct {
interface_association = 0x01,
_,
};
pub fn subclassName(subclass: u8) []const u8 {
return switch (@as(SubClass, @enumFromInt(subclass))) {
.common => "common",
_ => "unknown",
};
}
pub fn protocolName(protocol: u8) []const u8 {
return switch (@as(Protocol, @enumFromInt(protocol))) {
.interface_association => "Interface Association",
_ => "unknown",
};
}
};
// Subclass and protocol codes qualified by Class.application_specific.
@@ -234,6 +436,15 @@ pub const application_specific = struct {
test_and_measurement = 0x03,
_,
};
pub fn subclassName(subclass: u8) []const u8 {
return switch (@as(SubClass, @enumFromInt(subclass))) {
.firmware_upgrade => "Device Firmware Upgrade",
.irda_bridge => "IrDA Bridge",
.test_and_measurement => "Test & Measurement",
_ => "unknown",
};
}
};
/// Pack a (class, subclass, protocol) triple into one 0xCCSSPP value — the
@@ -280,6 +491,30 @@ test "class codes match the USB-IF assignments" {
_ = application_specific.SubClass.firmware_upgrade;
}
test "readable names decode the well-known triples" {
const std = @import("std");
const eql = std.testing.expectEqualStrings;
try eql("Hub", className(0x09));
try eql("Unknown", className(0x42));
// interfaceName decodes the combos we log.
try eql("HID boot keyboard", interfaceName(0x03, 0x01, 0x01));
try eql("HID boot mouse", interfaceName(0x03, 0x01, 0x02));
try eql("Mass Storage (Bulk-Only)", interfaceName(0x08, 0x06, 0x50));
try eql("Hub (SuperSpeed)", interfaceName(0x09, 0x00, 0x03));
try eql("Bluetooth", interfaceName(0xE0, 0x01, 0x01));
// The per-enum name functions.
try eql("Bulk-Only", mass_storage.protocolName(0x50));
try eql("SCSI", mass_storage.subclassName(0x06));
try eql("Bluetooth", wireless_controller.protocolName(0x01));
try eql("SuperSpeed", hub.protocolName(0x03));
try eql("keyboard", hid.protocolName(0x01));
try eql("SuperSpeed", speedName(4));
try eql("Polling", linkStateName(7));
}
test "packTriple / unpackTriple round-trip the identity a bus driver reports" {
const std = @import("std");
const expectEqual = std.testing.expectEqual;
+55 -6
View File
@@ -189,6 +189,10 @@ fn speedName(speed: u32) []const u8 {
/// class driver against.
var manager_handle: ?runtime.ipc.Handle = null;
// Per-root-port connected state from the previous tick, so the poll acts on
// empty->connected transitions (edge), never re-attempting a level every tick.
var prev_connected: [64]bool = [_]bool{false} ** 64;
fn scanPorts(manager: runtime.ipc.Handle) void {
const engine = if (controller) |*c| c else {
_ = runtime.system.write("/system/drivers/usb-xhci-bus: controller not initialised\n");
@@ -200,10 +204,14 @@ fn scanPorts(manager: runtime.ipc.Handle) void {
var connected: u32 = 0;
while (port <= engine.max_ports) : (port += 1) {
if (!engine.portConnected(port)) continue;
if (port < prev_connected.len) prev_connected[port] = true; // don't re-fire the poll for these
connected += 1;
bringUpPort(manager, engine, port);
}
if (connected == 0) _ = runtime.system.write("/system/drivers/usb-xhci-bus: no devices connected\n");
if (connected == 0) {
_ = runtime.system.write("/system/drivers/usb-xhci-bus: no devices connected\n");
engine.dumpPortTopology(); // help diagnose an empty scan: the xECP map + raw PORTSC
}
}
/// Bring up whatever is on `port`: setup + enumerate + register/report one child
@@ -221,8 +229,15 @@ fn bringUpPort(manager: runtime.ipc.Handle, engine: *library.Controller, port: u
std.log.info("port {d} enumeration failed", .{port});
return;
}
std.log.info("port {d} device vendor 0x{x:0>4} product 0x{x:0>4}, {d} interface(s)", .{
var maker_buffer: [64]u8 = undefined;
var product_buffer: [64]u8 = undefined;
const maker = engine.readString(usb_device, @intFromEnum(usb_device.device_descriptor.manufacturer_index), &maker_buffer) orelse "?";
const product = engine.readString(usb_device, @intFromEnum(usb_device.device_descriptor.product_index), &product_buffer) orelse "?";
std.log.info("port {d} device: {s} \"{s} {s}\" (0x{x:0>4}:0x{x:0>4}), {d} interface(s)", .{
port,
usb_ids.className(usb_device.device_descriptor.device_class),
maker,
product,
usb_device.device_descriptor.vendor_id,
usb_device.device_descriptor.product_id,
usb_device.interface_count,
@@ -256,8 +271,9 @@ fn hubPortKey(hub_slot: u8, port: u16) u32 {
fn bringUpBehindHub(manager: runtime.ipc.Handle, engine: *library.Controller, hub: *library.Device, port: u16) void {
const status = engine.hubPortStatusAck(hub, port) orelse return;
const connected = library.Controller.hubPortConnected(status);
std.log.info("hub slot {d} port {d} status 0x{x:0>8} ({s})", .{ hub.slot_id, port, status, if (connected) "connected" else "empty" });
const existing = engine.deviceOnHubPort(hub, port);
if (connected != (existing != null)) // only when a device appears or leaves — not empty seed-sweep ports
std.log.info("hub slot {d} port {d}: {s} (status 0x{x:0>4})", .{ hub.slot_id, port, if (connected) "device connected" else "device removed", status & 0xFFFF });
if (!connected) {
if (existing) |dev| tearDownHubDevice(manager, engine, dev);
@@ -270,8 +286,15 @@ fn bringUpBehindHub(manager: runtime.ipc.Handle, engine: *library.Controller, hu
std.log.info("hub slot {d} port {d}: enumeration failed", .{ hub.slot_id, port });
return;
}
std.log.info("hub slot {d} port {d} device vendor 0x{x:0>4} product 0x{x:0>4}, {d} interface(s)", .{
var maker_buffer: [64]u8 = undefined;
var product_buffer: [64]u8 = undefined;
const maker = engine.readString(usb_device, @intFromEnum(usb_device.device_descriptor.manufacturer_index), &maker_buffer) orelse "?";
const product = engine.readString(usb_device, @intFromEnum(usb_device.device_descriptor.product_index), &product_buffer) orelse "?";
std.log.info("hub slot {d} port {d} device: {s} \"{s} {s}\" (0x{x:0>4}:0x{x:0>4}), {d} interface(s)", .{
hub.slot_id, port,
usb_ids.className(usb_device.device_descriptor.device_class),
maker,
product,
usb_device.device_descriptor.vendor_id,
usb_device.device_descriptor.product_id,
usb_device.interface_count,
@@ -378,9 +401,10 @@ fn reportInterface(manager: runtime.ipc.Handle, port: u32, interface: library.In
std.log.info("child report for port {d} interface {d} failed", .{ port, interface.number });
return null;
};
std.log.info("port {d} interface {d} class {d}/{d}/{d} registered as device {d}", .{
std.log.info("port {d} interface {d}: {s} ({d}/{d}/{d}) registered as device {d}", .{
port,
interface.number,
usb_ids.interfaceName(interface.class, interface.subclass, interface.protocol),
interface.class,
interface.subclass,
interface.protocol,
@@ -491,9 +515,34 @@ fn onNotification(badge: u64) void {
if (badge & runtime.ipc.notify_timer_bit == 0) return;
if (controller) |*engine| {
engine.pump();
// Poll every root port and reconcile — a device present but not yet
// enumerated is brought up; a device gone is torn down. This does NOT
// depend on a Port Status Change EVENT firing: the boot scan runs ~3 ms
// after the controller reset, far too early for a USB2 connection to
// debounce (~100 ms), and the SuperSpeed devices that DO show up early
// proved the event path unreliable for the late USB2 companion hub on
// real hardware. Polling catches it on the next tick regardless.
if (manager_handle) |manager| {
var port: u32 = 1;
while (port <= engine.max_ports and port <= prev_connected.len) : (port += 1) {
const connected = engine.portConnected(port);
const was = prev_connected[port];
prev_connected[port] = connected;
if (connected and !was and engine.deviceOnPort(port) == null) {
// Rising edge the boot scan missed (it ran before the USB2
// connection debounced): bring the device up now.
std.log.info("root port {d}: device appeared (PORTSC 0x{x:0>8})", .{ port, engine.portStatus(port) });
bringUpPort(manager, engine, port);
} else if (!connected and was and engine.deviceOnPort(port) != null) {
tearDownPort(manager, engine, port);
}
}
}
while (engine.takePortChange()) |port| {
const manager = manager_handle orelse break;
if (engine.portConnected(port)) {
const connected = engine.portConnected(port);
std.log.info("root port {d} change: {s} (PORTSC 0x{x:0>8})", .{ port, if (connected) "connected" else "empty", engine.portStatus(port) });
if (connected) {
if (engine.deviceOnPort(port) == null) bringUpPort(manager, engine, port);
} else {
tearDownPort(manager, engine, port);
@@ -22,6 +22,7 @@ const std = @import("std");
const runtime = @import("runtime");
const mmio = @import("mmio");
const usb_abi = @import("usb-abi");
const usb_ids = @import("usb-ids");
const dma = runtime.dma;
const system = runtime.system;
@@ -100,6 +101,7 @@ pub const TrbType = enum(u6) {
pub const CompletionCode = enum(u8) {
invalid = 0,
success = 1,
usb_transaction_error = 4,
short_packet = 13,
_,
};
@@ -484,6 +486,73 @@ pub const Controller = struct {
}
// PORTSC for 1-based port `port`.
/// Diagnostic: walk the xECP list and log each Supported Protocol capability
/// (USB 2.0 vs 3.x, the compatible root-port range), then dump every port's
/// raw PORTSC. Reveals where the USB2 root ports are and their state — for
/// finding a USB2 companion hub that isn't presenting a connection.
pub fn dumpPortTopology(self: *const Controller) void {
const hccparams1 = read32(self.register_base + cap_hccparams1);
var offset: usize = (hccparams1 >> 16) & 0xFFFF; // xECP: dword offset from register_base
var guard: u32 = 0;
while (offset != 0 and guard < 64) : (guard += 1) {
const cap_base = self.register_base + offset * 4;
const dw0 = read32(cap_base);
const id = dw0 & 0xFF;
if (id == 2) { // Supported Protocol
const dw2 = read32(cap_base + 8);
const major = (dw0 >> 24) & 0xFF;
const minor = (dw0 >> 16) & 0xFF;
const port_offset = dw2 & 0xFF;
const port_count = (dw2 >> 8) & 0xFF;
std.log.info("xECP USB {d}.{d}: root ports {d}..{d}", .{ major, minor, port_offset, port_offset + port_count - 1 });
}
const next = (dw0 >> 8) & 0xFF;
if (next == 0) break;
offset += next;
}
var port: u32 = 1;
while (port <= self.max_ports) : (port += 1) {
const portsc = self.portStatus(port);
std.log.info("PORTSC[{d}] 0x{x:0>8}: {s}, {s}, link={s}, power={s}, {s}", .{
port,
portsc,
if (portsc & 1 != 0) "connected" else "empty",
if (portsc & 2 != 0) "enabled" else "disabled",
usb_ids.linkStateName((portsc >> 5) & 0xF),
if (portsc & (1 << 9) != 0) "on" else "off",
usb_ids.speedName((portsc >> 10) & 0xF),
});
}
}
/// Read USB STRING descriptor `index` (English, langid 0x0409) into `out` as
/// ASCII, returning the slice — for logging manufacturer/product names.
/// Null for index 0 (no string) or a failed transfer. Non-ASCII code units
/// become '?'.
pub fn readString(self: *Controller, device: *Device, index: u8, out: []u8) ?[]const u8 {
if (index == 0) return null;
var raw: [256]u8 = undefined;
const request = usb_abi.Request{
.request_type = .{ .recipient = .device, .kind = .standard, .direction = .device_to_host },
.request_code = .get_descriptor,
.value = (@as(u16, 3) << 8) | index, // STRING descriptor
.index = 0x0409, // English (US)
.length = raw.len,
};
if (!self.controlTransfer(device, request, raw[0..], true)) return null;
const length = raw[0]; // bLength; the UTF-16LE payload is bytes 2..length
if (length < 2) return null;
const chars = (@min(length, raw.len) - 2) / 2;
var n: usize = 0;
var i: usize = 0;
while (i < chars and n < out.len) : (i += 1) {
const unit = @as(u16, raw[2 + i * 2]) | (@as(u16, raw[2 + i * 2 + 1]) << 8);
out[n] = if (unit >= 0x20 and unit < 0x7F) @intCast(unit) else '?';
n += 1;
}
return out[0..n];
}
pub fn portStatus(self: *const Controller, port: u32) u32 {
return read32(self.op_base + op_portsc_base + op_portsc_stride * (port - 1));
}
@@ -768,19 +837,31 @@ pub const Controller = struct {
}
fn addressDeviceCommand(self: *Controller, device: *Device) bool {
const physical = self.submitCommand(.{
.parameter = device.input_context.physical,
.control = trbControl(.address_device, @as(u32, device.slot_id) << 24),
});
const code = self.awaitCommand(physical) orelse {
std.log.info("port {d} setup: Address Device timed out", .{device.port});
return false;
};
if (code != @intFromEnum(CompletionCode.success)) {
std.log.info("port {d} setup: Address Device completion code {d}", .{ device.port, code });
return false;
// Retry on a USB Transaction Error (code 4): a freshly-reset device can
// miss the first SET_ADDRESS; re-reset the port and try again (xHCI
// 4.6.5). Up to 3 attempts.
var attempt: u32 = 0;
while (attempt < 3) : (attempt += 1) {
const physical = self.submitCommand(.{
.parameter = device.input_context.physical,
.control = trbControl(.address_device, @as(u32, device.slot_id) << 24),
});
const code = self.awaitCommand(physical) orelse {
std.log.info("port {d} setup: Address Device timed out (attempt {d})", .{ device.port, attempt + 1 });
return false;
};
if (code == @intFromEnum(CompletionCode.success)) return true;
std.log.info("port {d} setup: Address Device completion code {d} (attempt {d})", .{ device.port, code, attempt + 1 });
if (code != @intFromEnum(CompletionCode.usb_transaction_error)) return false;
// Re-reset a root-port device and wait the recovery interval before
// retrying. (A device behind a hub is reset through the hub — not
// retried here; its port was reset in serviceHubPort.)
if (device.parent_slot == 0) {
if (!self.resetPort(device.port)) return false;
system.sleep(10);
} else return false;
}
return true;
return false;
}
/// Reset the port, enable a slot, and address the device on it: after this the
@@ -806,6 +887,11 @@ pub const Controller = struct {
// (pre-reset reads misreport on real controllers; M20).
effective_speed = (self.portStatus(port) >> 10) & 0xF;
if (effective_speed == 0) effective_speed = speed; // defensive: keep the caller's read
// USB 2.0 spec 7.1.7.5: a device needs a reset-recovery interval
// (TRSTRCY, 10 ms) after reset before it answers SET_ADDRESS.
// Addressing immediately gives a USB Transaction Error (code 4) on
// real full-speed devices; QEMU tolerates the omission.
system.sleep(10);
}
const slot_id = self.enableSlot() orelse {
std.log.info("port {d} setup: Enable Slot failed", .{port});
+2 -2
View File
@@ -494,7 +494,7 @@ CASES = [
# B4a: the hub powers its ports. B4b: the keyboard behind it enumerates
# (route string + TT) and binds usb-hid-keyboard.
"expect": r"(?s)(?=.*hub slot \d+: \d+ downstream ports powered)"
r"(?=.*hub slot \d+ port \d+ device vendor 0x0627)"
r"(?=.*hub slot \d+ port \d+ device:.*0x0627)"
r"(?=.*usb-hid-keyboard: ok \(device 3)",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# USB mass storage end to end: the boot usb-storage device (the FAT32 image,
@@ -676,7 +676,7 @@ CASES = [
"-device", "usb-hub,bus=xhci2.0,port=1",
"-device", "usb-hub,bus=xhci2.0,port=1.1",
"-device", "usb-kbd,bus=xhci2.0,port=1.1.1"],
"expect": r"(?s)(?=.*hub slot \d+ port \d+ device vendor 0x0409)"
"expect": r"(?s)(?=.*hub slot \d+ port \d+ device:.*0x0409)"
r"(?=.*usb-hid-keyboard: ok \(device 3)",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# Hub-downstream disconnect (B4c): device_del the keyboard behind the hub;