Restore profile editor management on the native Joy-Con hub

This commit is contained in:
Joey Yakimowich-Payne 2026-09-13 08:35:10 -06:00
commit dd69f3d487
30 changed files with 1849 additions and 348 deletions

View file

@ -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;

View file

@ -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; }

View file

@ -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<uint8_t>(usb_output_driver_mode()),
usb_output_driver_capabilities(),
#endif
static_cast<uint8_t>(CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE),
static_cast<uint8_t>(
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<Operation>(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<uint16_t>(response_size));
}

View file

@ -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;

View file

@ -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("<HH", payload)
expected_size = (
PROFILE_SIZE if version >= 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("<H", payload, 286)[0]
@ -2232,9 +2327,13 @@ class ControllerProfile:
if payload[settings_offset:336] != bytes(336 - settings_offset):
raise ConfigManagerError("nonzero Turbo override padding")
reserved_offset = (
376 if has_combined_swing else
367 if has_swing else
364 if has_extra_buttons else 344
376
if has_combined_swing
else 367
if has_swing
else 364
if has_extra_buttons
else 344
)
if any(payload[reserved_offset:]):
raise ConfigManagerError("profile reserved fields must be zero")
@ -2258,14 +2357,25 @@ class ControllerProfile:
shift=shift,
turbo_defaults=turbo_defaults,
turbo_overrides=tuple(turbo_overrides),
extra_button_map=tuple(payload[344:351]) if has_extra_buttons else (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS),
extra_button_map=tuple(payload[344:351])
if has_extra_buttons
else (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS),
swing=(
ProfileSwing(*payload[364:368]) if has_combined_swing else
ProfileSwing(*payload[364:367]) if has_swing else ProfileSwing()
ProfileSwing(*payload[364:368])
if has_combined_swing
else ProfileSwing(*payload[364:367])
if has_swing
else ProfileSwing()
),
nunchuk_swing=ProfileSwing(*payload[368:372]) if has_combined_swing else ProfileSwing(),
combined_swing=ProfileCombinedSwing(*payload[372:375]) if has_combined_swing else ProfileCombinedSwing(),
combination_window_ms=payload[375] if has_combined_swing else PROFILE_COMBINATION_WINDOW_DEFAULT,
nunchuk_swing=ProfileSwing(*payload[368:372])
if has_combined_swing
else ProfileSwing(),
combined_swing=ProfileCombinedSwing(*payload[372:375])
if has_combined_swing
else ProfileCombinedSwing(),
combination_window_ms=payload[375]
if has_combined_swing
else PROFILE_COMBINATION_WINDOW_DEFAULT,
)
def to_bytes(self) -> 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("<I", transaction_id))
@ -3977,9 +4128,15 @@ def parse_profile_playtest(envelope: Envelope) -> 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

View file

