diff --git a/README.md b/README.md index db0d24a..565f9e8 100644 --- a/README.md +++ b/README.md @@ -717,7 +717,7 @@ tracking. Changes to upstream require an explicit pin/manifest update; native protocol adaptations belong in the separate adapter. -Both native bridge sources support the existing software **BOOTSEL reboot** +Native bridge builds support the existing software **BOOTSEL reboot** without erasing pairings, profiles or configuration. The standalone USB diagnostic probe does not. Connect the bridge to a PC and disconnect any genuine USB Joy-Con 2 before running this from the repository: @@ -738,18 +738,18 @@ PY USB access requires permission to the matching `/dev/bus/usb` device. With multiple bridges, select the intended PyUSB device by its `bus` and `address` -instead of sending to every matching device. The ordinary -`switch-pico-config reboot bootsel` CLI uses management discovery, which this -native USB identity deliberately does not expose; the direct helper above needs -no discovery request or interface claim. +instead of sending to every matching device. Standalone native identities still +require the direct helper above, with no discovery request or interface claim. +Native **hub** builds from 0.72 expose management on their `057e:2068` root, so +the ordinary `switch-pico-config reboot bootsel` CLI and profile editor work there. -Only vendor-device OUT `0x40`, request `0x04`, value `0x5350`, index `1`, with the -existing validated 16-byte management envelope is accepted. The shared handler -validates and dispatches at the control status ACK, then the existing 50 ms guard -enters ROM USB boot mode. Invalid envelopes cannot schedule a reboot. Nintendo's -separate request `0x04`, value `0x0276`, index `0`, length `0` remains an ordinary -setup acknowledgement. No configuration writes or extra management capabilities -are enabled in native mode. +The standalone bridge's only management command is vendor-device OUT `0x40`, +request `0x04`, value `0x5350`, index `1`, with the validated 16-byte envelope. +Reboot is scheduled only after its control status ACK, followed by the existing +50 ms guard. Invalid envelopes cannot schedule it. Nintendo's separate request +`0x04`, value `0x0276`, index `0`, length `0` remains an ordinary setup +acknowledgement. Hub 0.72 additionally exposes the existing profile/configuration +management protocol on the root only; native child identities remain separate. ### Experimental stock-socket native Joy-Con 2 hub @@ -948,6 +948,43 @@ byte-for-byte unchanged. The hub and both native children enumerated, and UART confirmed the nonblocking policy. Physical Wii startup/drift qualification is still pending. +**Native hub profile editor (0.72):** connect the Pico's built-in USB socket to +the computer, then run: + +```sh +uv run switch-pico-config profiles edit +``` + +The local editor runs at `http://127.0.0.1:8765/`. Save the profile before moving +the USB cable back to the Switch. The editor discovers only the hub root +`057e:2068`, validates its management response, and does not mistake the two +native children for extra adapters. Linux access is covered by the updated +`udev/99-switch-pico.rules`. USB output remains fixed to the native hub; ordinary +output-mode switching/reboot-to-mode is unavailable. + +Select the Wii profile owner and its active profile. With the Nunchuk connected, +Auto uses its live layout; while offline, choose **Preview ยท Wii Remote + Nunchuk**. +The physical Nunchuk **C** is logical `west` and **Z** is logical `north`, not the +unrelated Switch2 extra control named `c`. Both can target buttons or triggers. +For example, **Z โ†’ L**, **C โ†’ ZL**, with **Remote 2 โ†’ R**, makes **Z + 2** the +physical L+R combination. Save changes to that Wii profile, not the global +default or another controller's profile. + +This fixes the omitted editor integration: earlier hub builds accepted only +the private BOOTSEL management command, and host discovery excluded their root. +The existing profile service and storage transactions are reused. Root requests +cannot borrow child EP0 buffers; aborted/short/corrupt transfers and reset-stale +status completions cannot dispatch profile writes. Valid status ACKs preceding +a subsequent SETUP remain valid. + +Qualification: 390 focused tests pass. The actual browser editor saved the +Wii C/Z example and read it back after a Pico reboot. All 80 stored profiles +were compared: only the two intended mappings changed; the other 79 profiles, +metadata and active selections were unchanged. The configuration/pairing flash +region matched the pre-update backup. Native R/L descriptors, EP0 identity, +initialization and bulk-isolation checks passed while editor traffic was active. +No physical Switch L+R button press was claimed by that transport check. + For sensorless hardware, the checker supports `--input-only`: press real buttons and keep changing controls on both halves during the run. Neutral fallback alone cannot qualify. The result explicitly records that IMU was not required; diff --git a/src/firmware/usb/native_hub/native_hub.c b/src/firmware/usb/native_hub/native_hub.c index 96f7ba4..5225a9d 100644 --- a/src/firmware/usb/native_hub/native_hub.c +++ b/src/firmware/usb/native_hub/native_hub.c @@ -65,6 +65,7 @@ typedef struct { endpoint_t ep[CHANNELS]; control_t control; uint32_t generation; + uint32_t reset_generation; // Control SETUP aborts only EP0, never unrelated HID/vendor completions. uint32_t endpoint_generation[CHANNELS]; uint8_t configuration, idle_rate, protocol; @@ -74,6 +75,7 @@ typedef struct { uint8_t device, channel, kind; uint16_t length; uint32_t generation; + uint32_t reset_generation; uint8_t data[64]; } event_t; @@ -266,6 +268,7 @@ static __force_inline bool push_event(uint8_t device, uint8_t channel, uint8_t k event->length = length; event->generation = device < DEVICES ? (channel < 2 ? devices[device].generation : devices[device].endpoint_generation[channel]) : 0; + event->reset_generation = device < DEVICES ? devices[device].reset_generation : 0; if (kind == 2 && (channel & 1u) && channel != 1) copy_from_usb(event->data,data,length); else if (length) memcpy(event->data,data,length); __dmb(); event_head = next; @@ -279,6 +282,7 @@ static void __not_in_flash_func(usb_interrupt)(void) { // Reset wins over stale transfers and setup snapshots. for (uint8_t i = 0; i < DEVICES; ++i) { ++devices[i].generation; + ++devices[i].reset_generation; for (unsigned ch = 2; ch < CHANNELS; ++ch) ++devices[i].endpoint_generation[ch]; } for (unsigned i = 0; i < CHANNELS; ++i) buffer_regs()[i] = 0; @@ -432,7 +436,8 @@ bool native_hub_control_xfer(uint8_t slot, const tusb_control_request_t* request void* buffer, uint16_t length) { if (slot >= DEVICES || request == NULL || (length && buffer == NULL)) return false; control_t* c = &devices[slot].control; - if (memcmp(request,&c->request,sizeof(*request)) != 0) return false; + if (c->generation != devices[slot].generation || + memcmp(request,&c->request,sizeof(*request)) != 0) return false; if (request->bmRequestType & 0x80) reply(slot,buffer,length); else if (!request->wLength) status_in(slot,NO_ACTION); else { @@ -444,6 +449,9 @@ bool native_hub_control_xfer(uint8_t slot, const tusb_control_request_t* request } bool native_hub_control_status(uint8_t slot, const tusb_control_request_t* request) { if (slot >= DEVICES || request == NULL || request->wLength) return false; + control_t* c = &devices[slot].control; + if (c->generation != devices[slot].generation || + memcmp(request,&c->request,sizeof(*request)) != 0) return false; if (request->bmRequestType & 0x80) { devices[slot].control.stage = STATUS_OUT; arm_packet(slot,1,NULL,0); } else status_in(slot,NO_ACTION); @@ -453,6 +461,7 @@ bool native_hub_control_status(uint8_t slot, const tusb_control_request_t* reque static void reset_device(uint8_t slot) { uint32_t flags = spin_lock_blocking(bank_lock); ++devices[slot].generation; + ++devices[slot].reset_generation; for (unsigned ch = 2; ch < CHANNELS; ++ch) ++devices[slot].endpoint_generation[ch]; memset(devices[slot].buffers,0,sizeof(devices[slot].buffers)); memset(devices[slot].endpoint_controls,0,sizeof(devices[slot].endpoint_controls)); @@ -720,6 +729,7 @@ static void transmit_next(uint8_t slot, uint8_t channel) { static void transfer_complete(const event_t* event) { uint8_t slot = event->device, channel = event->channel; device_t* d = &devices[slot]; + if (event->reset_generation != d->reset_generation) return; if (channel >= 2 && event->generation != d->endpoint_generation[channel]) return; if (channel < 2) { control_t* c = &d->control; @@ -727,9 +737,19 @@ static void transfer_complete(const event_t* event) { probe_debug_printf("[HUB_CTRL] complete g=%" PRIu32 "/%" PRIu32 " ch=%u len=%u expected=%u state=%u\n", event->generation, c->generation, channel, event->length, c->packet_length, (unsigned)c->stage); + // Preserve a real status ACK queued before the next SETUP. Unlike + // SETUP, reset invalidates even these queued completions (above). if (event->generation != c->generation) return; if ((c->stage == STATUS_IN && channel == 0) || (c->stage == STATUS_OUT && channel == 1)) { - if (event->length) stall(slot); else control_complete(slot); + if (event->length) stall(slot); + else { + // Do not let a reset IRQ revoke ownership between checking it + // and publishing the acknowledged service transaction. + uint32_t flags = save_and_disable_interrupts(); + if (event->reset_generation == d->reset_generation) + control_complete(slot); + restore_interrupts(flags); + } } else if (c->stage == DATA_IN && channel == 0) { if (event->generation != d->generation) return; if (event->length != c->packet_length) { stall(slot); return; } diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index 058e578..5a3c70c 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -20,6 +20,9 @@ #endif #include "tusb.h" #include "usb/usb_output_driver.h" +#if SWITCH2_PROBE_HUB +#include "usb/native_hub/native_hub.h" +#endif namespace UsbConfigurationManagement { namespace { @@ -188,9 +191,16 @@ size_t encode_transaction(uint8_t* output, size_t output_size) { size_t encode_info(uint8_t* output, size_t output_size) { uint8_t payload[8] = { +#if SWITCH2_PROBE_HUB + 0, 72, 0, 2, + kNativeHubActiveMode, + USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE | + USB_OUTPUT_CAPABILITY_MOTION, +#else 0, 2, 0, 2, static_cast(usb_output_driver_mode()), usb_output_driver_capabilities(), +#endif static_cast(CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE), static_cast( CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE >> 8), @@ -696,6 +706,21 @@ UsbConfigurationManagement::Operation g_pending_operation = bool g_out_pending = false; bool g_out_processed = false; size_t g_pending_request_size = 0; +uint8_t g_pending_rhport = 0; +tusb_control_request_t g_pending_setup{}; +#if SWITCH2_PROBE_HUB +bool g_out_validated = false; +#endif + +bool management_control_xfer(uint8_t rhport, + const tusb_control_request_t* request, + void* buffer, uint16_t length) { +#if SWITCH2_PROBE_HUB + return native_hub_control_xfer(rhport, request, buffer, length); +#else + return tud_control_xfer(rhport, request, buffer, length); +#endif +} bool process_out_request() { using namespace UsbConfigurationManagement; @@ -981,14 +1006,24 @@ bool process_out_request() { bool usb_configuration_management_vendor_control( uint8_t rhport, uint8_t stage, tusb_control_request_t const* request) { +#if SWITCH2_PROBE_HUB + // Native children have independent EP0 protocols and must never touch the + // root management buffers, even to abort a pending root request. + if (rhport != 0) return false; +#endif if (stage == CONTROL_STAGE_SETUP) { g_out_pending = false; g_out_processed = false; g_pending_request_size = 0; +#if SWITCH2_PROBE_HUB + g_out_validated = false; +#endif } +#if !SWITCH2_PROBE_HUB if (adapter_host_probe_vendor_control(rhport, stage, request)) { return true; } +#endif using namespace UsbConfigurationManagement; if (request == nullptr || request->bmRequestType_bit.type != TUSB_REQ_TYPE_VENDOR || @@ -1000,6 +1035,12 @@ bool usb_configuration_management_vendor_control( const Operation operation = static_cast(request->bRequest); +#if SWITCH2_PROBE_HUB + // This image has a fixed native output. BOOTSEL uses the probe's private, + // independently validated path so its status ACK retains the reboot delay. + if (operation == Operation::kModeSet || operation == Operation::kReboot || + operation == Operation::kBootselReboot) return false; +#endif if ((operation == Operation::kHapticsTransportProbe || operation == Operation::kSwitch2MouseCapture || operation == Operation::kWiiIrGyro) && @@ -1010,16 +1051,32 @@ bool usb_configuration_management_vendor_control( if (request->bmRequestType_bit.direction == TUSB_DIR_IN) { return true; } - if (!g_out_pending || operation != g_pending_operation) { + if (!g_out_pending || rhport != g_pending_rhport || + memcmp(request, &g_pending_setup, sizeof(*request)) != 0 || + operation != g_pending_operation) { return false; } g_out_pending = false; +#if SWITCH2_PROBE_HUB + if (!g_out_validated) return false; + g_out_validated = false; +#endif if (operation == Operation::kHapticsExperiment) { return g_out_processed; } return process_out_request(); } if (stage == CONTROL_STAGE_DATA) { +#if SWITCH2_PROBE_HUB + if (request->bmRequestType_bit.direction == TUSB_DIR_OUT) { + DecodedRequest decoded{}; + g_out_validated = g_out_pending && rhport == g_pending_rhport && + memcmp(request, &g_pending_setup, sizeof(*request)) == 0 && + decode_request(operation, g_request_buffer, + g_pending_request_size, &decoded); + if (!g_out_validated) return false; + } +#endif if (operation == Operation::kHapticsExperiment && request->bmRequestType_bit.direction == TUSB_DIR_OUT) { if (!g_out_pending || operation != g_pending_operation || @@ -1042,10 +1099,12 @@ bool usb_configuration_management_vendor_control( } g_pending_operation = operation; g_pending_request_size = request->wLength; - g_out_pending = true; + g_pending_rhport = rhport; + g_pending_setup = *request; + g_out_pending = management_control_xfer( + rhport, request, g_request_buffer, request->wLength); g_out_processed = false; - return tud_control_xfer(rhport, request, g_request_buffer, - request->wLength); + return g_out_pending; } static uint8_t response[kMaximumResponseSize]{}; @@ -1149,7 +1208,7 @@ bool usb_configuration_management_vendor_control( return false; } return response_size != 0 && - tud_control_xfer( + management_control_xfer( rhport, request, response, static_cast(response_size)); } diff --git a/src/firmware/usb/usb_configuration_management.h b/src/firmware/usb/usb_configuration_management.h index aec6c7f..35d745f 100644 --- a/src/firmware/usb/usb_configuration_management.h +++ b/src/firmware/usb/usb_configuration_management.h @@ -14,6 +14,8 @@ namespace UsbConfigurationManagement { constexpr uint16_t kRequestValue = 0x5350; constexpr uint16_t kRequestIndex = 0x0001; constexpr uint8_t kProtocolVersion = 1; +// INFO-only active mode; never persisted as an AdapterRequestedMode. +constexpr uint8_t kNativeHubActiveMode = 5; constexpr size_t kRequestHeaderSize = 16; constexpr size_t kResponseHeaderSize = 20; constexpr size_t kPairingRecordSize = 8; diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 7f7eb54..1dad6e4 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -21,6 +21,7 @@ import usb.core USB_IDENTITIES = ( (0x057E, 0x2009), + (0x057E, 0x2068), # Native hub root only; its Joy-Con children are not adapters. (0xCAFE, 0x4010), (0xCAFE, 0x4020), (0xCAFE, 0x4021), @@ -125,7 +126,15 @@ ACTIVE_MODE_SWITCH_PROBE = 1 ACTIVE_MODE_XINPUT = 2 ACTIVE_MODE_DINPUT = 3 ACTIVE_MODE_MAC = 4 -ACTIVE_MODE_NAMES = ("Switch", "Switch probe", "XInput", "DInput", "Mac") +ACTIVE_MODE_NATIVE_HUB = 5 +ACTIVE_MODE_NAMES = ( + "Switch", + "Switch probe", + "XInput", + "DInput", + "Mac", + "Native Joy-Con hub", +) # USB management info byte 5 capability flags. CAPABILITY_INPUT = 1 << 0 CAPABILITY_RUMBLE = 1 << 1 @@ -638,7 +647,9 @@ class PairingRecord: def __post_init__(self) -> None: if self.transport not in (TRANSPORT_CLASSIC, TRANSPORT_BLE): - raise ConfigManagerError("pairing record must identify a physical Bluetooth peer") + raise ConfigManagerError( + "pairing record must identify a physical Bluetooth peer" + ) @property def address_text(self) -> str: @@ -724,7 +735,11 @@ def _control_index( ) -> int: if value is None: return PROFILE_NONE_BUTTON - controls = LOGICAL_CONTROLS if schema_version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION else OUTPUT_CONTROLS + controls = ( + LOGICAL_CONTROLS + if schema_version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION + else OUTPUT_CONTROLS + ) if type(value) is not str or value not in controls: choices = ", ".join(controls) raise ConfigManagerError(f"{name} must be null or one of: {choices}") @@ -796,7 +811,9 @@ class ControllerIdentity: raise ConfigManagerError("identity address must contain six bytes") _require_int(self.vendor_id, "identity vendor_id", 0, 0xFFFF) _require_int(self.product_id, "identity product_id", 0, 0xFFFF) - _require_int(self.partner_address_type, "identity partner_address_type", 0, 0xFF) + _require_int( + self.partner_address_type, "identity partner_address_type", 0, 0xFF + ) if type(self.partner_address) is not bytes or len(self.partner_address) != 6: raise ConfigManagerError("identity partner_address must contain six bytes") if self.is_joycon_pair: @@ -817,7 +834,9 @@ class ControllerIdentity: raise ConfigManagerError("invalid Joy-Con 2 pair identity") return if self.partner_address_type != 0 or self.partner_address != bytes(6): - raise ConfigManagerError("physical identity cannot contain a partner address") + raise ConfigManagerError( + "physical identity cannot contain a partner address" + ) if self.stable: if self.transport not in (TRANSPORT_CLASSIC, TRANSPORT_BLE): raise ConfigManagerError( @@ -855,8 +874,14 @@ class ControllerIdentity: "pair members must be stable BLE Joy-Con 2 (L) and (R) identities" ) return cls( - True, TRANSPORT_JOYCON_PAIR, left.address_type, left.address, - 0x057E, 0x2067, right.address_type, right.address, + True, + TRANSPORT_JOYCON_PAIR, + left.address_type, + left.address, + 0x057E, + 0x2067, + right.address_type, + right.address, ) def joycon_pair_members(self) -> tuple[ControllerIdentity, ControllerIdentity]: @@ -864,11 +889,20 @@ class ControllerIdentity: raise ConfigManagerError("identity is not a Joy-Con 2 pair") return ( ControllerIdentity( - True, TRANSPORT_BLE, self.address_type, self.address, 0x057E, 0x2067, + True, + TRANSPORT_BLE, + self.address_type, + self.address, + 0x057E, + 0x2067, ), ControllerIdentity( - True, TRANSPORT_BLE, self.partner_address_type, self.partner_address, - 0x057E, 0x2066, + True, + TRANSPORT_BLE, + self.partner_address_type, + self.partner_address, + 0x057E, + 0x2066, ), ) @@ -882,8 +916,14 @@ class ControllerIdentity: if flags & ~0x07 or not flags & 1: raise ConfigManagerError("invalid Joy-Con 2 pair flags") return cls( - True, TRANSPORT_JOYCON_PAIR, (flags >> 1) & 1, payload[2:8], - 0x057E, 0x2067, (flags >> 2) & 1, payload[8:14], + True, + TRANSPORT_JOYCON_PAIR, + (flags >> 1) & 1, + payload[2:8], + 0x057E, + 0x2067, + (flags >> 2) & 1, + payload[8:14], ) stable, transport, address_type, reserved = payload[:4] if stable not in (0, 1) or reserved != 0: @@ -901,10 +941,12 @@ class ControllerIdentity: def to_bytes(self) -> bytes: if self.is_joycon_pair: return ( - bytes(( - 1 | (self.address_type << 1) | (self.partner_address_type << 2), - TRANSPORT_JOYCON_PAIR, - )) + bytes( + ( + 1 | (self.address_type << 1) | (self.partner_address_type << 2), + TRANSPORT_JOYCON_PAIR, + ) + ) + self.address + self.partner_address ) @@ -962,7 +1004,10 @@ class ControllerIdentity: result.update( partner_address=self.partner_address_text, partner_address_type=self.partner_address_type, - members={"left": left.to_json_object(), "right": right.to_json_object()}, + members={ + "left": left.to_json_object(), + "right": right.to_json_object(), + }, ) return result @@ -1041,14 +1086,13 @@ class ProfilePlaytest: "connection_generation": self.connection_generation, "state_generation": self.state_generation, "identity": ( - self.identity.to_json_object() - if self.identity is not None - else None + self.identity.to_json_object() if self.identity is not None else None ), "layout": self.layout, "buttons": _button_mask_to_json(self.button_mask), "extra_buttons": [ - name for index, name in enumerate(EXTRA_BUTTONS) + name + for index, name in enumerate(EXTRA_BUTTONS) if self.extra_buttons & (1 << index) ], "left_stick": { @@ -1583,7 +1627,11 @@ class ProfileShortcuts: if type(obj["profiles"]) is not list: raise ConfigManagerError("profile.shortcuts.profiles must be an array") return cls( - _control_index(obj["modifier"], "profile.shortcuts.modifier", schema_version=schema_version), + _control_index( + obj["modifier"], + "profile.shortcuts.modifier", + schema_version=schema_version, + ), tuple( _button_index(selector, f"profile.shortcuts.profiles[{index}]") for index, selector in enumerate(obj["profiles"]) @@ -1611,8 +1659,12 @@ class ProfileShift: LOGICAL_BUTTONS ): raise ConfigManagerError("Shift button map must contain 16 mappings") - if type(self.extra_button_map) is not tuple or len(self.extra_button_map) != len(EXTRA_BUTTONS): - raise ConfigManagerError("Shift extra button map must contain seven mappings") + if type(self.extra_button_map) is not tuple or len( + self.extra_button_map + ) != len(EXTRA_BUTTONS): + raise ConfigManagerError( + "Shift extra button map must contain seven mappings" + ) for output in (*self.button_map, *self.extra_button_map): if type(output) is not int or ( output != PROFILE_NONE_BUTTON and not 0 <= output < len(LOGICAL_BUTTONS) @@ -1645,12 +1697,17 @@ class ProfileShift: obj["button_map"], LOGICAL_BUTTONS, "profile.shift.button_map" ) extras = ( - _require_object(obj["extra_button_map"], EXTRA_BUTTONS, "profile.shift.extra_button_map") - if schema_version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION else dict.fromkeys(EXTRA_BUTTONS) + _require_object( + obj["extra_button_map"], EXTRA_BUTTONS, "profile.shift.extra_button_map" + ) + if schema_version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION + else dict.fromkeys(EXTRA_BUTTONS) ) return cls( _require_enum(obj["mode"], SHIFT_MODES, "profile.shift.mode"), - _control_index(obj["modifier"], "profile.shift.modifier", schema_version=schema_version), + _control_index( + obj["modifier"], "profile.shift.modifier", schema_version=schema_version + ), tuple( _button_index(mappings[name], f"profile.shift.button_map.{name}") for name in LOGICAL_BUTTONS @@ -1767,8 +1824,12 @@ class ControllerMacro: if type(steps) is not list or len(steps) > PROFILE_MACRO_STEPS_PER_MACRO: raise ConfigManagerError(f"{name}.steps must contain zero to eight steps") return cls( - _control_mask_from_json(obj["trigger"], f"{name}.trigger", schema_version=schema_version), - _control_index(obj["cancel"], f"{name}.cancel", schema_version=schema_version), + _control_mask_from_json( + obj["trigger"], f"{name}.trigger", schema_version=schema_version + ), + _control_index( + obj["cancel"], f"{name}.cancel", schema_version=schema_version + ), tuple( MacroStep.from_json_object(step, f"{name}.steps[{index}]") for index, step in enumerate(steps) @@ -1778,7 +1839,9 @@ class ControllerMacro: if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION else 0 ), - obj["repeat_count"] if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION else 1, + obj["repeat_count"] + if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION + else 1, ) @@ -1792,7 +1855,9 @@ def _validate_swing_action(button: int, macro: int, modifier: int) -> None: ): raise ConfigManagerError("invalid swing macro index") if button != PROFILE_NONE_BUTTON and macro != PROFILE_NONE_BUTTON: - raise ConfigManagerError("swing action must select either a button or a macro, not both") + raise ConfigManagerError( + "swing action must select either a button or a macro, not both" + ) if type(modifier) is not int or ( modifier != PROFILE_NONE_BUTTON and not 0 <= modifier < len(LOGICAL_CONTROLS) ): @@ -1828,8 +1893,11 @@ class ProfileSwing: @classmethod def from_json_object( - cls, value: Any, name: str = "profile.swing", - *, schema_version: int = PROFILE_SCHEMA_VERSION, + cls, + value: Any, + name: str = "profile.swing", + *, + schema_version: int = PROFILE_SCHEMA_VERSION, ) -> ProfileSwing: fields = ["button", "sensitivity", "modifier"] if schema_version >= PROFILE_SCHEMA_VERSION: @@ -1837,10 +1905,15 @@ class ProfileSwing: obj = _require_object(value, fields, name) return cls( _button_index(obj["button"], f"{name}.button"), - _require_enum(obj["sensitivity"], SWING_SENSITIVITIES, f"{name}.sensitivity"), - _control_index(obj["modifier"], f"{name}.modifier", schema_version=schema_version), + _require_enum( + obj["sensitivity"], SWING_SENSITIVITIES, f"{name}.sensitivity" + ), + _control_index( + obj["modifier"], f"{name}.modifier", schema_version=schema_version + ), _swing_macro_index(obj["macro"], f"{name}.macro") - if schema_version >= PROFILE_SCHEMA_VERSION else PROFILE_NONE_BUTTON, + if schema_version >= PROFILE_SCHEMA_VERSION + else PROFILE_NONE_BUTTON, ) @@ -1900,7 +1973,9 @@ class ControllerProfile: LOGICAL_BUTTONS ): raise ConfigManagerError("button map must contain 16 logical mappings") - if type(self.extra_button_map) is not tuple or len(self.extra_button_map) != len(EXTRA_BUTTONS): + if type(self.extra_button_map) is not tuple or len( + self.extra_button_map + ) != len(EXTRA_BUTTONS): raise ConfigManagerError("extra button map must contain seven mappings") for mapping in (*self.button_map, *self.extra_button_map): if type(mapping) is not int or ( @@ -1983,17 +2058,23 @@ class ControllerProfile: ): gesture = getattr(self, name) if not isinstance(gesture, gesture_type): - raise ConfigManagerError(f"profile {name} must be {gesture_type.__name__}") + raise ConfigManagerError( + f"profile {name} must be {gesture_type.__name__}" + ) if gesture.macro != PROFILE_NONE_BUTTON: target = self.macros[gesture.macro] - if not target.steps or not any(step.duration_ms for step in target.steps): + if not target.steps or not any( + step.duration_ms for step in target.steps + ): raise ConfigManagerError( f"profile.{name} macro {gesture.macro + 1} must contain " "at least one step and a positive total duration" ) _require_int( - self.combination_window_ms, "profile.combination_window_ms", - PROFILE_COMBINATION_WINDOW_MIN, PROFILE_COMBINATION_WINDOW_MAX, + self.combination_window_ms, + "profile.combination_window_ms", + PROFILE_COMBINATION_WINDOW_MIN, + PROFILE_COMBINATION_WINDOW_MAX, ) if not isinstance(self.turbo_defaults, TurboSettings): raise ConfigManagerError("Turbo defaults must be TurboSettings") @@ -2048,7 +2129,9 @@ class ControllerProfile: raise ConfigManagerError("invalid profile size") version, size = struct.unpack_from("= PROFILE_EXPANDED_SCHEMA_VERSION else PROFILE_LEGACY_SIZE + PROFILE_SIZE + if version >= PROFILE_EXPANDED_SCHEMA_VERSION + else PROFILE_LEGACY_SIZE ) if ( version < PROFILE_LEGACY_SCHEMA_VERSION @@ -2063,7 +2146,9 @@ class ControllerProfile: has_extra_buttons = version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION has_swing = version >= PROFILE_SWING_SCHEMA_VERSION has_combined_swing = version >= PROFILE_SCHEMA_VERSION - control_count = len(LOGICAL_CONTROLS) if has_extra_buttons else len(OUTPUT_CONTROLS) + control_count = ( + len(LOGICAL_CONTROLS) if has_extra_buttons else len(OUTPUT_CONTROLS) + ) if sparse_macros: if payload[75] & 0xCC: raise ConfigManagerError("profile action flags are invalid") @@ -2178,7 +2263,10 @@ class ControllerProfile: 0 if legacy_trigger == PROFILE_NONE_BUTTON else 1 << legacy_trigger ) cancel_control = payload[79] - if cancel_control != PROFILE_NONE_BUTTON and cancel_control >= control_count: + if ( + cancel_control != PROFILE_NONE_BUTTON + and cancel_control >= control_count + ): raise ConfigManagerError("invalid legacy macro cancel control") if has_action_controls: switching_chord |= (payload[75] & 0x03) << 16 @@ -2213,12 +2301,19 @@ class ControllerProfile: turbo_defaults = TurboSettings() turbo_overrides: list[TurboSettings | None] = [None] * len(LOGICAL_BUTTONS) if version >= PROFILE_EXPANDED_SCHEMA_VERSION: - if any(value != PROFILE_NONE_BUTTON and value >= control_count for value in (payload[256], payload[266])): + if any( + value != PROFILE_NONE_BUTTON and value >= control_count + for value in (payload[256], payload[266]) + ): raise ConfigManagerError("invalid profile modifier") shortcuts = ProfileShortcuts(payload[256], tuple(payload[257:265])) shift = ProfileShift( - payload[265], payload[266], tuple(payload[267:283]), - tuple(payload[351:358]) if has_extra_buttons else (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS), + payload[265], + payload[266], + tuple(payload[267:283]), + tuple(payload[351:358]) + if has_extra_buttons + else (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS), ) turbo_defaults = TurboSettings(*payload[283:286]) override_mask = struct.unpack_from(" bytes: @@ -2346,15 +2456,28 @@ class ControllerProfile: payload[362] = (self.switching_chord >> 18) & 0x7F payload[363] = (self.motion_toggle_chord >> 18) & 0x7F payload[364:368] = bytes( - (self.swing.button, self.swing.sensitivity, self.swing.modifier, self.swing.macro) + ( + self.swing.button, + self.swing.sensitivity, + self.swing.modifier, + self.swing.macro, + ) ) payload[368:372] = bytes( - (self.nunchuk_swing.button, self.nunchuk_swing.sensitivity, - self.nunchuk_swing.modifier, self.nunchuk_swing.macro) + ( + self.nunchuk_swing.button, + self.nunchuk_swing.sensitivity, + self.nunchuk_swing.modifier, + self.nunchuk_swing.macro, + ) ) payload[372:376] = bytes( - (self.combined_swing.button, self.combined_swing.macro, - self.combined_swing.modifier, self.combination_window_ms) + ( + self.combined_swing.button, + self.combined_swing.macro, + self.combined_swing.modifier, + self.combination_window_ms, + ) ) return bytes(payload) @@ -2461,8 +2584,11 @@ class ControllerProfile: obj["button_map"], LOGICAL_BUTTONS, "profile.button_map" ) extras = ( - _require_object(obj["extra_button_map"], EXTRA_BUTTONS, "profile.extra_button_map") - if schema_version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION else dict.fromkeys(EXTRA_BUTTONS) + _require_object( + obj["extra_button_map"], EXTRA_BUTTONS, "profile.extra_button_map" + ) + if schema_version >= PROFILE_EXTRA_CONTROL_SCHEMA_VERSION + else dict.fromkeys(EXTRA_BUTTONS) ) sticks = _require_object(obj["sticks"], ("left", "right"), "profile.sticks") triggers = _require_object( @@ -2491,7 +2617,11 @@ class ControllerProfile: right_trigger = _migrate_legacy_trigger_threshold(right_trigger) mask_parser = ( - (lambda value, name: _control_mask_from_json(value, name, schema_version=schema_version)) + ( + lambda value, name: _control_mask_from_json( + value, name, schema_version=schema_version + ) + ) if schema_version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION else _button_mask_from_json ) @@ -2541,7 +2671,11 @@ class ControllerProfile: if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION: trigger_mask = mask_parser(macro["trigger"], "profile.macro.trigger") cancel_control = ( - _control_index(macro["cancel"], "profile.macro.cancel", schema_version=schema_version) + _control_index( + macro["cancel"], + "profile.macro.cancel", + schema_version=schema_version, + ) if schema_version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION else _button_index(macro["cancel"], "profile.macro.cancel") ) @@ -2559,8 +2693,12 @@ class ControllerProfile: turbo_defaults = TurboSettings() turbo_overrides: list[TurboSettings | None] = [None] * len(LOGICAL_BUTTONS) if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION: - shortcuts = ProfileShortcuts.from_json_object(obj["shortcuts"], schema_version=schema_version) - shift = ProfileShift.from_json_object(obj["shift"], schema_version=schema_version) + shortcuts = ProfileShortcuts.from_json_object( + obj["shortcuts"], schema_version=schema_version + ) + shift = ProfileShift.from_json_object( + obj["shift"], schema_version=schema_version + ) settings = _require_object( obj["turbo_settings"], ("defaults", "overrides"), @@ -2633,20 +2771,27 @@ class ControllerProfile: for name in EXTRA_BUTTONS ), swing=( - ProfileSwing.from_json_object(obj["swing"], schema_version=schema_version) + ProfileSwing.from_json_object( + obj["swing"], schema_version=schema_version + ) if schema_version >= PROFILE_SWING_SCHEMA_VERSION else ProfileSwing() ), nunchuk_swing=( - ProfileSwing.from_json_object(obj["nunchuk_swing"], "profile.nunchuk_swing") - if schema_version >= PROFILE_SCHEMA_VERSION else ProfileSwing() + ProfileSwing.from_json_object( + obj["nunchuk_swing"], "profile.nunchuk_swing" + ) + if schema_version >= PROFILE_SCHEMA_VERSION + else ProfileSwing() ), combined_swing=( ProfileCombinedSwing.from_json_object(obj["combined_swing"]) - if schema_version >= PROFILE_SCHEMA_VERSION else ProfileCombinedSwing() + if schema_version >= PROFILE_SCHEMA_VERSION + else ProfileCombinedSwing() ), combination_window_ms=( - obj["combination_window_ms"] if schema_version >= PROFILE_SCHEMA_VERSION + obj["combination_window_ms"] + if schema_version >= PROFILE_SCHEMA_VERSION else PROFILE_COMBINATION_WINDOW_DEFAULT ), ) @@ -3681,9 +3826,7 @@ def write_configuration( return _wait_for_transaction(device, transaction_id, timeout) -def set_joycon_mode( - device: UsbDevice, mode: int, timeout: float -) -> TransactionStatus: +def set_joycon_mode(device: UsbDevice, mode: int, timeout: float) -> TransactionStatus: """Persist the adapter-wide player mode without rebooting or changing profiles.""" _require_int(mode, "Joy-Con2 mode", 0, 1) before = read_configuration(device) @@ -3731,6 +3874,10 @@ def set_mode( REQUESTED_MODE_MAC, ): raise ConfigManagerError("requested USB mode is not available") + if read_info(device).active_mode == ACTIVE_MODE_NATIVE_HUB: + raise ConfigManagerError( + "Native Joy-Con hub firmware has fixed USB output; mode changes are unavailable" + ) transaction_id = _host_transaction_id() _control_out( device, @@ -3742,6 +3889,10 @@ def set_mode( def request_reboot(device: UsbDevice, transaction_id: int) -> None: _require_int(transaction_id, "transaction ID", 1, HOST_TRANSACTION_ID_MASK) + if read_info(device).active_mode == ACTIVE_MODE_NATIVE_HUB: + raise ConfigManagerError( + "Native Joy-Con hub firmware has fixed USB output; reboot-to-mode is unavailable" + ) _control_out(device, OP_REBOOT, struct.pack(" ProfilePlaytest: if len(envelope.payload) != expected_size: raise ConfigManagerError("invalid profile playtest payload") payload = envelope.payload - extra_buttons = payload[54] if len(payload) >= PROFILE_PLAYTEST_EXTRA_BUTTON_SIZE else 0 + extra_buttons = ( + payload[54] if len(payload) >= PROFILE_PLAYTEST_EXTRA_BUTTON_SIZE else 0 + ) layout_code = payload[55] if len(payload) >= PROFILE_PLAYTEST_SIZE else 0 - layout_count = 6 if envelope.schema_version == PROFILE_PLAYTEST_TOPOLOGY_SCHEMA_VERSION else len(PROFILE_PLAYTEST_LAYOUTS) + layout_count = ( + 6 + if envelope.schema_version == PROFILE_PLAYTEST_TOPOLOGY_SCHEMA_VERSION + else len(PROFILE_PLAYTEST_LAYOUTS) + ) if layout_code >= layout_count: raise ConfigManagerError("invalid playtest controller layout") if extra_buttons & ~0x7F: @@ -4441,6 +4598,10 @@ def configure_mode( ): raise ConfigManagerError("requested USB mode is not available") before_info = read_info(device) + if before_info.active_mode == ACTIVE_MODE_NATIVE_HUB: + raise ConfigManagerError( + "Native Joy-Con hub firmware has fixed USB output; mode changes are unavailable" + ) before_configuration = read_configuration(device) if before_configuration.requested_mode == requested_mode and _mode_is_active( requested_mode, before_info.active_mode @@ -4829,7 +4990,9 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"Configuration generation: {configuration.generation}") print(f"Configuration CRC: {configuration.crc:08x}") print(f"Pairing window: {configuration.pairing_window_seconds} seconds") - print(f"Joy-Con2 player mode: {JOYCON_MODE_NAMES[configuration.joycon_mode]}") + print( + f"Joy-Con2 player mode: {JOYCON_MODE_NAMES[configuration.joycon_mode]}" + ) elif args.command == "diagnostics": diagnostics = read_runtime_diagnostics(device) print(f"Initialization stage: {diagnostics.initialization_stage}") @@ -4860,7 +5023,9 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"USB mode is already {args.mode}.") elif args.command == "joycon-mode": if args.mode is not None: - set_joycon_mode(device, JOYCON_MODE_NAMES.index(args.mode), args.timeout) + set_joycon_mode( + device, JOYCON_MODE_NAMES.index(args.mode), args.timeout + ) configuration = read_configuration(device) mode_name = JOYCON_MODE_NAMES[configuration.joycon_mode] supported = configuration.schema_version >= CONFIGURATION_SCHEMA_VERSION diff --git a/tests/native_hub_management_test.cpp b/tests/native_hub_management_test.cpp new file mode 100644 index 0000000..b7c7a66 --- /dev/null +++ b/tests/native_hub_management_test.cpp @@ -0,0 +1,352 @@ +#include "usb/usb_configuration_management.h" +#include "usb/native_hub/native_hub.h" +#include "platform/pico/pico_profile_storage.h" +#include "adapter/adapter_usb_mode.h" +#include "bootsel.h" +#include +#include +#include +#include +#include +#include + +extern "C" { +void native_test_initialize(void); +void native_test_drain(void); +bool native_test_setup(uint8_t, const tusb_control_request_t*, bool); +bool native_test_out(uint8_t, const uint8_t*, uint16_t, bool); +bool native_test_in(uint8_t, uint8_t*, uint16_t*, bool); +void native_test_bus_reset(bool); +} + +namespace { +using namespace UsbConfigurationManagement; +std::array flash; +uint32_t programs = 0; +uint32_t erases = 0; +uint32_t bootsel_calls = 0; +std::array child_identity[2]; + +void require(bool condition, const char* message) { + if (!condition) { std::cerr << message << '\n'; std::exit(1); } +} +[[noreturn]] void unexpected_mutation() { + require(false, "profile editor invoked an unrelated configuration/pairing/reboot mutation"); + std::abort(); +} +uint16_t u16(const std::vector& data, size_t offset) { + return data.at(offset) | (static_cast(data.at(offset + 1)) << 8); +} +uint32_t u32(const std::vector& data, size_t offset) { + return u16(data, offset) | (static_cast(u16(data, offset + 2)) << 16); +} +void put16(std::vector& data, size_t offset, uint16_t value) { + data.at(offset) = value; data.at(offset + 1) = value >> 8; +} +void put32(std::vector& data, size_t offset, uint32_t value) { + put16(data, offset, value); put16(data, offset + 2, value >> 16); +} +tusb_control_request_t request(Operation op, bool input, uint16_t length) { + tusb_control_request_t setup{}; + setup.bmRequestType = input ? 0xc0 : 0x40; + setup.bRequest = static_cast(op); + setup.wValue = kRequestValue; setup.wIndex = kRequestIndex; setup.wLength = length; + return setup; +} +std::vector envelope(Operation op, const std::vector& payload) { + std::vector bytes(kRequestHeaderSize + payload.size()); + memcpy(bytes.data(), "SPMG", 4); + bytes[4] = kProtocolVersion; bytes[5] = static_cast(op); + put16(bytes, 8, payload.size()); + put32(bytes, 12, configuration_crc32(payload.data(), payload.size())); + std::copy(payload.begin(), payload.end(), bytes.begin() + kRequestHeaderSize); + return bytes; +} +void acknowledge(uint8_t slot = 0, bool drain = true) { + uint8_t packet[64]; uint16_t length = 0xffff; + require(native_test_in(slot, packet, &length, drain) && length == 0, + "OUT transfer did not complete with a real zero-length status packet"); +} +std::vector receive(uint8_t slot = 0) { + std::vector bytes; + for (;;) { + uint8_t packet[64]; uint16_t length = 0; + require(native_test_in(slot, packet, &length, true), "control IN packet was not available"); + bytes.insert(bytes.end(), packet, packet + length); + if (length < 64) break; + } + require(native_test_out(slot, nullptr, 0, true), "control IN status OUT was rejected"); + return bytes; +} +std::vector read_operation(Operation op) { + const auto setup = request(op, true, kMaximumResponseSize); + require(native_test_setup(0, &setup, true), "root management read stalled"); + auto bytes = receive(); + require(bytes.size() >= kResponseHeaderSize && memcmp(bytes.data(), "SPMG", 4) == 0 && + bytes[5] == static_cast(op) && + bytes.size() == kResponseHeaderSize + u16(bytes, 8), "management read envelope is corrupt"); + require(u32(bytes, 16) == configuration_crc32(bytes.data() + kResponseHeaderSize, + bytes.size() - kResponseHeaderSize), "multi-packet response CRC is corrupt"); + return bytes; +} +void write_operation(Operation op, const std::vector& payload, bool ack = true) { + const auto bytes = envelope(op, payload); + const auto setup = request(op, false, bytes.size()); + require(native_test_setup(0, &setup, true), "root management write setup stalled"); + for (size_t offset = 0; offset < bytes.size(); offset += 64) { + require(native_test_out(0, bytes.data() + offset, + std::min(64, bytes.size() - offset), true), "management OUT packet stalled"); + } + if (ack) acknowledge(); +} +std::vector identity_payload(uint8_t profile) { + std::vector payload(15); + require(controller_identity_encode(controller_identity_global(), payload.data(), 14), "global identity did not encode"); + payload[14] = profile; return payload; +} +std::vector encoded_profile(uint8_t destination) { + ControllerProfile profile = controller_profile_default(controller_identity_global(), 0); + profile.button_map[0] = destination; + std::vector bytes(CONTROLLER_PROFILE_ENCODED_SIZE); + require(controller_profile_encode(profile, bytes.data(), bytes.size()), "edited profile is invalid"); + return bytes; +} +void begin_profile(uint32_t transaction, const std::vector& profile) { + std::vector payload(28); + put32(payload, 0, transaction); + require(controller_identity_encode(controller_identity_global(), payload.data() + 4, 14), "profile owner did not encode"); + put16(payload, 20, CONTROLLER_PROFILE_SCHEMA_VERSION); put16(payload, 22, profile.size()); + put32(payload, 24, configuration_crc32(profile.data(), profile.size())); + write_operation(Operation::kProfileBegin, payload); + // Selecting an existing owner is part of normal editor navigation and + // must not persist anything while a profile is merely being staged. + write_operation(Operation::kProfileSelect, identity_payload(0)); +} +std::vector chunk_payload(uint32_t transaction, const std::vector& profile, size_t offset) { + const size_t count = std::min(kMaximumChunkSize, profile.size() - offset); + std::vector payload(8 + count); + put32(payload, 0, transaction); put16(payload, 4, offset); put16(payload, 6, count); + std::copy_n(profile.data() + offset, count, payload.data() + 8); + return payload; +} +void stage_profile(uint32_t transaction, const std::vector& profile) { + begin_profile(transaction, profile); + for (size_t offset = 0; offset < profile.size(); offset += kMaximumChunkSize) + write_operation(Operation::kProfileChunk, chunk_payload(transaction, profile, offset)); +} +std::vector transaction_payload(uint32_t id) { std::vector data(4); put32(data, 0, id); return data; } +void require_profile(const std::vector& expected) { + auto bytes = read_operation(Operation::kProfileRead); + require(bytes[6] == static_cast(Status::kOk) && + std::vector(bytes.begin() + kResponseHeaderSize, bytes.end()) == expected, + "host readback differs from the durable selected profile"); +} +void read_child(uint8_t slot) { + tusb_control_request_t setup{}; + setup.bmRequestType = 0xc0; setup.bRequest = 3; setup.wLength = 128; + require(native_test_setup(slot, &setup, true), "native child identity stalled"); + const auto bytes = receive(slot); + require(bytes == std::vector(child_identity[slot - 1].begin(), child_identity[slot - 1].end()), + "native child identity leaked root or sibling vendor bytes"); +} + +void test_profile_transport() { + const uint32_t programs_before = programs, erases_before = erases; + const auto original = encoded_profile(0); + const auto edited = encoded_profile(4); + auto info = read_operation(Operation::kInfo); + require(info[kResponseHeaderSize] == 0 && info[kResponseHeaderSize + 1] == 72 && + info[kResponseHeaderSize + 2] == 0 && info[kResponseHeaderSize + 4] == 5 && + info[kResponseHeaderSize + 5] == 7, "native INFO does not describe the fixed image"); + auto list = read_operation(Operation::kProfileList); + require(list[kResponseHeaderSize] == 1 && list.size() > 64, "root catalog omitted the global profile owner"); + auto playtest = read_operation(Operation::kProfilePlaytest); + require(playtest[kResponseHeaderSize] == 0 && playtest[kResponseHeaderSize + 1] == 0xff, + "disconnected playtest fabricated controller input"); + require_profile(original); + require(programs == programs_before && erases == erases_before, "editor reads wrote saved storage"); + for (uint8_t slot : {1, 2}) { + const auto management = request(Operation::kProfileList, true, kMaximumResponseSize); + require(!native_test_setup(slot, &management, true), "native child accepted regular management"); + read_child(slot); + } + for (Operation op : {Operation::kModeSet, Operation::kReboot}) { + const auto setup = request(op, false, kRequestHeaderSize + (op == Operation::kModeSet ? 5 : 4)); + require(!native_test_setup(0, &setup, true), "fixed native image accepted mode switching"); + } + + begin_profile(1, edited); + const auto chunk = envelope(Operation::kProfileChunk, chunk_payload(1, edited, 0)); + const auto setup = request(Operation::kProfileChunk, false, chunk.size()); + require(native_test_setup(0, &setup, true) && native_test_out(0, chunk.data(), 64, true), "first full OUT packet failed"); + read_child(1); read_child(2); + const auto child_management = request(Operation::kInfo, true, kMaximumResponseSize); + require(!native_test_setup(1, &child_management, true), "child INFO was accepted during a root write"); + require(native_test_out(0, chunk.data() + 64, chunk.size() - 64, true), "interleaved child requests corrupted root OUT tail"); + acknowledge(); + for (size_t offset = kMaximumChunkSize; offset < edited.size(); offset += kMaximumChunkSize) + write_operation(Operation::kProfileChunk, chunk_payload(1, edited, offset)); + write_operation(Operation::kProfileCommit, transaction_payload(1), false); + profile_service_task_on_storage_core(1000); + require(programs == programs_before && erases == erases_before, "profile persisted before its status ACK"); + + // Host sends its next SETUP before Core0 drains the already completed ACK. + acknowledge(0, false); + const auto next = request(Operation::kInfo, true, kMaximumResponseSize); + require(native_test_setup(0, &next, false), "next root SETUP was rejected"); + native_test_drain(); receive(); + profile_service_task_on_storage_core(1000); + require_profile(edited); + auto status = read_operation(Operation::kProfileTransactionStatus); + require(status[6] == static_cast(Status::kOk) && u32(status, kResponseHeaderSize) == 1, + "genuine queued ACK lost its commit when the next SETUP arrived"); + + // Reopen the actual storage journal, not the service's published cache. + ProfileStorage reopened; + ControllerProfile stored{}, sibling{}; + require(reopened.initialize(pico_profile_storage_io()) && + reopened.get(controller_identity_global(), 0, &stored) == ProfileStorageResult::kOk && + reopened.get(controller_identity_global(), 1, &sibling) == ProfileStorageResult::kOk && + stored.button_map[0] == 4 && sibling.button_map[0] == 0, + "profile journal lost the edit or modified an unrelated profile"); +} + +void test_interrupted_transactions() { + const auto retained = encoded_profile(4), replacement = encoded_profile(5); + const uint32_t programs_before = programs, erases_before = erases; + stage_profile(2, replacement); + write_operation(Operation::kProfileCommit, transaction_payload(2), false); + // No status token completed: a fresh root SETUP aborts the write. + read_operation(Operation::kInfo); + profile_service_task_on_storage_core(2000); + require_profile(retained); + require(programs == programs_before && erases == erases_before, "aborted status committed stale profile data"); + // The shared service intentionally retains incomplete receives. An + // explicit wrong-ID commit ends one with OutOfOrder, never a flash write. + write_operation(Operation::kProfileCommit, transaction_payload(0)); + + begin_profile(3, replacement); + const auto chunk = envelope(Operation::kProfileChunk, chunk_payload(3, replacement, 0)); + const auto setup = request(Operation::kProfileChunk, false, chunk.size()); + require(native_test_setup(0, &setup, true) && native_test_out(0, chunk.data(), 64, true), "aborted-packet setup failed"); + read_operation(Operation::kInfo); + require(!native_test_out(0, chunk.data() + 64, chunk.size() - 64, true), "new SETUP left an aborted OUT tail armed"); + require(native_test_setup(0, &setup, true) && native_test_out(0, chunk.data(), 64, true), "short-packet setup failed"); + require(!native_test_out(0, chunk.data() + 64, chunk.size() - 65, true), "short OUT tail was accepted"); + auto status = read_operation(Operation::kProfileTransactionStatus); + require(u16(status, kResponseHeaderSize + 4) == 0, "short OUT appended stale bytes to a profile"); + auto corrupt = chunk; + corrupt[12] ^= 1; + require(native_test_setup(0, &setup, true) && native_test_out(0, corrupt.data(), 64, true), "bad-CRC setup failed"); + require(!native_test_out(0, corrupt.data() + 64, corrupt.size() - 64, true), "bad CRC acquired a status ACK"); + write_operation(Operation::kProfileCommit, transaction_payload(0)); + + stage_profile(4, replacement); + write_operation(Operation::kProfileCommit, transaction_payload(4), false); + // The SIE captured ACK, but a bus reset revoked the queued transaction + // before the service consumed it. A SETUP must not be conflated with reset. + acknowledge(0, false); + native_test_bus_reset(true); + profile_service_task_on_storage_core(3000); + require_profile(retained); + require(programs == programs_before && erases == erases_before, "bus reset committed a stale queued profile ACK"); + write_operation(Operation::kProfileCommit, transaction_payload(0)); + + stage_profile(5, replacement); + write_operation(Operation::kProfileCommit, transaction_payload(5)); + profile_service_task_on_storage_core(3000); + require_profile(replacement); + // A repeated status token is unarmed and cannot dispatch the commit twice. + uint8_t packet[64]; uint16_t length; + require(!native_test_in(0, packet, &length, true), "completed request retained a second status ACK"); +} + +void test_private_bootsel() { + const auto bytes = envelope(Operation::kBootselReboot, {}); + const auto setup = request(Operation::kBootselReboot, false, bytes.size()); + for (uint8_t slot : {0, 1, 2}) { + require(native_test_setup(slot, &setup, true), "private BOOTSEL setup stalled"); + require(!native_test_out(slot, bytes.data(), bytes.size() - 1, true), "short BOOTSEL was accepted"); + probe_bootsel_task(100); probe_bootsel_task(200); + require(bootsel_calls == 0, "short BOOTSEL rebooted the device"); + require(native_test_setup(slot, &setup, true) && native_test_out(slot, bytes.data(), bytes.size(), true), + "valid private BOOTSEL envelope failed"); + // An unrelated identity/INFO SETUP cancels an unacknowledged BOOTSEL. + if (slot) read_child(slot); else read_operation(Operation::kInfo); + probe_bootsel_task(300); probe_bootsel_task(400); + require(bootsel_calls == 0, "unacknowledged BOOTSEL rebooted the device"); + } + require(native_test_setup(2, &setup, true) && native_test_out(2, bytes.data(), bytes.size(), true), + "validated child BOOTSEL failed"); + acknowledge(2); + probe_bootsel_task(500); probe_bootsel_task(549); + require(bootsel_calls == 0, "BOOTSEL did not retain the post-ACK delay"); + probe_bootsel_task(550); + require(bootsel_calls == 1, "validated child BOOTSEL did not reach ROM after the delay"); +} + +bool flash_read(void*, uint8_t arena, size_t offset, uint8_t* data, size_t size) { + if (arena >= PROFILE_STORAGE_ARENA_COUNT || offset > PROFILE_STORAGE_ARENA_SIZE || + size > PROFILE_STORAGE_ARENA_SIZE - offset) return false; + memcpy(data, flash.data() + arena * PROFILE_STORAGE_ARENA_SIZE + offset, size); return true; +} +bool flash_erase(void*, uint8_t arena) { + if (arena >= PROFILE_STORAGE_ARENA_COUNT) return false; + ++erases; memset(flash.data() + arena * PROFILE_STORAGE_ARENA_SIZE, 0xff, PROFILE_STORAGE_ARENA_SIZE); return true; +} +bool flash_program(void*, uint8_t arena, size_t offset, const uint8_t* data, size_t size) { + if (arena >= PROFILE_STORAGE_ARENA_COUNT || size != PROFILE_STORAGE_PAGE_SIZE || + offset % PROFILE_STORAGE_PAGE_SIZE || offset + size > PROFILE_STORAGE_ARENA_SIZE) return false; + ++programs; + uint8_t* destination = flash.data() + arena * PROFILE_STORAGE_ARENA_SIZE + offset; + for (size_t i = 0; i < size; ++i) destination[i] &= data[i]; + return memcmp(destination, data, size) == 0; +} +} // namespace + +ProfileStorageIo pico_profile_storage_io() { + return {nullptr, PROFILE_STORAGE_ARENA_SIZE, PROFILE_STORAGE_SECTOR_SIZE, + PROFILE_STORAGE_PAGE_SIZE, flash_read, flash_erase, flash_program}; +} +uint32_t configuration_crc32(const uint8_t* data, size_t size) { return profile_storage_crc32(data, size); } +void configuration_service_snapshot(ConfigurationServiceSnapshot* out) { + *out = {}; out->state = ConfigurationServiceState::kReady; + out->configuration = adapter_configuration_default(); +} +ConfigurationTransactionStatus configuration_service_begin(uint32_t, uint16_t, size_t, uint32_t) { unexpected_mutation(); } +ConfigurationTransactionStatus configuration_service_append(uint32_t, size_t, const uint8_t*, size_t) { unexpected_mutation(); } +ConfigurationTransactionStatus configuration_service_commit(uint32_t) { unexpected_mutation(); } +ConfigurationTransactionStatus configuration_service_reset(uint32_t) { unexpected_mutation(); } +ConfigurationTransactionStatus configuration_service_set_mode(uint32_t, AdapterRequestedMode, const AdapterModeAvailability&) { unexpected_mutation(); } +const AdapterModeAvailability& adapter_usb_mode_availability() { unexpected_mutation(); } +bool adapter_reboot_for_mode_transaction(uint32_t) { unexpected_mutation(); } +bool adapter_reboot_to_bootsel() { unexpected_mutation(); } +void bluepad32_input_backend_request_pairing_snapshot() { unexpected_mutation(); } +uint32_t bluepad32_input_backend_clear_pairings() { unexpected_mutation(); } +void bluepad32_input_backend_pairing_snapshot(Bluepad32PairingSnapshot* out) { *out = {}; } +void bluepad32_input_backend_playtest_snapshot(uint8_t, Bluepad32PlaytestSnapshot* out) { *out = {}; } +void bluepad32_input_backend_diagnostics(Bluepad32BackendDiagnostics* out) { *out = {}; } +bool bluepad32_input_backend_identify(const ControllerIdentity&) { return false; } +bool bluepad32_input_backend_set_wii_orientation(const ControllerIdentity&, uint32_t, bool) { return false; } +bool bluepad32_input_backend_capture_start(uint8_t, uint32_t, const CaptureOptions&) { return false; } +bool bluepad32_input_backend_capture_stop(uint32_t) { return false; } +bool bluepad32_input_backend_capture_page(uint32_t, uint16_t, Bluepad32CaptureSnapshot*) { return false; } +extern "C" void reset_usb_boot(uint32_t, uint32_t) { ++bootsel_calls; } +extern "C" bool tud_vendor_control_xfer_cb(uint8_t slot, uint8_t stage, const tusb_control_request_t* setup) { + if (probe_management_vendor_control(slot, stage, setup)) return true; + if (slot < 1 || slot > 2 || setup->bmRequestType != 0xc0 || + setup->bRequest != 3 || setup->wValue || setup->wIndex) return false; + return stage != CONTROL_STAGE_SETUP || native_hub_control_xfer(slot, setup, + child_identity[slot - 1].data(), child_identity[slot - 1].size()); +} + +int main() { + static_assert(sizeof(tusb_control_request_t) == 8); + flash.fill(0xff); child_identity[0].fill(0x31); child_identity[1].fill(0x72); + profile_service_prepare(); profile_service_initialize_on_storage_core(); + native_test_initialize(); + test_profile_transport(); + test_interrupted_transactions(); + test_private_bootsel(); + std::cout << "native root management packet and persistence regressions passed\n"; +} diff --git a/tests/native_hub_stubs/hardware/clocks.h b/tests/native_hub_stubs/hardware/clocks.h new file mode 100644 index 0000000..ac29fc4 --- /dev/null +++ b/tests/native_hub_stubs/hardware/clocks.h @@ -0,0 +1,2 @@ +#pragma once +#include "hardware_stub.h" diff --git a/tests/native_hub_stubs/hardware/irq.h b/tests/native_hub_stubs/hardware/irq.h new file mode 100644 index 0000000..ac29fc4 --- /dev/null +++ b/tests/native_hub_stubs/hardware/irq.h @@ -0,0 +1,2 @@ +#pragma once +#include "hardware_stub.h" diff --git a/tests/native_hub_stubs/hardware/resets.h b/tests/native_hub_stubs/hardware/resets.h new file mode 100644 index 0000000..ac29fc4 --- /dev/null +++ b/tests/native_hub_stubs/hardware/resets.h @@ -0,0 +1,2 @@ +#pragma once +#include "hardware_stub.h" diff --git a/tests/native_hub_stubs/hardware/structs/sio.h b/tests/native_hub_stubs/hardware/structs/sio.h new file mode 100644 index 0000000..ac29fc4 --- /dev/null +++ b/tests/native_hub_stubs/hardware/structs/sio.h @@ -0,0 +1,2 @@ +#pragma once +#include "hardware_stub.h" diff --git a/tests/native_hub_stubs/hardware/structs/usb.h b/tests/native_hub_stubs/hardware/structs/usb.h new file mode 100644 index 0000000..ac29fc4 --- /dev/null +++ b/tests/native_hub_stubs/hardware/structs/usb.h @@ -0,0 +1,2 @@ +#pragma once +#include "hardware_stub.h" diff --git a/tests/native_hub_stubs/hardware/structs/usb_dpram.h b/tests/native_hub_stubs/hardware/structs/usb_dpram.h new file mode 100644 index 0000000..ac29fc4 --- /dev/null +++ b/tests/native_hub_stubs/hardware/structs/usb_dpram.h @@ -0,0 +1,2 @@ +#pragma once +#include "hardware_stub.h" diff --git a/tests/native_hub_stubs/hardware/sync.h b/tests/native_hub_stubs/hardware/sync.h new file mode 100644 index 0000000..ac29fc4 --- /dev/null +++ b/tests/native_hub_stubs/hardware/sync.h @@ -0,0 +1,2 @@ +#pragma once +#include "hardware_stub.h" diff --git a/tests/native_hub_stubs/hardware/watchdog.h b/tests/native_hub_stubs/hardware/watchdog.h new file mode 100644 index 0000000..ac29fc4 --- /dev/null +++ b/tests/native_hub_stubs/hardware/watchdog.h @@ -0,0 +1,2 @@ +#pragma once +#include "hardware_stub.h" diff --git a/tests/native_hub_stubs/hardware_stub.h b/tests/native_hub_stubs/hardware_stub.h new file mode 100644 index 0000000..d611092 --- /dev/null +++ b/tests/native_hub_stubs/hardware_stub.h @@ -0,0 +1,109 @@ +#pragma once +#include +#include +#include +#include + +#define __not_in_flash_func(name) name +#define __force_inline inline __attribute__((always_inline)) +#define __dmb() ((void)0) + +typedef struct { unsigned unused; } spin_lock_t; +static inline uint32_t save_and_disable_interrupts(void) { return 0; } +static inline void restore_interrupts(uint32_t flags) { (void)flags; } +static inline uint32_t spin_lock_blocking(spin_lock_t* lock) { (void)lock; return 0; } +static inline void spin_unlock(spin_lock_t* lock, uint32_t flags) { (void)lock; (void)flags; } +static inline bool spin_try_lock_unsafe(spin_lock_t* lock) { (void)lock; return true; } +static inline void spin_unlock_unsafe(spin_lock_t* lock) { (void)lock; } +static inline int spin_lock_claim_unused(bool required) { (void)required; return 0; } +static inline spin_lock_t* spin_lock_instance(unsigned index) { + static spin_lock_t lock; (void)index; return &lock; +} +static inline void hw_clear_bits(volatile uint32_t* address, uint32_t bits) { *address &= ~bits; } +static inline void hw_set_bits(volatile uint32_t* address, uint32_t bits) { *address |= bits; } + +typedef struct { + volatile uint32_t ints, sie_status, buf_status, dev_addr_ctrl, inte; + volatile uint32_t ep_stall_arm, muxing, phy_direct, phy_direct_override; + volatile uint32_t pwr, main_ctrl, sie_ctrl, ep_nak_stall_status; + volatile uint32_t ep_tx_error, ep_rx_error; +} usb_hw_t; +typedef struct { volatile uint32_t in, out; } usb_pair_t; +typedef struct { + uint8_t setup_packet[8]; + usb_pair_t ep_ctrl[15]; + usb_pair_t ep_buf_ctrl[16]; + uint8_t ep0_buf_a[64]; + uint8_t padding[3776]; +} usb_device_dpram_t; +typedef struct { volatile uint32_t mtime, gpio_hi_oe_clr; } sio_hw_t; +extern usb_hw_t native_test_usb; +extern usb_device_dpram_t native_test_dpram; +extern sio_hw_t native_test_sio; +#define usb_hw (&native_test_usb) +#define usb_dpram (&native_test_dpram) +#define sio_hw (&native_test_sio) +#define USBCTRL_DPRAM_BASE ((uintptr_t)usb_dpram) +#define USB_DPRAM_SIZE sizeof(*usb_dpram) + +#define USB_BUF_CTRL_LEN_MASK 0x3ffu +#define USB_BUF_CTRL_AVAIL (1u << 10) +#define USB_BUF_CTRL_STALL (1u << 11) +#define USB_BUF_CTRL_SEL (1u << 12) +#define USB_BUF_CTRL_DATA1_PID (1u << 13) +#define USB_BUF_CTRL_LAST (1u << 14) +#define USB_BUF_CTRL_FULL (1u << 15) +#define EP_CTRL_ENABLE_BITS (1u << 31) +#define EP_CTRL_INTERRUPT_PER_BUFFER (1u << 29) +#define EP_CTRL_INTERRUPT_ON_NAK (1u << 16) +#define EP_CTRL_BUFFER_TYPE_LSB 26 +#define USB_INTS_BUS_RESET_BITS (1u << 0) +#define USB_INTS_SETUP_REQ_BITS (1u << 1) +#define USB_INTS_DEV_SUSPEND_BITS (1u << 2) +#define USB_INTS_DEV_RESUME_FROM_HOST_BITS (1u << 3) +#define USB_INTS_BUFF_STATUS_BITS (1u << 4) +#define USB_SIE_STATUS_SETUP_REC_BITS (1u << 0) +#define USB_SIE_STATUS_BUS_RESET_BITS (1u << 1) +#define USB_SIE_STATUS_SUSPENDED_BITS (1u << 2) +#define USB_SIE_STATUS_RESUME_BITS (1u << 3) +#define USB_USB_MUXING_TO_PHY_BITS 1u +#define USB_USB_MUXING_SOFTCON_BITS 2u +#define USB_USB_MUXING_USBPHY_AS_GPIO_BITS 4u +#define SIO_GPIO_HI_IN_USB_DP_BITS 1u +#define SIO_GPIO_HI_IN_USB_DM_BITS 2u +#define USB_USBPHY_DIRECT_DP_PULLUP_EN_BITS 1u +#define USB_USBPHY_DIRECT_OVERRIDE_DP_PULLUP_EN_OVERRIDE_EN_BITS 1u +#define USB_USB_PWR_VBUS_DETECT_BITS 1u +#define USB_USB_PWR_VBUS_DETECT_OVERRIDE_EN_BITS 2u +#define USB_MAIN_CTRL_CONTROLLER_EN_BITS 1u +#define USB_SIE_CTRL_EP0_INT_1BUF_BITS 1u +#define USB_SIE_CTRL_PULLUP_EN_BITS 2u +#define RESETS_RESET_USBCTRL_BITS 1u +#define USBCTRL_IRQ 0u +#define clk_sys 0u + +static inline uint32_t clock_get_hz(unsigned clock) { (void)clock; return 240000000u; } +static inline void reset_block(uint32_t mask) { (void)mask; } +static inline void unreset_block_wait(uint32_t mask) { (void)mask; } +static inline void multicore_launch_core1(void (*entry)(void)) { (void)entry; } +static inline void irq_set_exclusive_handler(unsigned irq, void (*fn)(void)) { (void)irq; (void)fn; } +static inline void irq_set_priority(unsigned irq, unsigned priority) { (void)irq; (void)priority; } +static inline void irq_set_enabled(unsigned irq, bool enabled) { (void)irq; (void)enabled; } +static inline uint32_t time_us_32(void) { return 1000000u; } +static inline bool watchdog_enable_caused_reboot(void) { return false; } +static inline void watchdog_enable(uint32_t ms, bool pause) { (void)ms; (void)pause; } +static inline void watchdog_update(void) {} +static inline void stdio_init_all(void) {} +static inline void sleep_ms(uint32_t ms) { (void)ms; } +static inline void tight_loop_contents(void) {} +static inline void pico_get_unique_board_id_string(char* buffer, size_t size) { + if (size) buffer[0] = '\0'; +} +#ifdef __cplusplus +extern "C" { +#endif +void reset_usb_boot(uint32_t gpio_mask, uint32_t disable_mask); +int probe_debug_printf(const char* format, ...); +#ifdef __cplusplus +} +#endif diff --git a/tests/native_hub_stubs/pico/bootrom.h b/tests/native_hub_stubs/pico/bootrom.h new file mode 100644 index 0000000..ac29fc4 --- /dev/null +++ b/tests/native_hub_stubs/pico/bootrom.h @@ -0,0 +1,2 @@ +#pragma once +#include "hardware_stub.h" diff --git a/tests/native_hub_stubs/pico/multicore.h b/tests/native_hub_stubs/pico/multicore.h new file mode 100644 index 0000000..ac29fc4 --- /dev/null +++ b/tests/native_hub_stubs/pico/multicore.h @@ -0,0 +1,2 @@ +#pragma once +#include "hardware_stub.h" diff --git a/tests/native_hub_stubs/pico/stdlib.h b/tests/native_hub_stubs/pico/stdlib.h new file mode 100644 index 0000000..ac29fc4 --- /dev/null +++ b/tests/native_hub_stubs/pico/stdlib.h @@ -0,0 +1,2 @@ +#pragma once +#include "hardware_stub.h" diff --git a/tests/native_hub_stubs/pico/unique_id.h b/tests/native_hub_stubs/pico/unique_id.h new file mode 100644 index 0000000..ac29fc4 --- /dev/null +++ b/tests/native_hub_stubs/pico/unique_id.h @@ -0,0 +1,2 @@ +#pragma once +#include "hardware_stub.h" diff --git a/tests/native_hub_stubs/tusb.h b/tests/native_hub_stubs/tusb.h new file mode 100644 index 0000000..487431f --- /dev/null +++ b/tests/native_hub_stubs/tusb.h @@ -0,0 +1,45 @@ +#pragma once +#include +#include + +enum { + CONTROL_STAGE_SETUP = 0, CONTROL_STAGE_DATA = 1, CONTROL_STAGE_ACK = 2, + TUSB_REQ_RCPT_DEVICE = 0, TUSB_REQ_TYPE_VENDOR = 2, + TUSB_DIR_OUT = 0, TUSB_DIR_IN = 1, + TUSB_XFER_BULK = 2, TUSB_XFER_INTERRUPT = 3, + TUSB_REQ_GET_STATUS = 0, TUSB_REQ_CLEAR_FEATURE = 1, TUSB_REQ_SET_FEATURE = 3, + TUSB_REQ_SET_ADDRESS = 5, TUSB_REQ_GET_DESCRIPTOR = 6, + TUSB_REQ_GET_CONFIGURATION = 8, TUSB_REQ_SET_CONFIGURATION = 9, + TUSB_REQ_GET_INTERFACE = 10, TUSB_REQ_SET_INTERFACE = 11, + TUSB_DESC_DEVICE = 1, TUSB_DESC_CONFIGURATION = 2, TUSB_DESC_STRING = 3, + HID_REPORT_TYPE_OUTPUT = 2, +}; +typedef uint8_t hid_report_type_t; +typedef struct __attribute__((packed)) { + union { + uint8_t bmRequestType; + struct __attribute__((packed)) { + uint8_t recipient : 5; + uint8_t type : 2; + uint8_t direction : 1; + } bmRequestType_bit; + }; + uint8_t bRequest; + uint16_t wValue, wIndex, wLength; +} tusb_control_request_t; + +#ifdef __cplusplus +extern "C" { +#endif +bool tud_control_xfer(uint8_t rhport, const tusb_control_request_t* request, void* buffer, uint16_t length); +bool tud_control_status(uint8_t rhport, const tusb_control_request_t* request); +bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, const tusb_control_request_t* request); +void tud_vendor_rx_cb(uint8_t instance, const uint8_t* buffer, uint16_t length); +void tud_vendor_tx_cb(uint8_t instance, uint32_t length); +const uint8_t* tud_hid_descriptor_report_cb(uint8_t instance); +uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t type, uint8_t* buffer, uint16_t length); +void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t type, const uint8_t* buffer, uint16_t length); +void tud_hid_report_complete_cb(uint8_t instance, const uint8_t* buffer, uint16_t length); +#ifdef __cplusplus +} +#endif diff --git a/tests/native_hub_transport_fixture.c b/tests/native_hub_transport_fixture.c new file mode 100644 index 0000000..49a5864 --- /dev/null +++ b/tests/native_hub_transport_fixture.c @@ -0,0 +1,104 @@ +#include "hardware_stub.h" +#include "usb/native_hub/native_hub.c" + +usb_hw_t native_test_usb; +usb_device_dpram_t native_test_dpram; +sio_hw_t native_test_sio; + +void probe_router_init(uint32_t hz) { (void)hz; } +void probe_router_core1(void) {} +void probe_router_publish(const uint8_t values[PROBE_ROUTER_SLOTS], uint8_t slot) { (void)values; (void)slot; } +void probe_router_enable(bool enabled) { (void)enabled; } +bool probe_router_set_phase(uint32_t phase) { (void)phase; return true; } +void probe_router_snapshot(probe_router_stats* snapshot) { memset(snapshot,0,sizeof(*snapshot)); snapshot->ready = 1; } +int probe_debug_printf(const char* format, ...) { (void)format; return 0; } + +const uint8_t* native_joycon_device_descriptor(uint8_t instance) { (void)instance; return hub_device; } +const uint8_t* native_joycon_configuration_descriptor(uint8_t instance) { (void)instance; return hub_configuration; } +const uint16_t* native_joycon_string_descriptor(uint8_t instance, uint8_t index, uint16_t language) { + (void)instance; (void)language; return hub_string(index); +} +void native_joycon_usb_reset(uint8_t instance) { (void)instance; } +const uint8_t* tud_hid_descriptor_report_cb(uint8_t instance) { (void)instance; return NULL; } +uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t id, hid_report_type_t type, uint8_t* data, uint16_t length) { + (void)instance; (void)id; (void)type; (void)data; (void)length; return 0; +} +void tud_hid_set_report_cb(uint8_t instance, uint8_t id, hid_report_type_t type, const uint8_t* data, uint16_t length) { + (void)instance; (void)id; (void)type; (void)data; (void)length; +} +void tud_hid_report_complete_cb(uint8_t instance, const uint8_t* data, uint16_t length) { (void)instance; (void)data; (void)length; } +void tud_vendor_rx_cb(uint8_t instance, const uint8_t* data, uint16_t length) { (void)instance; (void)data; (void)length; } +void tud_vendor_tx_cb(uint8_t instance, uint32_t length) { (void)instance; (void)length; } + +void native_test_initialize(void) { + memset(devices,0,sizeof(devices)); + memset(ports,0,sizeof(ports)); + memset(usb_hw,0,sizeof(*usb_hw)); + memset(usb_dpram,0,sizeof(*usb_dpram)); + event_head = event_tail = 0; + failed = bus_suspended = bank_restore_pending = false; + bank_lock = spin_lock_instance(0); + active_device = default_device = 0; + addresses[0] = 0; addresses[1] = 1; addresses[2] = 2; + started = root_configured_once = true; +} + +static bool select_slot(uint8_t slot) { + if (!native_hub_select_device(addresses[slot],slot,UINT32_MAX / 2)) return false; + restore_selected_bank(); + return true; +} + +void native_test_drain(void) { native_hub_task(); } + +bool native_test_setup(uint8_t slot, const tusb_control_request_t* request, bool drain) { + if (!select_slot(slot)) return false; + memcpy(usb_dpram->setup_packet,request,sizeof(*request)); + usb_hw->sie_status = USB_SIE_STATUS_SETUP_REC_BITS; + usb_hw->ints = USB_INTS_SETUP_REQ_BITS; + usb_interrupt(); + usb_hw->ints = 0; + if (drain) native_hub_task(); + return !failed && devices[slot].control.stage != STALLED; +} + +bool native_test_out(uint8_t slot, const uint8_t* data, uint16_t length, bool drain) { + if (!select_slot(slot)) return false; + uint32_t value = buffer_regs()[1]; + if (!(value & USB_BUF_CTRL_AVAIL) || (value & USB_BUF_CTRL_STALL) || + length > (value & USB_BUF_CTRL_LEN_MASK)) return false; + if (length) copy_to_usb(usb_dpram->ep0_buf_a,data,length); + buffer_regs()[1] = (value & ~(USB_BUF_CTRL_AVAIL | USB_BUF_CTRL_LEN_MASK)) | length; + usb_hw->buf_status = 2; + usb_hw->ints = USB_INTS_BUFF_STATUS_BITS; + usb_interrupt(); + usb_hw->ints = 0; + if (drain) native_hub_task(); + return !failed && devices[slot].control.stage != STALLED; +} + +bool native_test_in(uint8_t slot, uint8_t* data, uint16_t* length, bool drain) { + if (!select_slot(slot)) return false; + uint32_t value = buffer_regs()[0]; + if (!(value & USB_BUF_CTRL_AVAIL) || !(value & USB_BUF_CTRL_FULL) || + (value & USB_BUF_CTRL_STALL)) return false; + *length = value & USB_BUF_CTRL_LEN_MASK; + if (*length) copy_from_usb(data,usb_dpram->ep0_buf_a,*length); + buffer_regs()[0] = value & ~USB_BUF_CTRL_AVAIL; + usb_hw->buf_status = 1; + usb_hw->ints = USB_INTS_BUFF_STATUS_BITS; + usb_interrupt(); + usb_hw->ints = 0; + if (drain) native_hub_task(); + return !failed && devices[slot].control.stage != STALLED; +} + +void native_test_bus_reset(bool drain) { + usb_hw->sie_status = USB_SIE_STATUS_BUS_RESET_BITS; + usb_hw->ints = USB_INTS_BUS_RESET_BITS; + usb_interrupt(); + usb_hw->ints = 0; + if (drain) native_hub_task(); + // Assign fixture addresses after reset, independently of EP0 state. + addresses[0] = 0; addresses[1] = 1; addresses[2] = 2; +} diff --git a/tests/switch2_native_gamepad_bridge_test.cpp b/tests/switch2_native_gamepad_bridge_test.cpp index 786bd96..3827dd1 100644 --- a/tests/switch2_native_gamepad_bridge_test.cpp +++ b/tests/switch2_native_gamepad_bridge_test.cpp @@ -358,6 +358,36 @@ void wii_bias_and_independent_sensor_freshness() { } } +void nunchuk_buttons_map_to_native_left_shoulders() { + ++source.controller.connection_generation; + source.controller.state = {}; + source.accel_valid = source.gyro_valid = false; + profile = controller_profile_default(controller_identity_global(), 0); + // The real Wii parser maps Nunchuk C to west and Z to north. These are + // ordinary profile inputs, not the unrelated Switch2 extra "C" control. + profile.button_map[static_cast(ControllerProfileLogicalButton::kWest)] = + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; + profile.button_map[static_cast(ControllerProfileLogicalButton::kNorth)] = + static_cast(ControllerProfileLogicalButton::kLeftShoulder); + source.controller.state.button_west = true; // C -> ZL. + publish(false); pair(); + assert(reports[0][2] == 0 && reports[1][2] == 0x20); + source.controller.state.button_west = false; + source.controller.state.button_north = true; // Z -> L. + publish(false); pair(); + assert(reports[0][2] == 0 && reports[1][2] == 0x10); + source.controller.state.button_right_shoulder = true; // Remote 2 -> R. + publish(false); pair(); + assert(reports[0][2] == 0x10 && reports[1][2] == 0x10); // Real L+R across the pair. + source.controller.state.button_west = true; + publish(false); pair(); + assert(reports[0][2] == 0x10 && reports[1][2] == 0x30); + source.controller.state = {}; + publish(false); pair(); + assert(reports[0][2] == 0 && reports[1][2] == 0); // No sticky synthetic chord. + no_mouse_or_rails(); +} + } // namespace int main() { @@ -372,5 +402,6 @@ int main() { if (SWITCH2_BRIDGE_IMU_TARGET_MASK == 3) real_motion_admission_and_loss(); selected_motion_target_keeps_both_control_halves(); wii_bias_and_independent_sensor_freshness(); + nunchuk_buttons_map_to_native_left_shoulders(); return 0; } diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 779934c..86048d6 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -41,6 +41,7 @@ class FakeDevice: bus = 1 address = 7 port_numbers = (1,) + firmware_version = (0, 2, 0) def __init__(self) -> None: self.configuration = struct.pack( @@ -300,9 +301,7 @@ class FakeDevice: request, bytes( [ - 0, - 2, - 0, + *self.firmware_version, 2, self.active_mode, self.capabilities, @@ -376,7 +375,9 @@ class FakeDevice: make_response( request, self.profiles[self.selected_profile], - schema=struct.unpack_from(" bytes: return ( struct.pack( - " N config_manager.write_configuration( device, config_manager.AdapterConfiguration( - 90, 0, 0, config_manager.REQUESTED_MODE_XINPUT, tuple(reversed(identities)), + 90, + 0, + 0, + config_manager.REQUESTED_MODE_XINPUT, + tuple(reversed(identities)), schema_version=schema, ), 1.0, @@ -830,7 +839,8 @@ def test_native_rumble_configuration_rejects_invalid_lists(malformation: str) -> @pytest.mark.parametrize( - "malformation", ("duplicate", "overflow", "global", "ble", "pair", "vendor", "product") + "malformation", + ("duplicate", "overflow", "global", "ble", "pair", "vendor", "product"), ) def test_native_rumble_write_rejects_invalid_approvals_before_transaction( malformation: str, @@ -865,11 +875,16 @@ def test_native_rumble_write_rejects_invalid_approvals_before_transaction( @pytest.mark.parametrize( ("schema", "joycon_mode"), - ((3, config_manager.JOYCON_MODE_PAIRED), (4, config_manager.JOYCON_MODE_INDIVIDUAL)), + ( + (3, config_manager.JOYCON_MODE_PAIRED), + (4, config_manager.JOYCON_MODE_INDIVIDUAL), + ), ) def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], - schema: int, joycon_mode: int, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + schema: int, + joycon_mode: int, ) -> None: device = FakeDevice() device.configuration_schema = schema @@ -1038,8 +1053,10 @@ def test_configuration_transaction_and_reset() -> None: ), ) def test_joycon_mode_legacy_read_and_write_refusal( - schema: int, payload: bytes, - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + schema: int, + payload: bytes, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], ) -> None: device = FakeDevice() device.configuration_schema = schema @@ -1047,7 +1064,9 @@ def test_joycon_mode_legacy_read_and_write_refusal( monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,)) assert config_manager.main(["joycon-mode", "--json"]) == 0 assert json.loads(capsys.readouterr().out) == { - "mode": "paired", "generation": 3, "supported": False, + "mode": "paired", + "generation": 3, + "supported": False, } configuration = config_manager.read_configuration(device) assert configuration.joycon_mode == config_manager.JOYCON_MODE_PAIRED @@ -1058,7 +1077,8 @@ def test_joycon_mode_legacy_read_and_write_refusal( assert capsys.readouterr().out == "" with pytest.raises(config_manager.ConfigManagerError): config_manager.write_configuration( - device, replace(configuration, joycon_mode=config_manager.JOYCON_MODE_INDIVIDUAL), + device, + replace(configuration, joycon_mode=config_manager.JOYCON_MODE_INDIVIDUAL), 1.0, ) assert device.configuration == payload @@ -1097,12 +1117,15 @@ def test_joycon_mode_rejects_invalid_values_before_transaction(mode: object) -> with pytest.raises(config_manager.ConfigManagerError): config_manager.set_joycon_mode(device, mode, 1.0) with pytest.raises(config_manager.ConfigManagerError): - config_manager.write_configuration(device, replace(before, joycon_mode=mode), 1.0) + config_manager.write_configuration( + device, replace(before, joycon_mode=mode), 1.0 + ) assert not device.out_requests def test_joycon_mode_cli_commits_and_reads_without_reboot_or_profile_changes( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], ) -> None: device = FakeDevice() device.configuration_schema = 4 @@ -1140,7 +1163,8 @@ def test_joycon_mode_cli_commits_and_reads_without_reboot_or_profile_changes( def test_joycon_mode_cli_commit_failure_does_not_claim_new_preference( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], ) -> None: device = FakeDevice() device.configuration_schema = 4 @@ -1627,19 +1651,53 @@ def test_requested_and_active_mode_response_validation() -> None: config_manager.read_info(device) +def test_native_hub_reports_capabilities_but_refuses_mode_changes() -> None: + device = FakeDevice() + device.firmware_version = (0, 72, 0) + device.active_mode = 5 + device.capabilities = 7 + before = config_manager.read_configuration(device) + + info = config_manager.read_info(device) + assert info.firmware_version == (0, 72, 0) + assert info.mode_name() == "Native Joy-Con hub" + assert info.capability_names() == ("input", "rumble", "motion") + with pytest.raises(config_manager.ConfigManagerError, match="fixed USB output"): + config_manager.configure_mode(device, before.requested_mode, 1.0) + with pytest.raises(config_manager.ConfigManagerError, match="fixed USB output"): + config_manager.set_mode(device, config_manager.REQUESTED_MODE_SWITCH, 1.0) + with pytest.raises(config_manager.ConfigManagerError, match="fixed USB output"): + config_manager.request_reboot(device, 1) + assert config_manager.read_configuration(device) == before + assert device.out_requests == [] + + config_manager.request_bootsel_reboot(device) + assert device.bootsel_reboot_requested + + @pytest.mark.parametrize( ("left_type", "right_type", "flags"), [(0, 0, 1), (1, 0, 3), (0, 1, 5), (1, 1, 7)] ) def test_joycon_pair_wire_round_trip_preserves_both_typed_members( - left_type: int, right_type: int, flags: int, + left_type: int, + right_type: int, + flags: int, ) -> None: left = config_manager.ControllerIdentity( - True, config_manager.TRANSPORT_BLE, left_type, - bytes.fromhex("C10203040506"), 0x057E, 0x2067, + True, + config_manager.TRANSPORT_BLE, + left_type, + bytes.fromhex("C10203040506"), + 0x057E, + 0x2067, ) right = config_manager.ControllerIdentity( - True, config_manager.TRANSPORT_BLE, right_type, - bytes.fromhex("D11213141516"), 0x057E, 0x2066, + True, + config_manager.TRANSPORT_BLE, + right_type, + bytes.fromhex("D11213141516"), + 0x057E, + 0x2066, ) pair = config_manager.ControllerIdentity.make_joycon_pair(left, right) wire = bytes((flags, 3)) + left.address + right.address @@ -1653,34 +1711,51 @@ def test_joycon_pair_wire_round_trip_preserves_both_typed_members( def test_joycon_pair_distinguishes_address_types_without_normalizing_members() -> None: left = config_manager.ControllerIdentity( - True, config_manager.TRANSPORT_BLE, 0, - bytes.fromhex("C10203040506"), 0x057E, 0x2067, + True, + config_manager.TRANSPORT_BLE, + 0, + bytes.fromhex("C10203040506"), + 0x057E, + 0x2067, ) right = replace(left, address_type=1, product_id=0x2066) pair = config_manager.ControllerIdentity.make_joycon_pair(left, right) assert pair.to_bytes() == bytes.fromhex("0503C10203040506C10203040506") assert pair.joycon_pair_members() == (left, right) with pytest.raises(config_manager.ConfigManagerError): - config_manager.ControllerIdentity.make_joycon_pair(left, replace(right, address_type=0)) + config_manager.ControllerIdentity.make_joycon_pair( + left, replace(right, address_type=0) + ) with pytest.raises(config_manager.ConfigManagerError): config_manager.ControllerIdentity.make_joycon_pair(right, left) -@pytest.mark.parametrize("wire", [ - "0003102030405060C12233445566", # Stable flag missing. - "0D03102030405060C12233445566", # Reserved flag. - "0303102030405060C12233445566", # Left random address is not static. - "0503C12233445566102030405060", # Right random address is not static. - "0103102030405060102030405060", # Duplicate typed members. -]) +@pytest.mark.parametrize( + "wire", + [ + "0003102030405060C12233445566", # Stable flag missing. + "0D03102030405060C12233445566", # Reserved flag. + "0303102030405060C12233445566", # Left random address is not static. + "0503C12233445566102030405060", # Right random address is not static. + "0103102030405060102030405060", # Duplicate typed members. + ], +) def test_joycon_pair_rejects_malformed_wire(wire: str) -> None: with pytest.raises(config_manager.ConfigManagerError): config_manager.ControllerIdentity.from_bytes(bytes.fromhex(wire)) -@pytest.mark.parametrize("malformation", [ - "global", "classic", "vendor", "model", "address_type", "random_address", -]) +@pytest.mark.parametrize( + "malformation", + [ + "global", + "classic", + "vendor", + "model", + "address_type", + "random_address", + ], +) def test_joycon_pair_requires_legitimate_ble_members(malformation: str) -> None: pair = config_manager.ControllerIdentity.from_bytes( bytes.fromhex("0503102030405060C12233445566") @@ -1698,14 +1773,19 @@ def test_joycon_pair_requires_legitimate_ble_members(malformation: str) -> None: config_manager.ControllerIdentity.make_joycon_pair(left, invalid) -@pytest.mark.parametrize("fields", [ - {"stable": False}, - {"vendor_id": 0x045E}, - {"product_id": 0x2066}, - {"address_type": 2}, - {"partner_address_type": 3}, -]) -def test_joycon_pair_rejects_inconsistent_in_memory_identity(fields: dict[str, object]) -> None: +@pytest.mark.parametrize( + "fields", + [ + {"stable": False}, + {"vendor_id": 0x045E}, + {"product_id": 0x2066}, + {"address_type": 2}, + {"partner_address_type": 3}, + ], +) +def test_joycon_pair_rejects_inconsistent_in_memory_identity( + fields: dict[str, object], +) -> None: pair = config_manager.ControllerIdentity.from_bytes( bytes.fromhex("0503102030405060C12233445566") ) @@ -1713,10 +1793,13 @@ def test_joycon_pair_rejects_inconsistent_in_memory_identity(fields: dict[str, o replace(pair, **fields) -@pytest.mark.parametrize("fields", [ - {"partner_address_type": 1}, - {"partner_address": bytes.fromhex("C12233445566")}, -]) +@pytest.mark.parametrize( + "fields", + [ + {"partner_address_type": 1}, + {"partner_address": bytes.fromhex("C12233445566")}, + ], +) def test_physical_identity_cannot_hide_pair_members(fields: dict[str, object]) -> None: with pytest.raises(config_manager.ConfigManagerError): replace(native_rumble_identity(), **fields) @@ -1724,13 +1807,16 @@ def test_physical_identity_cannot_hide_pair_members(fields: dict[str, object]) - def test_pairing_inventory_rejects_logical_profile_owners() -> None: device = FakeDevice() - device.records = [(config_manager.TRANSPORT_JOYCON_PAIR, 0, bytes.fromhex("102030405060"))] + device.records = [ + (config_manager.TRANSPORT_JOYCON_PAIR, 0, bytes.fromhex("102030405060")) + ] with pytest.raises(config_manager.ConfigManagerError): config_manager.read_pairings(device) def test_profile_cli_lists_both_pair_addresses_but_native_inventory_omits_pair( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], ) -> None: device = FakeDevice() pair = config_manager.ControllerIdentity.from_bytes( @@ -1870,8 +1956,14 @@ def test_identity_and_profile_binary_json_round_trip() -> None: legacy_json_object["schema_version"] = config_manager.PROFILE_LEGACY_SCHEMA_VERSION legacy_json_object["size"] = config_manager.PROFILE_LEGACY_SIZE for field in ( - "shortcuts", "shift", "turbo_settings", "extra_button_map", "swing", - "nunchuk_swing", "combined_swing", "combination_window_ms", + "shortcuts", + "shift", + "turbo_settings", + "extra_button_map", + "swing", + "nunchuk_swing", + "combined_swing", + "combination_window_ms", ): del legacy_json_object[field] del legacy_json_object["motion_toggle_chord"] @@ -1936,8 +2028,14 @@ def test_schema5_full_macro_stream_migrates_bytes_and_json(monkeypatch) -> None: obj["schema_version"] = 5 obj["size"] = 256 for field in ( - "shortcuts", "shift", "turbo_settings", "extra_button_map", "swing", - "nunchuk_swing", "combined_swing", "combination_window_ms", + "shortcuts", + "shift", + "turbo_settings", + "extra_button_map", + "swing", + "nunchuk_swing", + "combined_swing", + "combination_window_ms", ): del obj[field] for macro in obj["macros"]: @@ -1997,31 +2095,53 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None: device.profiles[(device.stable_identity.to_bytes(), 1)] = bytes(legacy_wire) assert config_manager.read_profile(device, device.stable_identity, 1) == profile device.profile_aliases[device.stable_identity.to_bytes()] = "Custom controller" - old_listing = config_manager.parse_profile_list(config_manager.parse_response( - make_response(config_manager.OP_PROFILE_LIST, device._profile_list_payload(), schema=6), - config_manager.OP_PROFILE_LIST, - )) - assert old_listing[1] == config_manager.ProfileListEntry(device.stable_identity, 1, "Custom controller") + old_listing = config_manager.parse_profile_list( + config_manager.parse_response( + make_response( + config_manager.OP_PROFILE_LIST, device._profile_list_payload(), schema=6 + ), + config_manager.OP_PROFILE_LIST, + ) + ) + assert old_listing[1] == config_manager.ProfileListEntry( + device.stable_identity, 1, "Custom controller" + ) @pytest.mark.parametrize("version", [7, 8, 9]) -def test_schema7_extra_controls_keep_output_channels_and_wire_layout(version: int) -> None: +def test_schema7_extra_controls_keep_output_channels_and_wire_layout( + version: int, +) -> None: obj = custom_profile().to_json_object() - obj["extra_button_map"] = dict(zip( - config_manager.EXTRA_BUTTONS, - ("south", "right_trigger", "left_trigger", None, "dpad_left", "start", "capture"), - )) + obj["extra_button_map"] = dict( + zip( + config_manager.EXTRA_BUTTONS, + ( + "south", + "right_trigger", + "left_trigger", + None, + "dpad_left", + "start", + "capture", + ), + ) + ) obj["shift"]["mode"] = "hold" obj["shift"]["modifier"] = "gl" - obj["shift"]["extra_button_map"] = dict(zip( - config_manager.EXTRA_BUTTONS, - ("east", None, "west", "north", "system", "dpad_up", "dpad_right"), - )) + obj["shift"]["extra_button_map"] = dict( + zip( + config_manager.EXTRA_BUTTONS, + ("east", None, "west", "north", "system", "dpad_up", "dpad_right"), + ) + ) obj["shortcuts"]["modifier"] = "left_sr" obj["shortcuts"]["profiles"][0] = "south" obj["switching_chord"] = ["left_trigger", "c", "right_sr"] obj["motion_toggle_chord"] = ["right_trigger", "gl", "left_sl"] - for index, names in enumerate((["c", "gl"], ["gr"], ["left_sl", "left_sr"], ["right_sl", "right_sr"])): + for index, names in enumerate( + (["c", "gl"], ["gr"], ["left_sl", "left_sr"], ["right_sl", "right_sr"]) + ): obj["macros"][index]["trigger"] = names obj["macros"][index]["cancel"] = config_manager.EXTRA_BUTTONS[index + 3] obj["schema_version"] = version @@ -2040,7 +2160,12 @@ def test_schema7_extra_controls_keep_output_channels_and_wire_layout(version: in assert encoded[358:364] == bytes((3, 4, 24, 96, 65, 10)) assert config_manager.ControllerProfile.from_bytes(encoded) == profile assert config_manager.ControllerProfile.from_json(profile.to_json()) == profile - assert len(profile.button_map) == len(profile.shift.button_map) == len(profile.turbo_modes) == 16 + assert ( + len(profile.button_map) + == len(profile.shift.button_map) + == len(profile.turbo_modes) + == 16 + ) assert profile.swing.button == config_manager.PROFILE_NONE_BUTTON legacy_wire = bytearray(encoded) struct.pack_into(" None: obj = config_manager.ControllerProfile.default().to_json_object() target = obj @@ -2075,10 +2209,20 @@ def test_extra_controls_cannot_be_output_destinations(path: tuple[str, ...]) -> config_manager.ControllerProfile.from_json_object(obj) -@pytest.mark.parametrize("offset,value", [ - (98, 18 << 2), (256, 18), (266, 24), (344, 1), (358, 1), (363, 1), -]) -def test_schema6_rejects_schema7_controls_in_old_fields(offset: int, value: int) -> None: +@pytest.mark.parametrize( + "offset,value", + [ + (98, 18 << 2), + (256, 18), + (266, 24), + (344, 1), + (358, 1), + (363, 1), + ], +) +def test_schema6_rejects_schema7_controls_in_old_fields( + offset: int, value: int +) -> None: payload = bytearray(config_manager.ControllerProfile.default().to_bytes()) struct.pack_into(" None: payload = bytearray(config_manager.ControllerProfile.default().to_bytes()) struct.pack_into(" None: macro["steps"].append(config_manager.MacroStep.end().to_json_object()) obj["macro"] = macro for key in ( - "shortcuts", "shift", "turbo_settings", "extra_button_map", "swing", - "nunchuk_swing", "combined_swing", "combination_window_ms", + "shortcuts", + "shift", + "turbo_settings", + "extra_button_map", + "swing", + "nunchuk_swing", + "combined_swing", + "combination_window_ms", ): del obj[key] assert config_manager.ControllerProfile.from_json_object(obj) == profile assert config_manager.ControllerProfile.from_bytes(profile.to_bytes()) == profile -@pytest.mark.parametrize(("button", "sensitivity", "modifier"), [ - ("south", "low", "right_sr"), - ("dpad_right", "high", "south"), - ("west", "medium", "right_trigger"), - (None, "high", None), -]) +@pytest.mark.parametrize( + ("button", "sensitivity", "modifier"), + [ + ("south", "low", "right_sr"), + ("dpad_right", "high", "south"), + ("west", "medium", "right_trigger"), + (None, "high", None), + ], +) def test_swing_profile_round_trip_preserves_other_settings( - button: str | None, sensitivity: str, modifier: str | None, + button: str | None, + sensitivity: str, + modifier: str | None, ) -> None: before = custom_profile() obj = before.to_json_object() obj["swing"] = { - "button": button, "sensitivity": sensitivity, "modifier": modifier, + "button": button, + "sensitivity": sensitivity, + "modifier": modifier, "macro": None, } profile = config_manager.ControllerProfile.from_json_object(obj) @@ -2162,17 +2327,23 @@ def test_swing_profile_round_trip_preserves_other_settings( assert config_manager.read_profile(device, device.stable_identity, 2) == profile -@pytest.mark.parametrize(("field", "value"), [ - ("button", "left_trigger"), - ("button", "right_sr"), - ("button", 0), - ("sensitivity", "extreme"), - ("sensitivity", None), - ("sensitivity", 1), - ("modifier", "unknown"), - ("modifier", False), - ("macro", 0), ("macro", 5), ("macro", True), ("macro", "1"), -]) +@pytest.mark.parametrize( + ("field", "value"), + [ + ("button", "left_trigger"), + ("button", "right_sr"), + ("button", 0), + ("sensitivity", "extreme"), + ("sensitivity", None), + ("sensitivity", 1), + ("modifier", "unknown"), + ("modifier", False), + ("macro", 0), + ("macro", 5), + ("macro", True), + ("macro", "1"), + ], +) def test_swing_rejects_invalid_json_settings(field: str, value: object) -> None: obj = config_manager.ControllerProfile.default().to_json_object() obj["swing"][field] = value @@ -2180,22 +2351,47 @@ def test_swing_rejects_invalid_json_settings(field: str, value: object) -> None: config_manager.ControllerProfile.from_json_object(obj) -@pytest.mark.parametrize(("field", "value"), [ - ("button", -1), ("button", 16), ("button", True), - ("sensitivity", -1), ("sensitivity", 3), ("sensitivity", False), - ("modifier", -1), ("modifier", 25), ("modifier", 256), - ("macro", -1), ("macro", 4), ("macro", False), -]) +@pytest.mark.parametrize( + ("field", "value"), + [ + ("button", -1), + ("button", 16), + ("button", True), + ("sensitivity", -1), + ("sensitivity", 3), + ("sensitivity", False), + ("modifier", -1), + ("modifier", 25), + ("modifier", 256), + ("macro", -1), + ("macro", 4), + ("macro", False), + ], +) def test_swing_rejects_invalid_in_memory_settings(field: str, value: object) -> None: with pytest.raises(config_manager.ConfigManagerError): replace(config_manager.ProfileSwing(), **{field: value}) -@pytest.mark.parametrize(("offset", "value"), [ - (364, 16), (365, 3), (366, 25), (367, 4), - (368, 16), (369, 3), (370, 25), (371, 4), - (372, 16), (373, 4), (374, 25), (375, 29), (375, 201), (376, 1), -]) +@pytest.mark.parametrize( + ("offset", "value"), + [ + (364, 16), + (365, 3), + (366, 25), + (367, 4), + (368, 16), + (369, 3), + (370, 25), + (371, 4), + (372, 16), + (373, 4), + (374, 25), + (375, 29), + (375, 201), + (376, 1), + ], +) def test_swing_rejects_corrupt_wire_settings(offset: int, value: int) -> None: payload = bytearray(config_manager.ControllerProfile.default().to_bytes()) payload[offset] = value @@ -2203,11 +2399,18 @@ def test_swing_rejects_corrupt_wire_settings(offset: int, value: int) -> None: config_manager.ControllerProfile.from_bytes(payload) -@pytest.mark.parametrize("mutation", [ - "missing", "unknown", "missing_button", "missing_sensitivity", "missing_modifier", - "missing_macro", - "legacy_field", -]) +@pytest.mark.parametrize( + "mutation", + [ + "missing", + "unknown", + "missing_button", + "missing_sensitivity", + "missing_modifier", + "missing_macro", + "legacy_field", + ], +) def test_swing_json_fields_are_strict(mutation: str) -> None: obj = config_manager.ControllerProfile.default().to_json_object() if mutation == "missing": @@ -2246,7 +2449,9 @@ def test_schema8_swing_migration_preserves_remote_binding() -> None: def test_gesture_macro_json_indices_and_wire_layout(macro_number: int) -> None: obj = custom_profile().to_json_object() obj["macros"][macro_number - 1] = { - **obj["macros"][0], "trigger": [], "playback": "toggle", + **obj["macros"][0], + "trigger": [], + "playback": "toggle", } obj["swing"].update(macro=macro_number, sensitivity="high", modifier="right_sr") obj["nunchuk_swing"].update(button="dpad_right", sensitivity="low", modifier="c") @@ -2255,10 +2460,22 @@ def test_gesture_macro_json_indices_and_wire_layout(macro_number: int) -> None: profile = config_manager.ControllerProfile.from_json_object(obj) assert profile.swing.macro == profile.combined_swing.macro == macro_number - 1 encoded = profile.to_bytes() - assert encoded[364:376] == bytes(( - 255, 2, 24, macro_number - 1, 15, 0, 18, 255, - 255, macro_number - 1, 16, 200, - )) + assert encoded[364:376] == bytes( + ( + 255, + 2, + 24, + macro_number - 1, + 15, + 0, + 18, + 255, + 255, + macro_number - 1, + 16, + 200, + ) + ) assert encoded[376:] == bytes(8) assert config_manager.ControllerProfile.from_bytes(encoded) == profile assert profile.to_json_object() == obj @@ -2271,14 +2488,17 @@ def test_gesture_actions_are_exclusive(gesture: str) -> None: with pytest.raises(config_manager.ConfigManagerError): config_manager.ControllerProfile.from_json_object(obj) action_type = ( - config_manager.ProfileCombinedSwing if gesture == "combined_swing" + config_manager.ProfileCombinedSwing + if gesture == "combined_swing" else config_manager.ProfileSwing ) with pytest.raises(config_manager.ConfigManagerError): action_type(button=0, macro=0) payload = bytearray(custom_profile().to_bytes()) button_offset, macro_offset = { - "swing": (364, 367), "nunchuk_swing": (368, 371), "combined_swing": (372, 373), + "swing": (364, 367), + "nunchuk_swing": (368, 371), + "combined_swing": (372, 373), }[gesture] payload[button_offset] = payload[macro_offset] = 0 with pytest.raises(config_manager.ConfigManagerError): @@ -2288,7 +2508,8 @@ def test_gesture_actions_are_exclusive(gesture: str) -> None: @pytest.mark.parametrize("gesture", ["swing", "nunchuk_swing", "combined_swing"]) @pytest.mark.parametrize("empty", [False, True]) def test_gesture_macro_requires_configured_positive_duration_target( - gesture: str, empty: bool, + gesture: str, + empty: bool, ) -> None: obj = custom_profile().to_json_object() target = obj["macros"][0] @@ -2315,17 +2536,26 @@ def test_combination_window_inclusive_bounds_round_trip(window: int) -> None: obj = config_manager.ControllerProfile.default().to_json_object() obj["combination_window_ms"] = window profile = config_manager.ControllerProfile.from_json_object(obj) - assert config_manager.ControllerProfile.from_bytes(profile.to_bytes()).combination_window_ms == window + assert ( + config_manager.ControllerProfile.from_bytes( + profile.to_bytes() + ).combination_window_ms + == window + ) @pytest.mark.parametrize("window", [29, 201, True, 100.0, None]) -def test_combination_window_rejects_out_of_range_or_noninteger_values(window: object) -> None: +def test_combination_window_rejects_out_of_range_or_noninteger_values( + window: object, +) -> None: obj = config_manager.ControllerProfile.default().to_json_object() obj["combination_window_ms"] = window with pytest.raises(config_manager.ConfigManagerError): config_manager.ControllerProfile.from_json_object(obj) with pytest.raises(config_manager.ConfigManagerError): - replace(config_manager.ControllerProfile.default(), combination_window_ms=window) + replace( + config_manager.ControllerProfile.default(), combination_window_ms=window + ) @pytest.mark.parametrize( @@ -2610,20 +2840,31 @@ def test_profile_playtest_decodes_raw_controller_state() -> None: def test_playtest_layout_and_extra_inputs_preserve_legacy_firmware() -> None: device = FakeDevice() - device.stable_identity = replace(device.stable_identity, vendor_id=0x057E, product_id=0x2067) + device.stable_identity = replace( + device.stable_identity, vendor_id=0x057E, product_id=0x2067 + ) device.playtest_extra_buttons = 0x55 device.playtest_layout = 3 payload, flags = device._profile_playtest_payload() def parse(data: bytes, schema: int) -> config_manager.ProfilePlaytest: - return config_manager.parse_profile_playtest(config_manager.parse_response( - make_response(config_manager.OP_PROFILE_PLAYTEST, data, flags=flags, schema=schema), - config_manager.OP_PROFILE_PLAYTEST, - )) + return config_manager.parse_profile_playtest( + config_manager.parse_response( + make_response( + config_manager.OP_PROFILE_PLAYTEST, data, flags=flags, schema=schema + ), + config_manager.OP_PROFILE_PLAYTEST, + ) + ) current = parse(payload, 4) assert current.to_json_object()["layout"] == "joycon2-pair" - assert current.to_json_object()["extra_buttons"] == ["c", "gr", "left_sr", "right_sr"] + assert current.to_json_object()["extra_buttons"] == [ + "c", + "gr", + "left_sr", + "right_sr", + ] assert current.to_json_object()["buttons"] == ["south", "dpad_up", "dpad_right"] assert parse(payload[:55], 3) == replace(current, layout=None) assert parse(payload[:54], 2) == replace(current, extra_buttons=0, layout=None) @@ -3085,31 +3326,80 @@ def test_mode_parser_accepts_all_implemented_modes() -> None: assert args.mode == mode -def test_candidate_discovery_checks_all_usb_identities( - monkeypatch: pytest.MonkeyPatch, -) -> None: - lookups: list[tuple[int, int]] = [] - - assert config_manager.USB_IDENTITIES == ( +@pytest.mark.parametrize( + "identity", + [ (0x057E, 0x2009), + (0x057E, 0x2068), (0xCAFE, 0x4010), (0xCAFE, 0x4020), (0xCAFE, 0x4021), - ) + ], +) +def test_discovery_finds_one_adapter_without_its_native_children( + monkeypatch: pytest.MonkeyPatch, + identity: tuple[int, int], +) -> None: + device = FakeDevice() + if identity == (0x057E, 0x2068): + device.firmware_version = (0, 72, 0) + device.active_mode = 5 + right = FakeDevice() + right.address = 8 + left = FakeDevice() + left.address = 9 + devices = { + identity: (device,), + (0x057E, 0x2066): (right,), + (0x057E, 0x2067): (left,), + } - def find(**arguments: object) -> tuple[object, ...]: - lookups.append( - ( - int(arguments["idVendor"]), - int(arguments["idProduct"]), - ) + def find(**arguments: object) -> tuple[FakeDevice, ...]: + return devices.get( + (int(arguments["idVendor"]), int(arguments["idProduct"])), () ) + + monkeypatch.setattr(config_manager.usb.core, "find", find) + assert config_manager.find_pico(None, None, timeout=0) is device + for child in (right, left): + with pytest.raises(config_manager.ConfigManagerError, match="no USB-connected"): + config_manager.find_pico(child.bus, child.address, timeout=0) + + +@pytest.mark.parametrize( + "response", + [ + config_manager.usb.core.USBError("management request stalled", errno=32), + b"Nintendo", + make_response(config_manager.OP_INFO, b""), + make_response(config_manager.OP_INFO, bytes((0, 72, 0, 2, 5, 0x80, 0, 2))), + ], +) +def test_native_discovery_requires_validated_management_info( + monkeypatch: pytest.MonkeyPatch, + response: bytes | Exception, +) -> None: + class NintendoDevice(FakeDevice): + address = 8 + + def ctrl_transfer(self, *args: object, **kwargs: object) -> bytes: + if isinstance(response, Exception): + raise response + return response + + nintendo = NintendoDevice() + hub = FakeDevice() + hub.active_mode = 5 + + def find(**arguments: object) -> tuple[FakeDevice, ...]: + if (arguments["idVendor"], arguments["idProduct"]) == (0x057E, 0x2068): + return nintendo, hub return () monkeypatch.setattr(config_manager.usb.core, "find", find) - - assert list(config_manager._candidate_devices()) == [] - assert tuple(lookups) == config_manager.USB_IDENTITIES + assert config_manager.find_pico(None, None, timeout=0) is hub + with pytest.raises(config_manager.ConfigManagerError, match="none accepted"): + config_manager.find_pico(nintendo.bus, nintendo.address, timeout=0) def test_find_requires_selector_for_multiple_picos( @@ -4241,7 +4531,9 @@ def test_haptics_fixture_metadata_follows_reported_frame_count( ) pattern = snapshot.to_json_object()["pattern"] assert pattern["stereo_frames_per_packet"] == packet_frames - assert pattern["packet_interval_us"] == pytest.approx(packet_frames * 1000000 / 3000) + assert pattern["packet_interval_us"] == pytest.approx( + packet_frames * 1000000 / 3000 + ) assert pattern["total_packets"] == total_packets assert pattern["priming_silence_packets"] == silence_packets assert pattern["trailing_silence_packets"] == silence_packets @@ -4261,7 +4553,9 @@ def test_haptics_fixture_metadata_follows_reported_frame_count( @pytest.mark.parametrize("mode", [0, 1]) @pytest.mark.parametrize("packet_frames", [0, 48]) -def test_haptics_rejects_unadvertised_frame_sizes(mode: int, packet_frames: int) -> None: +def test_haptics_rejects_unadvertised_frame_sizes( + mode: int, packet_frames: int +) -> None: device = HapticsDevice( [haptics_response(2, slot=0, mode=mode, packet_frames=packet_frames)] ) diff --git a/tests/test_native_hub_management_native.py b/tests/test_native_hub_management_native.py new file mode 100644 index 0000000..9000bfa --- /dev/null +++ b/tests/test_native_hub_management_native.py @@ -0,0 +1,59 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_native_hub_management_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + cc = shutil.which("cc") or shutil.which("gcc") + cxx = shutil.which("c++") or shutil.which("g++") + assert cc is not None and cxx is not None, "host C and C++ compilers are required" + includes = [ + f"-I{root / 'tests' / 'native_hub_stubs'}", + f"-I{root / 'tests' / 'bluepad32_native_stubs'}", + f"-I{root / 'src' / 'firmware'}", + f"-I{root / 'tools' / 'pico_usb_address_probe'}", + f"-I{root / 'tools' / 'switch2_usb_probe'}", + ] + flags = ["-Wall", "-Wextra", "-Werror", "-pedantic", "-DSWITCH2_PROBE_HUB=1"] + transport = tmp_path / "native_hub_transport.o" + executable = tmp_path / "native_hub_management_test" + subprocess.run( + [ + cc, + "-std=c11", + *flags, + *includes, + "-c", + str(root / "tests" / "native_hub_transport_fixture.c"), + "-o", + str(transport), + ], + check=True, + cwd=root, + ) + sources = [ + "tests/native_hub_management_test.cpp", + "src/firmware/usb/usb_configuration_management.cpp", + "tools/switch2_usb_probe/bootsel.cpp", + "src/firmware/configuration/adapter_configuration.cpp", + "src/firmware/core/controller_identity.cpp", + "src/firmware/profile/controller_profile.cpp", + "src/firmware/profile/profile_storage.cpp", + "src/firmware/profile/profile_service.cpp", + ] + subprocess.run( + [ + cxx, + "-std=c++17", + *flags, + *includes, + *(str(root / path) for path in sources), + str(transport), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_profile_web.py b/tests/test_profile_web.py index 3f1d190..a83b401 100644 --- a/tests/test_profile_web.py +++ b/tests/test_profile_web.py @@ -18,7 +18,7 @@ from tests.test_config_manager import FakeDevice, custom_profile, native_rumble_ @contextmanager def running_server( - monkeypatch: pytest.MonkeyPatch, device: FakeDevice + monkeypatch: pytest.MonkeyPatch, device: FakeDevice | None ) -> Iterator[tuple[str, str]]: server = profile_web.ProfileEditorServer( ("127.0.0.1", 0), @@ -26,7 +26,8 @@ def running_server( device_address=None, timeout=1.0, ) - monkeypatch.setattr(server, "find_device", lambda: device) + if device is not None: + monkeypatch.setattr(server, "find_device", lambda: device) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: @@ -118,13 +119,80 @@ def test_editor_serves_assets_and_complete_schema( ) -@pytest.mark.parametrize(("product_id", "extras"), [ - (0x2069, {"c", "gl", "gr"}), - (0x2067, {"left_sl", "left_sr"}), - (0x2066, {"c", "right_sl", "right_sr"}), -]) +def test_native_hub_editor_discovers_root_and_preserves_saved_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + device.firmware_version = (0, 72, 0) + device.active_mode = 5 + device.capabilities = 7 + device.profiles[(device.global_identity.to_bytes(), 2)] = ( + custom_profile().to_bytes() + ) + configuration = config_manager.read_configuration(device) + pairings = config_manager.read_pairings(device) + profiles = device.profiles.copy() + active_profiles = device.active_profiles.copy() + + def find(**arguments: object) -> tuple[FakeDevice, ...]: + if (arguments["idVendor"], arguments["idProduct"]) == (0x057E, 0x2068): + return (device,) + return () + + monkeypatch.setattr(config_manager.usb.core, "find", find) + with running_server(monkeypatch, None) as (base_url, token): + status, listing = request_json(f"{base_url}/api/profiles") + assert status == 200 + assert [entry["key"] for entry in listing["identities"]] == [ + identity.to_bytes().hex() for identity in device.profile_identities + ] + status, selected = request_json(f"{base_url}/api/profiles/1/8") + assert status == 200 + assert ( + selected["profile"] + == config_manager.ControllerProfile.default().to_json_object() + ) + + draft = custom_profile().to_json_object() + status, _ = request_json( + f"{base_url}/api/profiles/1/8", method="PUT", value=draft, token=token + ) + assert status == 200 + status, _ = request_json( + f"{base_url}/api/profiles/1/8/activate", method="POST", token=token + ) + assert status == 200 + status, stored = request_json(f"{base_url}/api/profiles/1/8") + assert status == 200 + assert stored["profile"] == draft + assert stored["active"] is True + + profiles[(device.stable_identity.to_bytes(), 7)] = custom_profile().to_bytes() + active_profiles[device.stable_identity.to_bytes()] = 7 + assert device.profiles == profiles + assert device.active_profiles == active_profiles + assert config_manager.read_configuration(device) == configuration + assert config_manager.read_pairings(device) == pairings + assert device.active_mode == 5 + assert not { + config_manager.OP_MODE_SET, + config_manager.OP_REBOOT, + config_manager.OP_BOOTSEL_REBOOT, + }.intersection(device.requests) + + +@pytest.mark.parametrize( + ("product_id", "extras"), + [ + (0x2069, {"c", "gl", "gr"}), + (0x2067, {"left_sl", "left_sr"}), + (0x2066, {"c", "right_sl", "right_sr"}), + ], +) def test_switch2_input_choices_are_never_output_targets( - monkeypatch: pytest.MonkeyPatch, product_id: int, extras: set[str], + monkeypatch: pytest.MonkeyPatch, + product_id: int, + extras: set[str], ) -> None: device = FakeDevice() identity = replace(device.stable_identity, vendor_id=0x057E, product_id=product_id) @@ -145,7 +213,8 @@ def test_switch2_input_choices_are_never_output_targets( @pytest.mark.parametrize("version", [6, 7, 8]) def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss( - monkeypatch: pytest.MonkeyPatch, version: int, + monkeypatch: pytest.MonkeyPatch, + version: int, ) -> None: device = FakeDevice() key = (device.stable_identity.to_bytes(), 1) @@ -167,10 +236,15 @@ def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss( with running_server(monkeypatch, device) as (base_url, token): status, migrated = request_json(f"{base_url}/api/profiles/1/2") assert status == 200 - assert config_manager.ControllerProfile.from_json_object(migrated["profile"]) == profile + assert ( + config_manager.ControllerProfile.from_json_object(migrated["profile"]) + == profile + ) assert migrated["profile"]["swing"] == profile.swing.to_json_object() draft = migrated["profile"] - draft["extra_button_map"] = dict(zip(config_manager.EXTRA_BUTTONS, config_manager.LOGICAL_BUTTONS[:7])) + draft["extra_button_map"] = dict( + zip(config_manager.EXTRA_BUTTONS, config_manager.LOGICAL_BUTTONS[:7]) + ) draft["shift"]["mode"] = "hold" draft["shift"]["modifier"] = "c" draft["shift"]["extra_button_map"]["right_sr"] = "dpad_right" @@ -179,26 +253,40 @@ def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss( draft["switching_chord"] = ["left_sl", "left_sr"] draft["motion_toggle_chord"] = ["right_sl", "c"] draft["swing"] = { - "button": "west", "sensitivity": "high", "modifier": "right_trigger", + "button": "west", + "sensitivity": "high", + "modifier": "right_trigger", "macro": None, } draft["nunchuk_swing"].update(macro=1, sensitivity="low", modifier="c") draft["combined_swing"].update(macro=2, modifier="left_trigger") draft["combination_window_ms"] = 30 status, validated = request_json( - f"{base_url}/api/profiles/validate", method="POST", value=draft, token=token, + f"{base_url}/api/profiles/validate", + method="POST", + value=draft, + token=token, ) assert status == 200 expected_profile = config_manager.ControllerProfile.from_json_object(draft) - assert config_manager.ControllerProfile.from_json_object(validated["profile"]) == expected_profile + assert ( + config_manager.ControllerProfile.from_json_object(validated["profile"]) + == expected_profile + ) assert device.profiles[key] == bytes(legacy_wire) status, _ = request_json( - f"{base_url}/api/profiles/1/2", method="PUT", value=draft, token=token, + f"{base_url}/api/profiles/1/2", + method="PUT", + value=draft, + token=token, ) assert status == 200 status, stored = request_json(f"{base_url}/api/profiles/1/2") assert status == 200 - assert config_manager.ControllerProfile.from_json_object(stored["profile"]) == expected_profile + assert ( + config_manager.ControllerProfile.from_json_object(stored["profile"]) + == expected_profile + ) assert stored["alias"] == "Living room" assert stored["name"] == "Racing" assert stored["active"] is True @@ -210,7 +298,8 @@ def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss( @pytest.mark.parametrize("gesture", ["swing", "nunchuk_swing", "combined_swing"]) def test_editor_rejects_unconfigured_gesture_macro_without_saving( - monkeypatch: pytest.MonkeyPatch, gesture: str, + monkeypatch: pytest.MonkeyPatch, + gesture: str, ) -> None: device = FakeDevice() key = (device.stable_identity.to_bytes(), 1) @@ -219,10 +308,14 @@ def test_editor_rejects_unconfigured_gesture_macro_without_saving( draft[gesture]["macro"] = 4 with running_server(monkeypatch, device) as (base_url, token): for endpoint, method in ( - ("/api/profiles/validate", "POST"), ("/api/profiles/1/2", "PUT"), + ("/api/profiles/validate", "POST"), + ("/api/profiles/1/2", "PUT"), ): status, result = request_json( - f"{base_url}{endpoint}", method=method, value=draft, token=token, + f"{base_url}{endpoint}", + method=method, + value=draft, + token=token, ) assert status == 400 assert "error" in result @@ -267,8 +360,10 @@ def test_editor_identifies_connected_controller_artwork( (identity["controller"]["style"], identity["controller"]["layout"]) for identity in listing["identities"] ] == [ - ("generic", "generic"), ("switch", "switch-pro"), - ("playstation", "dualsense"), ("xbox", "xbox"), + ("generic", "generic"), + ("switch", "switch-pro"), + ("playstation", "dualsense"), + ("xbox", "xbox"), ] assert [identity["key"] for identity in listing["identities"]] == [ identity.to_bytes().hex() for identity in device.profile_identities @@ -285,7 +380,8 @@ def joycon_pair_device() -> FakeDevice: device.profile_identities = [device.global_identity, left, right, pair] device.stable_identity = pair device.active_profiles = { - identity.to_bytes(): index for index, identity in enumerate(device.profile_identities) + identity.to_bytes(): index + for index, identity in enumerate(device.profile_identities) } default_profile = config_manager.ControllerProfile.default().to_bytes() device.profiles = { @@ -299,7 +395,8 @@ def joycon_pair_device() -> FakeDevice: def test_persistent_pair_owner_is_offline_capable_and_isolated_from_solo_banks( - monkeypatch: pytest.MonkeyPatch, joycon_pair_device: FakeDevice, + monkeypatch: pytest.MonkeyPatch, + joycon_pair_device: FakeDevice, ) -> None: device = joycon_pair_device _, left, right, pair = device.profile_identities @@ -312,7 +409,9 @@ def test_persistent_pair_owner_is_offline_capable_and_isolated_from_solo_banks( identity.to_bytes().hex() for identity in device.profile_identities ] assert [owner["controller"]["layout"] for owner in owners[1:]] == [ - "joycon2-left", "joycon2-right", "joycon2-pair", + "joycon2-left", + "joycon2-right", + "joycon2-pair", ] owner = owners[3] assert owner["controller"]["model"] != owners[1]["controller"]["model"] @@ -322,7 +421,11 @@ def test_persistent_pair_owner_is_offline_capable_and_isolated_from_solo_banks( assert owner["identity"]["members"]["right"]["address"] == right.address_text assert owner["identity"]["members"]["right"]["address_type"] == 1 assert set(owner["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == { - "c", "left_sl", "left_sr", "right_sl", "right_sr", + "c", + "left_sl", + "left_sr", + "right_sl", + "right_sr", } status, offline = request_json(f"{base_url}/api/profiles/3/8/playtest") assert status == 200 @@ -337,8 +440,10 @@ def test_persistent_pair_owner_is_offline_capable_and_isolated_from_solo_banks( for bank, output in ((3, "north"), (1, "east"), (2, "west")): expected[bank]["button_map"]["south"] = output status, _ = request_json( - f"{base_url}/api/profiles/{bank}/8", method="PUT", - value=expected[bank], token=token, + f"{base_url}/api/profiles/{bank}/8", + method="PUT", + value=expected[bank], + token=token, ) assert status == 200 for other_bank, expected_profile in expected.items(): @@ -350,16 +455,26 @@ def test_persistent_pair_owner_is_offline_capable_and_isolated_from_solo_banks( ("identities/3/alias", "Couch pair"), ): status, _ = request_json( - f"{base_url}/api/{path}", method="PUT", value={"value": value}, token=token, + f"{base_url}/api/{path}", + method="PUT", + value={"value": value}, + token=token, ) assert status == 200 status, _ = request_json( - f"{base_url}/api/profiles/3/8/activate", method="POST", token=token, + f"{base_url}/api/profiles/3/8/activate", + method="POST", + token=token, ) assert status == 200 status, listing = request_json(f"{base_url}/api/profiles") assert status == 200 - assert [owner["active_profile"] for owner in listing["identities"]] == [1, 2, 3, 8] + assert [owner["active_profile"] for owner in listing["identities"]] == [ + 1, + 2, + 3, + 8, + ] assert listing["identities"][3]["label"] == "Couch pair" assert listing["identities"][3]["key"] == pair.to_bytes().hex() for bank in (1, 2, 3): @@ -388,7 +503,8 @@ def test_persistent_pair_owner_is_offline_capable_and_isolated_from_solo_banks( def test_capture_cannot_bind_pair_input_to_a_solo_owner( - monkeypatch: pytest.MonkeyPatch, joycon_pair_device: FakeDevice, + monkeypatch: pytest.MonkeyPatch, + joycon_pair_device: FakeDevice, ) -> None: device = joycon_pair_device left = device.profile_identities[1] @@ -396,7 +512,9 @@ def test_capture_cannot_bind_pair_input_to_a_solo_owner( status, _ = request_json(f"{base_url}/api/profiles/1/1/playtest") assert status == 200 status, _ = request_json( - f"{base_url}/api/profiles/1/1/capture/start", method="POST", token=token, + f"{base_url}/api/profiles/1/1/capture/start", + method="POST", + token=token, value={ "owner_key": left.to_bytes().hex(), "capture_id": "solo-bank-paired-input", @@ -416,37 +534,56 @@ def test_capture_cannot_bind_pair_input_to_a_solo_owner( @pytest.mark.parametrize("owner_index", [0, 1]) def test_live_layout_transitions_do_not_infer_topology_from_profile_owner( - monkeypatch: pytest.MonkeyPatch, owner_index: int, + monkeypatch: pytest.MonkeyPatch, + owner_index: int, ) -> None: device = FakeDevice() left = replace(device.stable_identity, vendor_id=0x057E, product_id=0x2067) device.stable_identity = left device.profile_identities = [device.global_identity, left] device.active_profiles[left.to_bytes()] = 0 - device.profiles[(left.to_bytes(), 0)] = config_manager.ControllerProfile.default().to_bytes() + device.profiles[(left.to_bytes(), 0)] = ( + config_manager.ControllerProfile.default().to_bytes() + ) device.playtest_motion = None with running_server(monkeypatch, device) as (base_url, _): - for code, expected in ((1, "joycon2-left"), (3, "joycon2-pair"), (1, "joycon2-left")): + for code, expected in ( + (1, "joycon2-left"), + (3, "joycon2-pair"), + (1, "joycon2-left"), + ): device.playtest_layout = code - status, sample = request_json(f"{base_url}/api/profiles/{owner_index}/1/playtest") + status, sample = request_json( + f"{base_url}/api/profiles/{owner_index}/1/playtest" + ) assert status == 200 assert sample["controller"]["layout"] == expected - assert sample["owner_key"] == device.profile_identities[owner_index].to_bytes().hex() + assert ( + sample["owner_key"] + == device.profile_identities[owner_index].to_bytes().hex() + ) assert sample["identity_key"] == left.to_bytes().hex() assert sample["identity"]["is_joycon_pair"] is False expected_extras = ( {"c", "left_sl", "left_sr", "right_sl", "right_sr"} - if code == 3 else {"left_sl", "left_sr"} + if code == 3 + else {"left_sl", "left_sr"} + ) + assert ( + set(sample["source_controls"]) & set(config_manager.EXTRA_BUTTONS) + == expected_extras ) - assert set(sample["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == expected_extras status, listing = request_json(f"{base_url}/api/profiles") assert status == 200 assert [owner["key"] for owner in listing["identities"]] == [ - device.global_identity.to_bytes().hex(), left.to_bytes().hex(), + device.global_identity.to_bytes().hex(), + left.to_bytes().hex(), ] assert listing["identities"][1]["controller"]["layout"] == "joycon2-left" device.playtest_connected = False - status, offline = request_json(f"{base_url}/api/profiles/{owner_index}/1/playtest") + status, offline = request_json( + f"{base_url}/api/profiles/{owner_index}/1/playtest" + ) assert status == 200 assert offline["connected"] is False assert offline["layout"] is None @@ -468,7 +605,9 @@ def test_wii_pid_does_not_claim_a_remote_or_extension_without_live_metadata( monkeypatch: pytest.MonkeyPatch, ) -> None: device = FakeDevice() - device.stable_identity = replace(device.stable_identity, vendor_id=0x057E, product_id=0x0330) + device.stable_identity = replace( + device.stable_identity, vendor_id=0x057E, product_id=0x0330 + ) device.profile_identities = [device.global_identity, device.stable_identity] device.active_profiles[device.stable_identity.to_bytes()] = 0 with running_server(monkeypatch, device) as (base_url, _): @@ -488,9 +627,12 @@ def test_wii_pid_does_not_claim_a_remote_or_extension_without_live_metadata( assert status == 200 assert sample["controller"]["layout"] == expected if code: - assert set(sample["source_controls"]).isdisjoint(config_manager.EXTRA_BUTTONS) + assert set(sample["source_controls"]).isdisjoint( + config_manager.EXTRA_BUTTONS + ) assert ("left_shoulder" in sample["source_controls"]) == (code == 5) + def test_editor_reads_writes_and_activates_profiles_atomically( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -651,13 +793,17 @@ def test_editor_rejects_invalid_or_unauthorized_mutations( def test_joycon_mode_preserves_configuration_and_all_profile_banks( - monkeypatch: pytest.MonkeyPatch, joycon_pair_device: FakeDevice, + monkeypatch: pytest.MonkeyPatch, + joycon_pair_device: FakeDevice, ) -> None: device = joycon_pair_device config_manager.write_configuration( device, config_manager.AdapterConfiguration( - 135, 0, 0, config_manager.REQUESTED_MODE_XINPUT, + 135, + 0, + 0, + config_manager.REQUESTED_MODE_XINPUT, (native_rumble_identity(),), ), 1.0, @@ -677,24 +823,35 @@ def test_joycon_mode_preserves_configuration_and_all_profile_banks( status, initial = request_json(f"{base_url}/api/joycon-mode") assert status == 200 assert initial == { - "mode": "paired", "generation": before.generation, "supported": True, + "mode": "paired", + "generation": before.generation, + "supported": True, } for mode in ("individual", "paired"): status, committed = request_json( - f"{base_url}/api/joycon-mode", method="PUT", - value={"mode": mode}, token=token, + f"{base_url}/api/joycon-mode", + method="PUT", + value={"mode": mode}, + token=token, ) assert status == 200 stored = config_manager.read_configuration(device) assert committed == { - "mode": mode, "generation": stored.generation, "supported": True, + "mode": mode, + "generation": stored.generation, + "supported": True, } assert stored.joycon_mode == config_manager.JOYCON_MODE_NAMES.index(mode) assert stored.generation > before.generation - assert replace( - stored, joycon_mode=before.joycon_mode, - generation=before.generation, crc=before.crc, - ) == before + assert ( + replace( + stored, + joycon_mode=before.joycon_mode, + generation=before.generation, + crc=before.crc, + ) + == before + ) assert request_json(f"{base_url}/api/joycon-mode") == (200, committed) assert device.profiles == profiles assert device.profile_names == names @@ -706,14 +863,19 @@ def test_joycon_mode_preserves_configuration_and_all_profile_banks( @pytest.mark.parametrize("schema", [1, 2, 3]) def test_joycon_mode_legacy_reads_default_and_refuses_mutations( - monkeypatch: pytest.MonkeyPatch, schema: int, + monkeypatch: pytest.MonkeyPatch, + schema: int, ) -> None: device = FakeDevice() config_manager.write_configuration( device, config_manager.AdapterConfiguration( - 95, 0, 0, - native_switch_controllers=(native_rumble_identity(),) if schema == 3 else (), + 95, + 0, + 0, + native_switch_controllers=(native_rumble_identity(),) + if schema == 3 + else (), schema_version=schema, ), 1.0, @@ -723,12 +885,16 @@ def test_joycon_mode_legacy_reads_default_and_refuses_mutations( status, legacy = request_json(f"{base_url}/api/joycon-mode") assert status == 200 assert legacy == { - "mode": "paired", "generation": before.generation, "supported": False, + "mode": "paired", + "generation": before.generation, + "supported": False, } for mode in ("paired", "individual"): status, _ = request_json( - f"{base_url}/api/joycon-mode", method="PUT", - value={"mode": mode}, token=token, + f"{base_url}/api/joycon-mode", + method="PUT", + value={"mode": mode}, + token=token, ) assert status == 400 assert config_manager.read_configuration(device) == before @@ -739,29 +905,48 @@ def test_joycon_mode_rejects_unauthorized_and_malformed_requests( ) -> None: device = FakeDevice() config_manager.write_configuration( - device, config_manager.AdapterConfiguration(110, 0, 0), 1.0, + device, + config_manager.AdapterConfiguration(110, 0, 0), + 1.0, ) before = config_manager.read_configuration(device) with running_server(monkeypatch, device) as (base_url, token): endpoint = f"{base_url}/api/joycon-mode" for supplied_token in (None, "invalid"): status, _ = request_json( - endpoint, method="PUT", value={"mode": "individual"}, + endpoint, + method="PUT", + value={"mode": "individual"}, token=supplied_token, ) assert status == 403 for body in ( - {}, [], {"mode": 1}, {"mode": True}, {"mode": None}, - {"mode": ["individual"]}, {"mode": "Individual"}, + {}, + [], + {"mode": 1}, + {"mode": True}, + {"mode": None}, + {"mode": ["individual"]}, + {"mode": "Individual"}, {"mode": "individual", "pairing_window_seconds": 10}, ): status, _ = request_json( - endpoint, method="PUT", value=body, token=token, + endpoint, + method="PUT", + value=body, + token=token, ) assert status == 400 - for raw in (b"", b"{", b"\xff", b" " * (profile_web._MAXIMUM_REQUEST_BYTES + 1)): + for raw in ( + b"", + b"{", + b"\xff", + b" " * (profile_web._MAXIMUM_REQUEST_BYTES + 1), + ): request = urllib.request.Request( - endpoint, data=raw, method="PUT", + endpoint, + data=raw, + method="PUT", headers={"X-Switch-Pico-Token": token}, ) with pytest.raises(urllib.error.HTTPError) as rejected: @@ -769,7 +954,8 @@ def test_joycon_mode_rejects_unauthorized_and_malformed_requests( assert rejected.value.code == 400 for method in ("GET", "PUT"): request = urllib.request.Request( - endpoint, method=method, + endpoint, + method=method, data=b'{"mode":"individual"}' if method == "PUT" else None, headers={"Host": "untrusted.example", "X-Switch-Pico-Token": token}, ) @@ -784,12 +970,16 @@ def test_joycon_mode_commit_is_not_reported_as_confirmed_when_readback_fails( ) -> None: device = FakeDevice() config_manager.write_configuration( - device, config_manager.AdapterConfiguration(110, 0, 0), 1.0, + device, + config_manager.AdapterConfiguration(110, 0, 0), + 1.0, ) read_configuration = config_manager.read_configuration before = read_configuration(device) - def disconnect_after_commit(device: config_manager.UsbDevice) -> config_manager.AdapterConfiguration: + def disconnect_after_commit( + device: config_manager.UsbDevice, + ) -> config_manager.AdapterConfiguration: configuration = read_configuration(device) if configuration.generation != before.generation: raise usb.core.USBError("device disconnected") @@ -798,18 +988,23 @@ def test_joycon_mode_commit_is_not_reported_as_confirmed_when_readback_fails( monkeypatch.setattr(config_manager, "read_configuration", disconnect_after_commit) with running_server(monkeypatch, device) as (base_url, token): status, response = request_json( - f"{base_url}/api/joycon-mode", method="PUT", - value={"mode": "individual"}, token=token, + f"{base_url}/api/joycon-mode", + method="PUT", + value={"mode": "individual"}, + token=token, ) assert status == 503 assert "error" in response assert "mode" not in response - assert read_configuration(device).joycon_mode == config_manager.JOYCON_MODE_INDIVIDUAL + assert ( + read_configuration(device).joycon_mode == config_manager.JOYCON_MODE_INDIVIDUAL + ) @pytest.mark.parametrize("method", ["GET", "PUT"]) def test_joycon_mode_usb_failure_returns_service_unavailable( - monkeypatch: pytest.MonkeyPatch, method: str, + monkeypatch: pytest.MonkeyPatch, + method: str, ) -> None: device = FakeDevice() before = device.configuration @@ -820,8 +1015,10 @@ def test_joycon_mode_usb_failure_returns_service_unavailable( monkeypatch.setattr(device, "ctrl_transfer", disconnected) with running_server(monkeypatch, device) as (base_url, token): status, response = request_json( - f"{base_url}/api/joycon-mode", method=method, - value={"mode": "individual"} if method == "PUT" else None, token=token, + f"{base_url}/api/joycon-mode", + method=method, + value={"mode": "individual"} if method == "PUT" else None, + token=token, ) assert status == 503 assert "error" in response @@ -906,7 +1103,6 @@ def test_recorder_accepts_first_connection_generation_zero( assert status == 200 and stopped["state_name"] == "stopped" - def test_wii_orientation_endpoint_validation_and_failures( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -951,9 +1147,7 @@ def test_wii_orientation_endpoint_validation_and_failures( def failing_config(*args: Any, **kwargs: Any) -> None: raise config_manager.ConfigManagerError("firmware rejected orientation") - monkeypatch.setattr( - config_manager, "set_wii_orientation", failing_config - ) + monkeypatch.setattr(config_manager, "set_wii_orientation", failing_config) status, err = request_json( f"{base_url}/api/identities/1/wii-orientation", method="POST", @@ -967,9 +1161,7 @@ def test_wii_orientation_endpoint_validation_and_failures( def usb_failing_config(*args: Any, **kwargs: Any) -> None: raise usb.core.USBError("USB pipe error") - monkeypatch.setattr( - config_manager, "set_wii_orientation", usb_failing_config - ) + monkeypatch.setattr(config_manager, "set_wii_orientation", usb_failing_config) status, err = request_json( f"{base_url}/api/identities/1/wii-orientation", method="POST", diff --git a/tools/switch2_usb_probe/bootsel.cpp b/tools/switch2_usb_probe/bootsel.cpp index a5e4ce1..f3f579f 100644 --- a/tools/switch2_usb_probe/bootsel.cpp +++ b/tools/switch2_usb_probe/bootsel.cpp @@ -26,8 +26,8 @@ uint32_t bootsel_deadline_ms; #endif } -bool probe_bootsel_vendor_control(uint8_t rhport, uint8_t stage, - const tusb_control_request_t* request) { +bool probe_management_vendor_control(uint8_t rhport, uint8_t stage, + const tusb_control_request_t* request) { using namespace UsbConfigurationManagement; #if SWITCH2_PROBE_HUB if (rhport > PROBE_CONTROLLER_COUNT) return false; @@ -41,7 +41,12 @@ bool probe_bootsel_vendor_control(uint8_t rhport, uint8_t stage, request->bRequest != static_cast(Operation::kBootselReboot) || request->wValue != kRequestValue || request->wIndex != kRequestIndex || request->wLength != kRequestHeaderSize) { +#if SWITCH2_PROBE_HUB + return rhport == 0 && + usb_configuration_management_vendor_control(rhport, stage, request); +#else return false; +#endif } #if SWITCH2_PROBE_HUB if (stage == CONTROL_STAGE_SETUP) { diff --git a/tools/switch2_usb_probe/bootsel.h b/tools/switch2_usb_probe/bootsel.h index f40faa6..4bfd396 100644 --- a/tools/switch2_usb_probe/bootsel.h +++ b/tools/switch2_usb_probe/bootsel.h @@ -9,9 +9,10 @@ extern "C" { #endif -// Core 0: expose only the existing private BOOTSEL command, not configuration. -bool probe_bootsel_vendor_control(uint8_t rhport, uint8_t stage, - const tusb_control_request_t* request); +// Core 0: native root management plus private BOOTSEL on root and children. +// Non-hub probes retain their private BOOTSEL-only management surface. +bool probe_management_vendor_control(uint8_t rhport, uint8_t stage, + const tusb_control_request_t* request); // Core 0: service the existing reboot delay only after a validated status ACK. void probe_bootsel_task(uint32_t now_ms); diff --git a/tools/switch2_usb_probe/main.c b/tools/switch2_usb_probe/main.c index e116b77..93b7da2 100644 --- a/tools/switch2_usb_probe/main.c +++ b/tools/switch2_usb_probe/main.c @@ -669,7 +669,7 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, if (stage == CONTROL_STAGE_SETUP) log_packet("VENDOR_CONTROL", rhport, 0, (const uint8_t*)request, sizeof(*request)); #ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE - if (probe_bootsel_vendor_control(rhport, stage, request)) + if (probe_management_vendor_control(rhport, stage, request)) return true; #endif #if SWITCH2_PROBE_HUB diff --git a/tools/switch2_usb_probe/probe_build.cmake b/tools/switch2_usb_probe/probe_build.cmake index bfa7c4f..94e9131 100644 --- a/tools/switch2_usb_probe/probe_build.cmake +++ b/tools/switch2_usb_probe/probe_build.cmake @@ -369,12 +369,12 @@ function(switch2_usb_probe_configure target) endif() if(SWITCH2_PROBE_HUB AND SWITCH2_BRIDGE_FULL_INPUT) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.71-native-gamepad-trace") + pico_set_program_version(${target} "0.72-native-gamepad-trace") else() - pico_set_program_version(${target} "0.71-native-gamepad") + pico_set_program_version(${target} "0.72-native-gamepad") endif() elseif(SWITCH2_PROBE_HUB) - pico_set_program_version(${target} "0.67-native-hub-latency") + pico_set_program_version(${target} "0.72-native-hub-editor") elseif(SWITCH2_PROBE_JOIN_CHORD_GATE) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) pico_set_program_version(${target} "0.37-pair-chord-trace") diff --git a/udev/99-switch-pico.rules b/udev/99-switch-pico.rules index 9d3b925..cd42bda 100644 --- a/udev/99-switch-pico.rules +++ b/udev/99-switch-pico.rules @@ -1,5 +1,7 @@ -# Development USB identities used by switch-pico XInput and generic HID modes. +# USB identities used by switch-pico native hub, XInput, and generic HID modes. # Restrict configuration access to the active local seat and plugdev members. +# Native hub root only; host tools still validate the switch-pico management response. +SUBSYSTEM=="usb", ATTR{idVendor}=="057e", ATTR{idProduct}=="2068", MODE="0660", GROUP="plugdev", TAG+="uaccess" SUBSYSTEM=="usb", ATTR{idVendor}=="cafe", ATTR{idProduct}=="4010", MODE="0660", GROUP="plugdev", TAG+="uaccess" SUBSYSTEM=="usb", ATTR{idVendor}=="cafe", ATTR{idProduct}=="4020", MODE="0660", GROUP="plugdev", TAG+="uaccess" SUBSYSTEM=="usb", ATTR{idVendor}=="cafe", ATTR{idProduct}=="4021", MODE="0660", GROUP="plugdev", TAG+="uaccess"