diff --git a/system/devices/usb-ids.zig b/system/devices/usb-ids.zig index 470fc95..1ebedda 100644 --- a/system/devices/usb-ids.zig +++ b/system/devices/usb-ids.zig @@ -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; diff --git a/system/drivers/usb-xhci-bus/usb-xhci-bus.zig b/system/drivers/usb-xhci-bus/usb-xhci-bus.zig index 339e646..fae9393 100644 --- a/system/drivers/usb-xhci-bus/usb-xhci-bus.zig +++ b/system/drivers/usb-xhci-bus/usb-xhci-bus.zig @@ -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); diff --git a/system/drivers/usb-xhci-bus/usb-xhci-library.zig b/system/drivers/usb-xhci-bus/usb-xhci-library.zig index 3dc3ba8..656ca98 100644 --- a/system/drivers/usb-xhci-bus/usb-xhci-library.zig +++ b/system/drivers/usb-xhci-bus/usb-xhci-library.zig @@ -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}); diff --git a/test/qemu_test.py b/test/qemu_test.py index e321ec8..5fb6f9f 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -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;