@ -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 <algorithm>
#include <array>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <vector>
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<uint8_t, PROFILE_STORAGE_ARENA_COUNT * PROFILE_STORAGE_ARENA_SIZE> flash;
uint32_t programs = 0;
uint32_t erases = 0;
uint32_t bootsel_calls = 0;
std::array<uint8_t, 64> 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<uint8_t>& data, size_t offset) {
return data.at(offset) | (static_cast<uint16_t>(data.at(offset + 1)) << 8);
}
uint32_t u32(const std::vector<uint8_t>& data, size_t offset) {
return u16(data, offset) | (static_cast<uint32_t>(u16(data, offset + 2)) << 16);
}
void put16(std::vector<uint8_t>& data, size_t offset, uint16_t value) {
data.at(offset) = value; data.at(offset + 1) = value >> 8;
}
void put32(std::vector<uint8_t>& 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<uint8_t>(op);
setup.wValue = kRequestValue; setup.wIndex = kRequestIndex; setup.wLength = length;
return setup;
}
std::vector<uint8_t> envelope(Operation op, const std::vector<uint8_t>& payload) {
std::vector<uint8_t> bytes(kRequestHeaderSize + payload.size());
memcpy(bytes.data(), "SPMG", 4);
bytes[4] = kProtocolVersion; bytes[5] = static_cast<uint8_t>(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<uint8_t> receive(uint8_t slot = 0) {
std::vector<uint8_t> 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<uint8_t> 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<uint8_t>(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<uint8_t>& 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<size_t>(64, bytes.size() - offset), true), "management OUT packet stalled");
}
if (ack) acknowledge();
}
std::vector<uint8_t> identity_payload(uint8_t profile) {
std::vector<uint8_t> payload(15);
require(controller_identity_encode(controller_identity_global(), payload.data(), 14), "global identity did not encode");
payload[14] = profile; return payload;
}
std::vector<uint8_t> encoded_profile(uint8_t destination) {
ControllerProfile profile = controller_profile_default(controller_identity_global(), 0);
profile.button_map[0] = destination;
std::vector<uint8_t> 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<uint8_t>& profile) {
std::vector<uint8_t> 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<uint8_t> chunk_payload(uint32_t transaction, const std::vector<uint8_t>& profile, size_t offset) {
const size_t count = std::min(kMaximumChunkSize, profile.size() - offset);
std::vector<uint8_t> 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<uint8_t>& 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<uint8_t> transaction_payload(uint32_t id) { std::vector<uint8_t> data(4); put32(data, 0, id); return data; }
void require_profile(const std::vector<uint8_t>& expected) {
auto bytes = read_operation(Operation::kProfileRead);
require(bytes[6] == static_cast<uint8_t>(Status::kOk) &&
std::vector<uint8_t>(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<uint8_t>(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<uint8_t>(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";
}

View file

@ -0,0 +1,2 @@
#pragma once
#include "hardware_stub.h"

View file

@ -0,0 +1,2 @@
#pragma once
#include "hardware_stub.h"

View file

@ -0,0 +1,2 @@
#pragma once
#include "hardware_stub.h"

View file

@ -0,0 +1,2 @@
#pragma once
#include "hardware_stub.h"

View file

@ -0,0 +1,2 @@
#pragma once
#include "hardware_stub.h"

View file

@ -0,0 +1,2 @@
#pragma once
#include "hardware_stub.h"

View file

@ -0,0 +1,2 @@
#pragma once
#include "hardware_stub.h"

View file

@ -0,0 +1,2 @@
#pragma once
#include "hardware_stub.h"

View file

@ -0,0 +1,109 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#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

View file

@ -0,0 +1,2 @@
#pragma once
#include "hardware_stub.h"

View file

@ -0,0 +1,2 @@
#pragma once
#include "hardware_stub.h"

View file

@ -0,0 +1,2 @@
#pragma once
#include "hardware_stub.h"

View file

@ -0,0 +1,2 @@
#pragma once
#include "hardware_stub.h"

View file

@ -0,0 +1,45 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
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

View file

@ -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;
}

View file

@ -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<unsigned>(ControllerProfileLogicalButton::kWest)] =
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
profile.button_map[static_cast<unsigned>(ControllerProfileLogicalButton::kNorth)] =
static_cast<uint8_t>(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;
}

View file

@ -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("<H", self.profiles[self.selected_profile])[0],
schema=struct.unpack_from(
"<H", self.profiles[self.selected_profile]
)[0],
generation=self.profile_generation,
)
)
@ -718,7 +719,11 @@ def native_rumble_configuration(
) -> bytes:
return (
struct.pack(
"<HBBB3x", 90, config_manager.REQUESTED_MODE_XINPUT, len(identities), joycon_mode
"<HBBB3x",
90,
config_manager.REQUESTED_MODE_XINPUT,
len(identities),
joycon_mode,
)
+ b"".join(identity.to_bytes() for identity in identities)
+ bytes((16 - len(identities)) * 14)
@ -770,7 +775,11 @@ def test_native_rumble_configuration_canonical_wire_round_trip(schema: int) -> 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("<H", legacy_wire, 0, version)
@ -2051,20 +2176,29 @@ def test_schema7_extra_controls_keep_output_channels_and_wire_layout(version: in
device = FakeDevice()
device.profiles[(device.stable_identity.to_bytes(), 1)] = bytes(legacy_wire)
assert config_manager.read_profile(device, device.stable_identity, 1) == profile
listing = config_manager.parse_profile_list(config_manager.parse_response(
make_response(config_manager.OP_PROFILE_LIST, device._profile_list_payload(), schema=version),
config_manager.OP_PROFILE_LIST,
))
listing = config_manager.parse_profile_list(
config_manager.parse_response(
make_response(
config_manager.OP_PROFILE_LIST,
device._profile_list_payload(),
schema=version,
),
config_manager.OP_PROFILE_LIST,
)
)
assert listing[1].identity == device.stable_identity
assert listing[1].active_profile_index == 1
@pytest.mark.parametrize("path", [
("button_map", "south"),
("extra_button_map", "c"),
("triggers", "left", "output"),
("shift", "extra_button_map", "c"),
])
@pytest.mark.parametrize(
"path",
[
("button_map", "south"),
("extra_button_map", "c"),
("triggers", "left", "output"),
("shift", "extra_button_map", "c"),
],
)
def test_extra_controls_cannot_be_output_destinations(path: tuple[str, ...]) -> 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("<H", payload, 0, 6)
payload[344:] = bytes(40)
@ -2087,9 +2231,17 @@ def test_schema6_rejects_schema7_controls_in_old_fields(offset: int, value: int)
config_manager.ControllerProfile.from_bytes(payload)
@pytest.mark.parametrize("offset,value", [
(344, 18), (351, 16), (358, 128), (362, 128), (363, 128), (364, 1),
])
@pytest.mark.parametrize(
"offset,value",
[
(344, 18),
(351, 16),
(358, 128),
(362, 128),
(363, 128),
(364, 1),
],
)
def test_schema7_rejects_extra_map_and_mask_overflow(offset: int, value: int) -> None:
payload = bytearray(config_manager.ControllerProfile.default().to_bytes())
struct.pack_into("<H", payload, 0, 7)
@ -2129,27 +2281,40 @@ def test_legacy_control_profiles_preserve_custom_actions(version: int) -> 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)]
)

View file

@ -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)

View file

@ -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",

View file

@ -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<uint8_t>(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) {

View file

@ -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);

View file

@ -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

View file

@ -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")

View file

@ -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"