diff --git a/ADAPTER_PARITY_PLAN.md b/ADAPTER_PARITY_PLAN.md index 1fe00ed..2a4c1e9 100644 --- a/ADAPTER_PARITY_PLAN.md +++ b/ADAPTER_PARITY_PLAN.md @@ -47,7 +47,7 @@ The AIO firmware currently has: - generation-tagged state snapshots and rumble mailboxes across cores - persistent Classic and BLE bonding - a physical BOOTSEL pairing window and pairing reset -- endpoint-zero PC pairing management +- versioned endpoint-zero configuration and pairing management - Switch Pro input, motion, colors, and rumble per slot - per-controller ABXY and motion hotkeys @@ -65,7 +65,7 @@ The Bluetooth, UART, Switch, and XInput paths now share `ControllerState`: | Capability | Status | Evidence or remaining work | |---|---|---| | Bluetooth Classic and BLE | Complete | Bluepad32 supports both transports; bonds persist across reboot. | -| Pairing gate, reconnect, list, and clear | Complete | Physical BOOTSEL flow and `switch-pico-pairings` are implemented. | +| Pairing gate, reconnect, list, and clear | Complete | Physical BOOTSEL flow and `switch-pico-config pairings` use the versioned management protocol. | | Four concurrent controllers | Complete for Switch mode | Four independent USB interfaces and Bluetooth slots are implemented. | | Switch input | Complete | Buttons, sticks, lifecycle, colors, and per-slot isolation are hardware-tested. | | Switch motion | Complete for supported parsers | DualSense, Switch-family, Wii accelerometer, PS Move, and compatible 8BitDo modes are normalized. | @@ -75,6 +75,7 @@ The Bluetooth, UART, Switch, and XInput paths now share `ControllerState`: | Automatic Windows/Switch selection | Feasibility complete | Windows enumeration fix is in `db4a860`; real Windows transition and rumble were reported working. | | Windows feasibility test | Complete | `tools/Test-AdapterFeasibility.ps1` checks transition, PnP health, four XInput slots, controls, and rumble isolation. | | Protocol-neutral controller state | Complete | `ControllerState` is shared by Bluetooth, UART, Switch, and XInput paths; analog trigger precision is retained. | +| Persistent configuration protocol | Core complete | Two-copy CRC-validated flash storage, chunked transactions, generation recovery, and PC read/write/reset are hardware-tested. | | Production USB VID/PID | Missing | Prototype uses `CAFE:4010`; obtain an appropriate project VID/PID and repeat Windows binding tests. | | DInput output | Missing | Add generic HID descriptor and report driver. | | Mac output mode | Missing | Capture/define compatible descriptor and report semantics. | @@ -246,10 +247,16 @@ Completion evidence: - Switch buttons, sticks, ZL/ZR, motion, and rumble matched the fixed master baseline - the scan-latency regression was bisected, fixed, documented, and retested before acceptance -### Phase 2 — Persistent configuration protocol +### Phase 2 — Persistent configuration protocol — Core complete Generalize endpoint-zero management beyond pairing while keeping Switch USB enumeration unchanged. +The core delivery implements firmware/board/active-mode status, one +versioned adapter configuration object, transactional writes, and pairing +management. Connected-controller metadata and profile operations are added +with the controller identity and profile schemas in Phases 3 and 5. Reboot +operations remain with the guided updater in Phase 6. + Operations: - firmware and board version @@ -278,16 +285,15 @@ Storage requirements: - separate flash region from Bluepad32 bond storage - bounded write frequency -Host tooling: +Current host tooling: ```text switch-pico-config status -switch-pico-config mode xinput -switch-pico-config profiles list -switch-pico-config profiles export profile.json -switch-pico-config profiles import profile.json -switch-pico-config profiles activate 2 -switch-pico-config reboot --bootsel +switch-pico-config config show +switch-pico-config config set --pairing-window-seconds 90 +switch-pico-config config reset --yes +switch-pico-config pairings list +switch-pico-config pairings clear --yes ``` Acceptance: @@ -297,6 +303,25 @@ Acceptance: - Pairing management migrates to the versioned protocol in the same cutover. - Configuration survives power cycling on hardware. +Core completion evidence: + +- the configuration record has version, size, generation, payload CRC, and + header CRC fields with a fixed 512-byte payload ceiling +- two dedicated Pico flash sectors sit immediately before, and cannot overlap, + BTstack's two-sector bond store +- writes target the inactive copy, verify after programming, preserve the old + copy until validation, skip identical values, and allow at most one changed + commit per second +- native tests reject malformed, truncated, out-of-order, oversized, + unsupported-schema, and bad-CRC data and recover from corrupt or interrupted + writes +- pairing list/refresh/clear moved from the old pairing-only requests into the + versioned envelope and passed on hardware with two stored Classic bonds +- 47 tests passed; UART, AIO, and feasibility firmware built +- generation 1 with a 90-second pairing window survived a physical power cycle + and feasibility firmware reflash, after which reset stored the 60-second + default as generation 2 + ### Phase 3 — Mapping, tuning, profiles, and macros Use four profile slots per stable controller identity. Resolve identity from Bluetooth transport, identity address, VID, and PID; use a global default when stable identity is unavailable. @@ -511,17 +536,8 @@ Do not mark a host/controller combination complete from descriptor inspection or ## Next action -Begin Phase 2: generalize endpoint-zero management into a versioned persistent -configuration protocol while preserving pairing commands and Switch -enumeration. - -The first Phase 2 delivery should remain narrow: - -1. define version, size, CRC, generation, and atomic-commit invariants -2. reserve storage that cannot overlap Bluepad32 bonds -3. add read/write/reset operations for one small configuration object -4. migrate pairing management into the versioned envelope -5. verify malformed requests, interrupted writes, rollback, and power-cycle persistence - -Do not begin profiles, macros, or tuning transforms until the storage and USB -transaction boundary is proven. +Begin Phase 3 now that the storage and USB transaction boundary is proven. +Define the fixed-capacity profile schema first, then add mapping and tuning +transforms before macros or Turbo. Wire profile list/import/export/activate +commands through the Phase 2 transaction protocol only after each profile +field has runtime semantics and deterministic tests. diff --git a/CMakeLists.txt b/CMakeLists.txt index 96e1093..5fb3461 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -100,7 +100,12 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") target_sources(switch-pico PRIVATE bluepad32_input_backend.cpp bootsel_pairing_button.cpp - usb_pairing_management.cpp + adapter_configuration.cpp + configuration_storage.cpp + configuration_transaction.cpp + configuration_service.cpp + pico_configuration_storage.cpp + usb_configuration_management.cpp ) if(SWITCH_PICO_ADAPTER_FEASIBILITY) target_sources(switch-pico PRIVATE @@ -123,7 +128,7 @@ else() endif() pico_set_program_name(switch-pico "switch-pico") -pico_set_program_version(switch-pico "0.1") +pico_set_program_version(switch-pico "0.2.0") # Modify the below lines to enable/disable output over UART/USB # UART0 is enabled for debug logging; USB stdio remains off. diff --git a/README.md b/README.md index e918518..0f26801 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Both `build.py --aio` and direct AIO CMake configuration apply `patches/bluepad3 Pairing order determines the initial USB slot assignment. Up to four controllers map 1:1 to the four emulated Switch Pro Controller interfaces. -While a slot is free, the Pico continuously runs Bluepad32's normal Bluetooth discovery and autoconnect path. Pairing keys persist across Pico power cycles, so reconnect a previously paired controller by pressing its normal Home, PS, or Xbox power button; BOOTSEL is not required. Outside the BOOTSEL window, BTstack remains non-bondable, rejects new Classic SSP or legacy PIN authentication, and disables every BLE STK generation method. A controller in explicit pairing mode therefore cannot create a new Classic or BLE bond while the window is closed. +With no active controller, the Pico runs Bluepad32 discovery and autoconnect. After any controller becomes active, active discovery pauses to protect input, motion, and rumble latency; bonded controllers may still initiate incoming reconnects. Pairing keys persist across Pico power cycles, so reconnect a previously paired controller by pressing its normal Home, PS, or Xbox power button. Hold BOOTSEL for the bounded pairing window before pairing a new controller or a controller that requires host-side discovery. Outside that window, BTstack remains non-bondable and rejects new Classic and BLE authentication. To clear every stored Classic and BLE pairing without a PC, hold BOOTSEL continuously for 10 seconds. The normal pairing window opens after two seconds; continuing to hold until the LED changes to a rapid blink clears all bonds, disconnects active controllers, publishes neutral state to every slot, and closes new authentication. Release BOOTSEL, open a new pairing window, and pair controllers again. @@ -82,19 +82,25 @@ The Pico 2 W onboard LED reports the overall Bluetooth state: - **Reconnect a paired controller**: power it on normally with its Home, PS, or Xbox button. - **8BitDo Ultimate Bluetooth reconnect**: leave its selector in Bluetooth mode, press Home once, then shake it. After an abrupt controller power-off, the Pico can remain solid for up to four seconds while Bluetooth link supervision confirms the disconnect; scanning restarts immediately afterward. - **Pair a new controller**: hold BOOTSEL until the LED double-blinks, then put the controller into its explicit Bluetooth pairing mode. -- **Pairing window expires**: new authentication is disabled; discovery and remembered-controller autoconnect continue while a slot is free. +- **Pairing window expires**: new authentication and active discovery stop while a controller is active; remembered controllers may still initiate reconnects. - **Clear all pairings**: hold BOOTSEL continuously for 10 seconds, through the initial double blink, until the rapid confirmation blink starts. All controllers are disconnected and must be paired again. -### Managing pairings from a PC +### Managing configuration and pairings from a PC -Connect the Pico 2 W to the PC while the AIO firmware is running normally; do not enter the ROM BOOTSEL drive. The management command uses private vendor requests on USB endpoint 0, so it does not add an interface or depend on Linux `hidraw` nodes. +Connect the Pico 2 W to the PC while the AIO firmware is running normally; do not enter the ROM BOOTSEL drive. `switch-pico-config` uses versioned private vendor requests on USB endpoint 0, so it does not add an interface or depend on Linux `hidraw` nodes. ```sh -uv run switch-pico-pairings list -uv run switch-pico-pairings clear --yes +uv run switch-pico-config status +uv run switch-pico-config config show +uv run switch-pico-config config set --pairing-window-seconds 90 +uv run switch-pico-config config reset --yes +uv run switch-pico-config pairings list +uv run switch-pico-config pairings clear --yes ``` -`list` refreshes and prints stored Bluetooth Classic and BLE addresses. `clear --yes` deletes all bonds, disconnects active controllers, closes new authentication, and leaves autoconnect scanning active. The destructive command requires `--yes`. If multiple compatible Picos are attached, select one with `--bus N --address N`; the error lists their locations. USB access errors require permission to the matching `/dev/bus/usb` device. +Configuration records use version, size, generation, and CRC fields. Writes are chunked and atomically committed between two dedicated flash sectors; interrupted or corrupt writes fall back to the previous valid generation. Those sectors do not overlap Bluepad32's separate bond store. Rewriting identical configuration does not consume a flash write, and successful writes are rate-limited. + +`pairings list` refreshes and prints stored Bluetooth Classic and BLE addresses. `pairings clear --yes` deletes all bonds, disconnects active controllers, closes new authentication, and resumes discovery because no controllers remain. Destructive commands require `--yes`. If multiple compatible Picos are attached, select one with `--bus N --address N`; the error lists their locations. USB access errors require permission to the matching `/dev/bus/usb` device. ### Per-controller ABXY layout diff --git a/adapter_configuration.cpp b/adapter_configuration.cpp new file mode 100644 index 0000000..dc0aa61 --- /dev/null +++ b/adapter_configuration.cpp @@ -0,0 +1,43 @@ +#include "adapter_configuration.h" + +AdapterConfiguration adapter_configuration_default() { + return {}; +} + +bool adapter_configuration_encode(const AdapterConfiguration& configuration, + uint8_t* output, size_t output_size) { + if (output == nullptr || output_size < ADAPTER_CONFIGURATION_ENCODED_SIZE || + configuration.pairing_window_seconds < + ADAPTER_PAIRING_WINDOW_SECONDS_MIN || + configuration.pairing_window_seconds > + ADAPTER_PAIRING_WINDOW_SECONDS_MAX) { + return false; + } + + output[0] = static_cast(configuration.pairing_window_seconds); + output[1] = + static_cast(configuration.pairing_window_seconds >> 8); + output[2] = 0; + output[3] = 0; + return true; +} + +bool adapter_configuration_decode(const uint8_t* payload, size_t payload_size, + AdapterConfiguration* output) { + if (payload == nullptr || output == nullptr || + payload_size != ADAPTER_CONFIGURATION_ENCODED_SIZE || + payload[2] != 0 || payload[3] != 0) { + return false; + } + + const uint16_t pairing_window_seconds = + static_cast(payload[0]) | + static_cast(payload[1] << 8); + if (pairing_window_seconds < ADAPTER_PAIRING_WINDOW_SECONDS_MIN || + pairing_window_seconds > ADAPTER_PAIRING_WINDOW_SECONDS_MAX) { + return false; + } + + output->pairing_window_seconds = pairing_window_seconds; + return true; +} diff --git a/adapter_configuration.h b/adapter_configuration.h new file mode 100644 index 0000000..9839af6 --- /dev/null +++ b/adapter_configuration.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +constexpr uint16_t ADAPTER_CONFIGURATION_SCHEMA_VERSION = 1; +constexpr size_t ADAPTER_CONFIGURATION_ENCODED_SIZE = 4; +constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_MIN = 10; +constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_MAX = 300; +constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT = 60; + +struct AdapterConfiguration { + uint16_t pairing_window_seconds = + ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT; +}; + +AdapterConfiguration adapter_configuration_default(); +bool adapter_configuration_encode(const AdapterConfiguration& configuration, + uint8_t* output, size_t output_size); +bool adapter_configuration_decode(const uint8_t* payload, size_t payload_size, + AdapterConfiguration* output); diff --git a/bluepad32_input_backend.cpp b/bluepad32_input_backend.cpp index ccaa78a..057669d 100644 --- a/bluepad32_input_backend.cpp +++ b/bluepad32_input_backend.cpp @@ -1,5 +1,6 @@ #include "bluepad32_input_backend.h" #include "controller_hotkey_config.h" +#include "configuration_service.h" #include #include @@ -28,8 +29,10 @@ constexpr uint16_t kSwitchHostRumbleDurationMs = 50; constexpr uint16_t kXInputHostRumbleDurationMs = UINT16_MAX; #endif constexpr uint32_t kRumblePollIntervalMs = 5; +constexpr uint32_t kConfigurationPollIntervalMs = 50; constexpr uint8_t kSlotCount = BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; -constexpr uint32_t kPairingWindowDurationMs = 60000; +constexpr uint32_t kDefaultPairingWindowDurationMs = + ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT * 1000u; constexpr uint32_t kPairingResetFeedbackDurationMs = 2000; // Bluetooth Classic units are 0.625 ms: 0x1900 = 4 seconds. constexpr uint16_t kClassicLinkSupervisionTimeout = 0x1900; @@ -142,11 +145,14 @@ bool g_started = false; // These fields are only read or written by Core 1 / BTstack. btstack_timer_source_t g_rumble_timer{}; +btstack_timer_source_t g_configuration_timer{}; ConnectionStatus g_connection_status = ConnectionStatus::Initializing; btstack_packet_callback_registration_t g_pairing_event_callback{}; ConnectionPolicyState g_connection_policy_state = ConnectionPolicyState::Uninitialized; uint32_t g_pairing_window_deadline_ms = 0; +uint32_t g_pairing_window_duration_ms = + kDefaultPairingWindowDurationMs; uint32_t g_pairing_reset_feedback_deadline_ms = 0; uint16_t g_status_led_tick = 0; bool g_pairing_window_open = false; @@ -531,8 +537,15 @@ bool update_pairing_window(uint32_t now_ms) { critical_section_exit(&g_state_lock); if (requested) { + ConfigurationServiceSnapshot configuration{}; + configuration_service_snapshot(&configuration); + g_pairing_window_duration_ms = + static_cast( + configuration.configuration.pairing_window_seconds) * + 1000u; g_pairing_window_open = true; - g_pairing_window_deadline_ms = now_ms + kPairingWindowDurationMs; + g_pairing_window_deadline_ms = + now_ms + g_pairing_window_duration_ms; gap_set_bondable_mode(true); sm_set_accepted_stk_generation_methods(kAllBlePairingMethods); g_status_led_tick = 0; @@ -719,8 +732,16 @@ void update_status_led() { } } +void process_configuration_timer(btstack_timer_source_t* timer) { + configuration_service_task_on_storage_core( + btstack_run_loop_get_time_ms()); + btstack_run_loop_set_timer(timer, kConfigurationPollIntervalMs); + btstack_run_loop_add_timer(timer); +} + void process_rumble_timer(btstack_timer_source_t* timer) { const uint32_t now_ms = btstack_run_loop_get_time_ms(); + process_clear_pairings(now_ms); process_pairing_snapshot_request(); if (update_pairing_window(now_ms)) { @@ -814,6 +835,11 @@ void platform_on_init_complete() { btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer); btstack_run_loop_set_timer(&g_rumble_timer, kRumblePollIntervalMs); btstack_run_loop_add_timer(&g_rumble_timer); + btstack_run_loop_set_timer_handler( + &g_configuration_timer, process_configuration_timer); + btstack_run_loop_set_timer( + &g_configuration_timer, kConfigurationPollIntervalMs); + btstack_run_loop_add_timer(&g_configuration_timer); recompute_connection_status(); } @@ -994,6 +1020,7 @@ uni_platform* get_platform() { if (!flash_safe_execute_core_init()) { halt_wireless_backend(); } + configuration_service_initialize_on_storage_core(); if (cyw43_arch_init() != 0) { halt_wireless_backend(); } @@ -1019,6 +1046,7 @@ void bluepad32_input_backend_init() { } critical_section_init(&g_state_lock); + configuration_service_prepare(); for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { BackendSlot& slot = g_slots[slot_index]; slot = {}; @@ -1037,6 +1065,8 @@ void bluepad32_input_backend_init() { g_connection_status = ConnectionStatus::Initializing; g_connection_policy_state = ConnectionPolicyState::Uninitialized; g_pairing_window_deadline_ms = 0; + g_pairing_window_duration_ms = + kDefaultPairingWindowDurationMs; g_pairing_reset_feedback_deadline_ms = 0; g_pairing_window_open = false; g_initialized = true; diff --git a/configuration_service.cpp b/configuration_service.cpp new file mode 100644 index 0000000..e693947 --- /dev/null +++ b/configuration_service.cpp @@ -0,0 +1,186 @@ +#include "configuration_service.h" + +#include + +#include "pico/critical_section.h" +#include "pico_configuration_storage.h" + +namespace { + +constexpr uint32_t kMinimumCommitIntervalMs = 1000; + +critical_section_t g_lock; +bool g_prepared = false; +ConfigurationStorage g_storage; +ConfigurationTransaction g_transaction; +ConfigurationServiceSnapshot g_snapshot; +bool g_has_committed = false; +uint32_t g_last_commit_ms = 0; + +void publish_storage_snapshot(ConfigurationServiceState state) { + const ConfigurationStorageSnapshot& stored = g_storage.snapshot(); + AdapterConfiguration configuration = adapter_configuration_default(); + if (stored.valid && + (stored.schema_version != ADAPTER_CONFIGURATION_SCHEMA_VERSION || + !adapter_configuration_decode(stored.payload, + stored.payload_size, + &configuration))) { + state = ConfigurationServiceState::kStorageError; + } + + critical_section_enter_blocking(&g_lock); + g_snapshot.state = state; + g_snapshot.configuration = configuration; + g_snapshot.generation = stored.valid ? stored.generation : 0; + g_snapshot.payload_crc = stored.valid ? stored.payload_crc : 0; + g_snapshot.transaction = g_transaction.snapshot(); + critical_section_exit(&g_lock); +} + +} // namespace + +void configuration_service_prepare() { + if (g_prepared) { + return; + } + critical_section_init(&g_lock); + g_snapshot = {}; + g_snapshot.configuration = adapter_configuration_default(); + g_transaction.clear(); + g_prepared = true; +} + +void configuration_service_initialize_on_storage_core() { + if (!g_prepared) { + configuration_service_prepare(); + } + const bool initialized = + g_storage.initialize(pico_configuration_storage_io()); + publish_storage_snapshot(initialized + ? ConfigurationServiceState::kReady + : ConfigurationServiceState::kStorageError); +} + +void configuration_service_task_on_storage_core(uint32_t now_ms) { + uint8_t payload[CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE]{}; + uint16_t payload_size = 0; + uint16_t schema_version = 0; + + critical_section_enter_blocking(&g_lock); + const ConfigurationTransactionSnapshot transaction = + g_transaction.snapshot(); + if (transaction.status == ConfigurationTransactionStatus::kPending && + (!g_has_committed || + static_cast(now_ms - g_last_commit_ms) >= + kMinimumCommitIntervalMs)) { + payload_size = transaction.expected_size; + schema_version = g_transaction.schema_version(); + memcpy(payload, g_transaction.payload(), payload_size); + } + critical_section_exit(&g_lock); + if (payload_size == 0) { + return; + } + + const ConfigurationStorageResult result = + g_storage.commit(schema_version, payload, payload_size); + const ConfigurationStorageSnapshot& stored = g_storage.snapshot(); + ConfigurationTransactionStatus transaction_status = + ConfigurationTransactionStatus::kStorageError; + if (result == ConfigurationStorageResult::kOk) { + transaction_status = ConfigurationTransactionStatus::kCommitted; + g_has_committed = true; + g_last_commit_ms = now_ms; + } else if (result == ConfigurationStorageResult::kUnchanged) { + transaction_status = ConfigurationTransactionStatus::kUnchanged; + } + + AdapterConfiguration configuration{}; + ConfigurationServiceState service_state = + ConfigurationServiceState::kStorageError; + if (stored.valid && + stored.schema_version == ADAPTER_CONFIGURATION_SCHEMA_VERSION && + adapter_configuration_decode(stored.payload, stored.payload_size, + &configuration)) { + service_state = ConfigurationServiceState::kReady; + } else if (!stored.valid) { + configuration = adapter_configuration_default(); + } + + critical_section_enter_blocking(&g_lock); + g_transaction.set_result(transaction_status, + stored.valid ? stored.generation : 0, + stored.valid ? stored.payload_crc : 0); + g_snapshot.state = service_state; + g_snapshot.configuration = configuration; + g_snapshot.generation = stored.valid ? stored.generation : 0; + g_snapshot.payload_crc = stored.valid ? stored.payload_crc : 0; + g_snapshot.transaction = g_transaction.snapshot(); + critical_section_exit(&g_lock); +} + +ConfigurationTransactionStatus configuration_service_begin( + uint32_t transaction_id, uint16_t schema_version, size_t payload_size, + uint32_t payload_crc) { + critical_section_enter_blocking(&g_lock); + const ConfigurationTransactionStatus status = g_transaction.begin( + transaction_id, schema_version, payload_size, payload_crc); + g_snapshot.transaction = g_transaction.snapshot(); + critical_section_exit(&g_lock); + return status; +} + +ConfigurationTransactionStatus configuration_service_append( + uint32_t transaction_id, size_t offset, const uint8_t* data, + size_t size) { + critical_section_enter_blocking(&g_lock); + const ConfigurationTransactionStatus status = + g_transaction.append(transaction_id, offset, data, size); + g_snapshot.transaction = g_transaction.snapshot(); + critical_section_exit(&g_lock); + return status; +} + +ConfigurationTransactionStatus configuration_service_commit( + uint32_t transaction_id) { + critical_section_enter_blocking(&g_lock); + const ConfigurationTransactionStatus status = + g_transaction.finish(transaction_id); + g_snapshot.transaction = g_transaction.snapshot(); + critical_section_exit(&g_lock); + return status; +} + +ConfigurationTransactionStatus configuration_service_reset( + uint32_t transaction_id) { + uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; + const AdapterConfiguration defaults = adapter_configuration_default(); + if (!adapter_configuration_encode(defaults, payload, sizeof(payload))) { + return ConfigurationTransactionStatus::kMalformed; + } + const uint32_t crc = configuration_crc32(payload, sizeof(payload)); + + critical_section_enter_blocking(&g_lock); + ConfigurationTransactionStatus status = g_transaction.begin( + transaction_id, ADAPTER_CONFIGURATION_SCHEMA_VERSION, + sizeof(payload), crc); + if (status == ConfigurationTransactionStatus::kReceiving) { + status = g_transaction.append(transaction_id, 0, payload, + sizeof(payload)); + } + if (status == ConfigurationTransactionStatus::kReceiving) { + status = g_transaction.finish(transaction_id); + } + g_snapshot.transaction = g_transaction.snapshot(); + critical_section_exit(&g_lock); + return status; +} + +void configuration_service_snapshot(ConfigurationServiceSnapshot* output) { + if (output == nullptr) { + return; + } + critical_section_enter_blocking(&g_lock); + *output = g_snapshot; + critical_section_exit(&g_lock); +} diff --git a/configuration_service.h b/configuration_service.h new file mode 100644 index 0000000..5a12709 --- /dev/null +++ b/configuration_service.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +#include "adapter_configuration.h" +#include "configuration_transaction.h" + +enum class ConfigurationServiceState : uint8_t { + kLoading = 0, + kReady = 1, + kStorageError = 2, +}; + +struct ConfigurationServiceSnapshot { + ConfigurationServiceState state = ConfigurationServiceState::kLoading; + AdapterConfiguration configuration{}; + uint32_t generation = 0; + uint32_t payload_crc = 0; + ConfigurationTransactionSnapshot transaction{}; +}; + +void configuration_service_prepare(); +void configuration_service_initialize_on_storage_core(); +void configuration_service_task_on_storage_core(uint32_t now_ms); + +ConfigurationTransactionStatus configuration_service_begin( + uint32_t transaction_id, uint16_t schema_version, size_t payload_size, + uint32_t payload_crc); +ConfigurationTransactionStatus configuration_service_append( + uint32_t transaction_id, size_t offset, const uint8_t* data, + size_t size); +ConfigurationTransactionStatus configuration_service_commit( + uint32_t transaction_id); +ConfigurationTransactionStatus configuration_service_reset( + uint32_t transaction_id); +void configuration_service_snapshot(ConfigurationServiceSnapshot* output); diff --git a/configuration_storage.cpp b/configuration_storage.cpp new file mode 100644 index 0000000..80b3269 --- /dev/null +++ b/configuration_storage.cpp @@ -0,0 +1,197 @@ +#include "configuration_storage.h" + +#include + +namespace { + +constexpr uint8_t kRecordMagic[4] = {'S', 'P', 'C', 'F'}; +constexpr uint16_t kRecordFormatVersion = 1; +constexpr size_t kHeaderCrcOffset = 20; + +uint16_t read_u16(const uint8_t* input) { + return static_cast(input[0]) | + static_cast(input[1] << 8); +} + +uint32_t read_u32(const uint8_t* input) { + return static_cast(input[0]) | + (static_cast(input[1]) << 8) | + (static_cast(input[2]) << 16) | + (static_cast(input[3]) << 24); +} + +void write_u16(uint8_t* output, uint16_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); +} + +void write_u32(uint8_t* output, uint32_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); + output[2] = static_cast(value >> 16); + output[3] = static_cast(value >> 24); +} + +bool generation_is_newer(uint32_t candidate, uint32_t current) { + const uint32_t difference = candidate - current; + return difference != 0 && difference < 0x80000000u; +} + +} // namespace + +uint32_t configuration_crc32(const uint8_t* data, size_t size) { + uint32_t crc = 0xffffffffu; + for (size_t index = 0; index < size; ++index) { + crc ^= data[index]; + for (uint8_t bit = 0; bit < 8; ++bit) { + const uint32_t mask = 0u - (crc & 1u); + crc = (crc >> 1) ^ (0xedb88320u & mask); + } + } + return ~crc; +} + +bool ConfigurationStorage::initialize(const ConfigurationStorageIo& io) { + io_ = io; + snapshot_ = {}; + active_copy_ = 0; + initialized_ = io_.read != nullptr && io_.erase != nullptr && + io_.program != nullptr && io_.page_size != 0 && + io_.page_size <= CONFIGURATION_STORAGE_MAX_PAGE_SIZE && + io_.sector_size >= CONFIGURATION_STORAGE_MAX_RECORD_SIZE && + io_.sector_size % io_.page_size == 0; + if (!initialized_) { + return false; + } + + ConfigurationStorageSnapshot copies[CONFIGURATION_STORAGE_COPY_COUNT]{}; + const bool first_valid = read_copy(0, &copies[0]); + const bool second_valid = read_copy(1, &copies[1]); + if (first_valid && second_valid) { + active_copy_ = generation_is_newer(copies[1].generation, + copies[0].generation) + ? 1 + : 0; + snapshot_ = copies[active_copy_]; + } else if (first_valid) { + active_copy_ = 0; + snapshot_ = copies[0]; + } else if (second_valid) { + active_copy_ = 1; + snapshot_ = copies[1]; + } + return true; +} + +const ConfigurationStorageSnapshot& ConfigurationStorage::snapshot() const { + return snapshot_; +} + +bool ConfigurationStorage::read_copy( + uint8_t copy, ConfigurationStorageSnapshot* output) const { + uint8_t header[CONFIGURATION_STORAGE_RECORD_HEADER_SIZE]{}; + if (output == nullptr || + !io_.read(io_.context, copy, 0, header, sizeof(header)) || + memcmp(header, kRecordMagic, sizeof(kRecordMagic)) != 0 || + read_u16(&header[4]) != kRecordFormatVersion || + read_u16(&header[6]) != CONFIGURATION_STORAGE_RECORD_HEADER_SIZE || + configuration_crc32(header, kHeaderCrcOffset) != + read_u32(&header[kHeaderCrcOffset])) { + return false; + } + + const uint16_t payload_size = read_u16(&header[8]); + const uint16_t schema_version = read_u16(&header[10]); + if (payload_size == 0 || + payload_size > CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE || + schema_version == 0 || + CONFIGURATION_STORAGE_RECORD_HEADER_SIZE + payload_size > + io_.sector_size) { + return false; + } + + ConfigurationStorageSnapshot candidate{}; + if (!io_.read(io_.context, copy, + CONFIGURATION_STORAGE_RECORD_HEADER_SIZE, + candidate.payload, payload_size)) { + return false; + } + candidate.payload_crc = read_u32(&header[16]); + if (configuration_crc32(candidate.payload, payload_size) != + candidate.payload_crc) { + return false; + } + + candidate.valid = true; + candidate.payload_size = payload_size; + candidate.schema_version = schema_version; + candidate.generation = read_u32(&header[12]); + *output = candidate; + return true; +} + +ConfigurationStorageResult ConfigurationStorage::commit( + uint16_t schema_version, const uint8_t* payload, size_t payload_size) { + if (!initialized_ || schema_version == 0 || payload == nullptr || + payload_size == 0 || + payload_size > CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE) { + return ConfigurationStorageResult::kInvalidArgument; + } + + const uint32_t payload_crc = configuration_crc32(payload, payload_size); + if (snapshot_.valid && snapshot_.schema_version == schema_version && + snapshot_.payload_size == payload_size && + snapshot_.payload_crc == payload_crc && + memcmp(snapshot_.payload, payload, payload_size) == 0) { + return ConfigurationStorageResult::kUnchanged; + } + + const size_t program_size = + ((CONFIGURATION_STORAGE_RECORD_HEADER_SIZE + payload_size + + io_.page_size - 1) / + io_.page_size) * + io_.page_size; + if (program_size > CONFIGURATION_STORAGE_MAX_PROGRAM_SIZE || + program_size > io_.sector_size) { + return ConfigurationStorageResult::kInvalidArgument; + } + + uint8_t record[CONFIGURATION_STORAGE_MAX_PROGRAM_SIZE]; + memset(record, 0xff, sizeof(record)); + memcpy(record, kRecordMagic, sizeof(kRecordMagic)); + write_u16(&record[4], kRecordFormatVersion); + write_u16(&record[6], CONFIGURATION_STORAGE_RECORD_HEADER_SIZE); + write_u16(&record[8], static_cast(payload_size)); + write_u16(&record[10], schema_version); + const uint32_t generation = snapshot_.valid + ? snapshot_.generation + 1u + : 1u; + write_u32(&record[12], generation); + write_u32(&record[16], payload_crc); + write_u32(&record[kHeaderCrcOffset], + configuration_crc32(record, kHeaderCrcOffset)); + memcpy(&record[CONFIGURATION_STORAGE_RECORD_HEADER_SIZE], payload, + payload_size); + + const uint8_t target_copy = snapshot_.valid ? active_copy_ ^ 1u : 0u; + if (!io_.erase(io_.context, target_copy)) { + return ConfigurationStorageResult::kIoError; + } + for (size_t offset = 0; offset < program_size; + offset += io_.page_size) { + if (!io_.program(io_.context, target_copy, offset, + &record[offset], io_.page_size)) { + return ConfigurationStorageResult::kIoError; + } + } + + ConfigurationStorageSnapshot committed{}; + if (!read_copy(target_copy, &committed) || + committed.generation != generation || + committed.payload_crc != payload_crc) { + return ConfigurationStorageResult::kIoError; + } + active_copy_ = target_copy; + snapshot_ = committed; + return ConfigurationStorageResult::kOk; +} diff --git a/configuration_storage.h b/configuration_storage.h new file mode 100644 index 0000000..1dd2528 --- /dev/null +++ b/configuration_storage.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include + +constexpr size_t CONFIGURATION_STORAGE_COPY_COUNT = 2; +constexpr size_t CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE = 512; +constexpr size_t CONFIGURATION_STORAGE_RECORD_HEADER_SIZE = 24; +constexpr size_t CONFIGURATION_STORAGE_MAX_PAGE_SIZE = 256; +constexpr size_t CONFIGURATION_STORAGE_MAX_RECORD_SIZE = + CONFIGURATION_STORAGE_RECORD_HEADER_SIZE + + CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE; +constexpr size_t CONFIGURATION_STORAGE_MAX_PROGRAM_SIZE = + ((CONFIGURATION_STORAGE_MAX_RECORD_SIZE + + CONFIGURATION_STORAGE_MAX_PAGE_SIZE - 1) / + CONFIGURATION_STORAGE_MAX_PAGE_SIZE) * + CONFIGURATION_STORAGE_MAX_PAGE_SIZE; + +uint32_t configuration_crc32(const uint8_t* data, size_t size); + +enum class ConfigurationStorageResult : uint8_t { + kOk, + kUnchanged, + kInvalidArgument, + kIoError, +}; + +struct ConfigurationStorageIo { + void* context = nullptr; + size_t sector_size = 0; + size_t page_size = 0; + bool (*read)(void* context, uint8_t copy, size_t offset, + uint8_t* output, size_t size) = nullptr; + bool (*erase)(void* context, uint8_t copy) = nullptr; + bool (*program)(void* context, uint8_t copy, size_t offset, + const uint8_t* data, size_t size) = nullptr; +}; + +struct ConfigurationStorageSnapshot { + bool valid = false; + uint16_t schema_version = 0; + uint16_t payload_size = 0; + uint32_t generation = 0; + uint32_t payload_crc = 0; + uint8_t payload[CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE]{}; +}; + +class ConfigurationStorage { +public: + bool initialize(const ConfigurationStorageIo& io); + const ConfigurationStorageSnapshot& snapshot() const; + ConfigurationStorageResult commit(uint16_t schema_version, + const uint8_t* payload, + size_t payload_size); + +private: + bool read_copy(uint8_t copy, ConfigurationStorageSnapshot* output) const; + + ConfigurationStorageIo io_{}; + ConfigurationStorageSnapshot snapshot_{}; + uint8_t active_copy_ = 0; + bool initialized_ = false; +}; diff --git a/configuration_transaction.cpp b/configuration_transaction.cpp new file mode 100644 index 0000000..5e2c1bd --- /dev/null +++ b/configuration_transaction.cpp @@ -0,0 +1,108 @@ +#include "configuration_transaction.h" + +#include + +#include "adapter_configuration.h" + +ConfigurationTransactionStatus ConfigurationTransaction::begin( + uint32_t transaction_id, uint16_t schema_version, size_t payload_size, + uint32_t payload_crc) { + if (snapshot_.status == ConfigurationTransactionStatus::kReceiving || + snapshot_.status == ConfigurationTransactionStatus::kPending) { + return ConfigurationTransactionStatus::kBusy; + } + snapshot_ = {}; + snapshot_.transaction_id = transaction_id; + if (transaction_id == 0 || payload_size == 0) { + snapshot_.status = ConfigurationTransactionStatus::kMalformed; + return snapshot_.status; + } + if (schema_version != ADAPTER_CONFIGURATION_SCHEMA_VERSION) { + snapshot_.status = + ConfigurationTransactionStatus::kUnsupportedSchema; + return snapshot_.status; + } + if (payload_size > CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE) { + snapshot_.status = ConfigurationTransactionStatus::kTooLarge; + return snapshot_.status; + } + + snapshot_.expected_size = static_cast(payload_size); + snapshot_.expected_crc = payload_crc; + snapshot_.status = ConfigurationTransactionStatus::kReceiving; + schema_version_ = schema_version; + return snapshot_.status; +} + +ConfigurationTransactionStatus ConfigurationTransaction::append( + uint32_t transaction_id, size_t offset, const uint8_t* data, + size_t size) { + if (snapshot_.status != ConfigurationTransactionStatus::kReceiving) { + return ConfigurationTransactionStatus::kBusy; + } + if (transaction_id != snapshot_.transaction_id || data == nullptr || + size == 0 || offset != snapshot_.received_size || + offset + size > snapshot_.expected_size) { + snapshot_.status = ConfigurationTransactionStatus::kOutOfOrder; + return snapshot_.status; + } + + memcpy(&payload_[offset], data, size); + snapshot_.received_size = + static_cast(snapshot_.received_size + size); + return snapshot_.status; +} + +ConfigurationTransactionStatus ConfigurationTransaction::finish( + uint32_t transaction_id) { + if (snapshot_.status != ConfigurationTransactionStatus::kReceiving || + transaction_id != snapshot_.transaction_id) { + snapshot_.status = ConfigurationTransactionStatus::kOutOfOrder; + return snapshot_.status; + } + if (snapshot_.received_size != snapshot_.expected_size) { + snapshot_.status = ConfigurationTransactionStatus::kOutOfOrder; + return snapshot_.status; + } + if (configuration_crc32(payload_, snapshot_.expected_size) != + snapshot_.expected_crc) { + snapshot_.status = ConfigurationTransactionStatus::kBadCrc; + return snapshot_.status; + } + + AdapterConfiguration configuration{}; + if (!adapter_configuration_decode(payload_, snapshot_.expected_size, + &configuration)) { + snapshot_.status = ConfigurationTransactionStatus::kMalformed; + return snapshot_.status; + } + + snapshot_.status = ConfigurationTransactionStatus::kPending; + return snapshot_.status; +} + +void ConfigurationTransaction::set_result( + ConfigurationTransactionStatus status, uint32_t stored_generation, + uint32_t stored_crc) { + snapshot_.status = status; + snapshot_.stored_generation = stored_generation; + snapshot_.stored_crc = stored_crc; +} + +void ConfigurationTransaction::clear() { + snapshot_ = {}; + schema_version_ = 0; +} + +const ConfigurationTransactionSnapshot& +ConfigurationTransaction::snapshot() const { + return snapshot_; +} + +uint16_t ConfigurationTransaction::schema_version() const { + return schema_version_; +} + +const uint8_t* ConfigurationTransaction::payload() const { + return payload_; +} diff --git a/configuration_transaction.h b/configuration_transaction.h new file mode 100644 index 0000000..0045c69 --- /dev/null +++ b/configuration_transaction.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include + +#include "configuration_storage.h" + +enum class ConfigurationTransactionStatus : uint8_t { + kIdle = 0, + kReceiving = 1, + kPending = 2, + kCommitted = 3, + kUnchanged = 4, + kMalformed = 5, + kUnsupportedSchema = 6, + kTooLarge = 7, + kOutOfOrder = 8, + kBadCrc = 9, + kBusy = 10, + kStorageError = 11, +}; + +struct ConfigurationTransactionSnapshot { + uint32_t transaction_id = 0; + uint16_t received_size = 0; + uint16_t expected_size = 0; + uint32_t expected_crc = 0; + uint32_t stored_generation = 0; + uint32_t stored_crc = 0; + ConfigurationTransactionStatus status = + ConfigurationTransactionStatus::kIdle; +}; + +class ConfigurationTransaction { +public: + ConfigurationTransactionStatus begin(uint32_t transaction_id, + uint16_t schema_version, + size_t payload_size, + uint32_t payload_crc); + ConfigurationTransactionStatus append(uint32_t transaction_id, + size_t offset, + const uint8_t* data, + size_t size); + ConfigurationTransactionStatus finish(uint32_t transaction_id); + void set_result(ConfigurationTransactionStatus status, + uint32_t stored_generation, uint32_t stored_crc); + void clear(); + + const ConfigurationTransactionSnapshot& snapshot() const; + uint16_t schema_version() const; + const uint8_t* payload() const; + +private: + ConfigurationTransactionSnapshot snapshot_{}; + uint16_t schema_version_ = 0; + uint8_t payload_[CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE]{}; +}; diff --git a/firmware/switch-pico-adapter-feasibility.elf b/firmware/switch-pico-adapter-feasibility.elf index 0b6642e..c564e0a 100755 Binary files a/firmware/switch-pico-adapter-feasibility.elf and b/firmware/switch-pico-adapter-feasibility.elf differ diff --git a/firmware/switch-pico-adapter-feasibility.uf2 b/firmware/switch-pico-adapter-feasibility.uf2 index f5f6109..92fb02e 100644 Binary files a/firmware/switch-pico-adapter-feasibility.uf2 and b/firmware/switch-pico-adapter-feasibility.uf2 differ diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index ed48956..353f88f 100755 Binary files a/firmware/switch-pico-aio.elf and b/firmware/switch-pico-aio.elf differ diff --git a/firmware/switch-pico-aio.uf2 b/firmware/switch-pico-aio.uf2 index a3a4786..2bb4624 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/firmware/switch-pico.elf b/firmware/switch-pico.elf index 00900a6..caa8707 100755 Binary files a/firmware/switch-pico.elf and b/firmware/switch-pico.elf differ diff --git a/firmware/switch-pico.uf2 b/firmware/switch-pico.uf2 index 69063a8..12157a0 100644 Binary files a/firmware/switch-pico.uf2 and b/firmware/switch-pico.uf2 differ diff --git a/pico_configuration_storage.cpp b/pico_configuration_storage.cpp new file mode 100644 index 0000000..ee0b6d1 --- /dev/null +++ b/pico_configuration_storage.cpp @@ -0,0 +1,111 @@ +#include "pico_configuration_storage.h" + +#include + +#include "hardware/flash.h" +#include "pico/btstack_flash_bank.h" +#include "pico/flash.h" +#include "pico/platform.h" +extern "C" char __flash_binary_end; + + +namespace { + +constexpr size_t kConfigurationStorageSize = + CONFIGURATION_STORAGE_COPY_COUNT * FLASH_SECTOR_SIZE; +constexpr uint32_t kConfigurationStorageOffset = + PICO_FLASH_BANK_STORAGE_OFFSET - kConfigurationStorageSize; + +static_assert(PICO_FLASH_BANK_STORAGE_OFFSET >= kConfigurationStorageSize, + "configuration storage offset underflows flash"); +static_assert(kConfigurationStorageOffset + kConfigurationStorageSize <= + PICO_FLASH_BANK_STORAGE_OFFSET, + "configuration storage overlaps BTstack bonds"); +static_assert(PICO_FLASH_BANK_STORAGE_OFFSET + + PICO_FLASH_BANK_TOTAL_SIZE <= + PICO_FLASH_SIZE_BYTES, + "BTstack storage exceeds flash"); +static_assert(CONFIGURATION_STORAGE_MAX_RECORD_SIZE <= FLASH_SECTOR_SIZE, + "configuration record does not fit one flash sector"); +static_assert(CONFIGURATION_STORAGE_MAX_PAGE_SIZE == FLASH_PAGE_SIZE, + "configuration page buffer does not match Pico flash"); + +struct FlashMutation { + bool erase; + uint32_t offset; + const uint8_t* data; +}; + +void perform_flash_mutation(void* context) { + const auto* mutation = static_cast(context); + if (mutation->erase) { + flash_range_erase(mutation->offset, FLASH_SECTOR_SIZE); + } else { + flash_range_program(mutation->offset, mutation->data, + FLASH_PAGE_SIZE); + } +} + +bool storage_region_available() { + + const uintptr_t binary_end = + reinterpret_cast(&__flash_binary_end) - XIP_BASE; + return binary_end <= kConfigurationStorageOffset; +} + +bool read_storage(void*, uint8_t copy, size_t offset, uint8_t* output, + size_t size) { + if (copy >= CONFIGURATION_STORAGE_COPY_COUNT || output == nullptr || + offset + size > FLASH_SECTOR_SIZE || !storage_region_available()) { + return false; + } + const uintptr_t address = XIP_BASE + kConfigurationStorageOffset + + copy * FLASH_SECTOR_SIZE + offset; + memcpy(output, reinterpret_cast(address), size); + return true; +} + +bool erase_storage(void*, uint8_t copy) { + if (copy >= CONFIGURATION_STORAGE_COPY_COUNT || + !storage_region_available()) { + return false; + } + FlashMutation mutation{ + true, + static_cast(kConfigurationStorageOffset + + copy * FLASH_SECTOR_SIZE), + nullptr, + }; + return flash_safe_execute(perform_flash_mutation, &mutation, + UINT32_MAX) == PICO_OK; +} + +bool program_storage(void*, uint8_t copy, size_t offset, + const uint8_t* data, size_t size) { + if (copy >= CONFIGURATION_STORAGE_COPY_COUNT || data == nullptr || + size != FLASH_PAGE_SIZE || offset % FLASH_PAGE_SIZE != 0 || + offset + size > FLASH_SECTOR_SIZE || !storage_region_available()) { + return false; + } + FlashMutation mutation{ + false, + static_cast(kConfigurationStorageOffset + + copy * FLASH_SECTOR_SIZE + offset), + data, + }; + return flash_safe_execute(perform_flash_mutation, &mutation, + UINT32_MAX) == PICO_OK; +} + +} // namespace + +ConfigurationStorageIo pico_configuration_storage_io() { + return { + nullptr, + FLASH_SECTOR_SIZE, + FLASH_PAGE_SIZE, + read_storage, + erase_storage, + program_storage, + }; +} diff --git a/pico_configuration_storage.h b/pico_configuration_storage.h new file mode 100644 index 0000000..abe9f82 --- /dev/null +++ b/pico_configuration_storage.h @@ -0,0 +1,5 @@ +#pragma once + +#include "configuration_storage.h" + +ConfigurationStorageIo pico_configuration_storage_io(); diff --git a/pyproject.toml b/pyproject.toml index 9eefdf6..a504030 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ [project.scripts] controller-uart-bridge = "switch_pico_bridge.controller_uart_bridge:main" host-uart-logger = "switch_pico_bridge.host_uart_logger:main" -switch-pico-pairings = "switch_pico_bridge.pairing_manager:main" +switch-pico-config = "switch_pico_bridge.config_manager:main" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py new file mode 100755 index 0000000..3d7169d --- /dev/null +++ b/src/switch_pico_bridge/config_manager.py @@ -0,0 +1,600 @@ +#!/usr/bin/env python3 +"""Manage switch-pico persistent configuration and pairings over USB EP0.""" + +from __future__ import annotations + +import argparse +import secrets +import struct +import sys +import time +import zlib +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from typing import Any, Protocol, cast + +import usb.core + +USB_IDENTITIES = ((0x057E, 0x2009), (0xCAFE, 0x4010)) +REQUEST_VALUE = 0x5350 +REQUEST_INDEX = 0x0001 +PROTOCOL_VERSION = 1 +REQUEST_HEADER_SIZE = 16 +RESPONSE_HEADER_SIZE = 20 +MAXIMUM_REQUEST_SIZE = 64 +MAXIMUM_RESPONSE_SIZE = 152 +MAXIMUM_CHUNK_SIZE = 40 +USB_TIMEOUT_MS = 1000 + +OP_INFO = 0x01 +OP_CONFIGURATION_READ = 0x10 +OP_CONFIGURATION_BEGIN = 0x11 +OP_CONFIGURATION_CHUNK = 0x12 +OP_CONFIGURATION_COMMIT = 0x13 +OP_CONFIGURATION_RESET = 0x14 +OP_TRANSACTION_STATUS = 0x15 +OP_PAIRING_READ = 0x20 +OP_PAIRING_REFRESH = 0x21 +OP_PAIRING_CLEAR = 0x22 + +STATUS_OK = 0 +STATUS_PENDING = 1 +STATUS_NAMES = { + 2: "malformed request", + 3: "unsupported schema", + 4: "value too large", + 5: "out-of-order transaction", + 6: "CRC mismatch", + 7: "device busy", + 8: "storage failure", +} + +CONFIGURATION_SCHEMA_VERSION = 1 +CONFIGURATION_SIZE = 4 +PAIRING_WINDOW_SECONDS_MIN = 10 +PAIRING_WINDOW_SECONDS_MAX = 300 +PAIRING_RECORD_SIZE = 8 +PAIRING_RECORD_CAPACITY = 16 +TRANSPORT_CLASSIC = 1 +TRANSPORT_BLE = 2 + + +class ConfigManagerError(RuntimeError): + """Expected discovery, USB transport, or management protocol failure.""" + + +class UsbDevice(Protocol): + bus: int | None + address: int | None + + def ctrl_transfer( + self, + bm_request_type: int, + request: int, + value: int = 0, + index: int = 0, + data_or_w_length: Any = None, + timeout: int | None = None, + ) -> Any: + ... + + +@dataclass(frozen=True) +class Envelope: + operation: int + status: int + flags: int + schema_version: int + generation: int + payload_crc: int + payload: bytes + + +@dataclass(frozen=True) +class DeviceInfo: + firmware_version: tuple[int, int, int] + board: int + active_mode: int + maximum_configuration_size: int + + +@dataclass(frozen=True) +class AdapterConfiguration: + pairing_window_seconds: int + generation: int + crc: int + + +@dataclass(frozen=True) +class TransactionStatus: + transaction_id: int + received_size: int + expected_size: int + expected_crc: int + stored_generation: int + stored_crc: int + status: int + + +@dataclass(frozen=True) +class PairingRecord: + transport: int + address_type: int + address: bytes + + @property + def address_text(self) -> str: + return ":".join(f"{octet:02X}" for octet in self.address) + + @property + def transport_text(self) -> str: + if self.transport == TRANSPORT_CLASSIC: + return "Classic" + if self.transport == TRANSPORT_BLE: + address_types = { + 0: "public", + 1: "random", + 2: "public identity", + 3: "random identity", + } + suffix = address_types.get( + self.address_type, f"type {self.address_type}" + ) + return f"BLE ({suffix})" + return f"unknown transport {self.transport}" + + +@dataclass(frozen=True) +class PairingSnapshot: + generation: int + pending: bool + overflow: bool + records: tuple[PairingRecord, ...] + + +def _crc32(payload: bytes) -> int: + return zlib.crc32(payload) & 0xFFFFFFFF + + +def encode_request(operation: int, payload: bytes = b"") -> bytes: + if len(payload) + REQUEST_HEADER_SIZE > MAXIMUM_REQUEST_SIZE: + raise ConfigManagerError("management request exceeds EP0 limit") + return struct.pack( + "<4sBBBBHHI", + b"SPMG", + PROTOCOL_VERSION, + operation, + 0, + 0, + len(payload), + 0, + _crc32(payload), + ) + payload + + +def parse_response(payload: bytes, expected_operation: int) -> Envelope: + if len(payload) < RESPONSE_HEADER_SIZE: + raise ConfigManagerError("short management response") + ( + magic, + version, + operation, + status, + flags, + payload_size, + schema_version, + generation, + payload_crc, + ) = struct.unpack_from("<4sBBBBHHII", payload) + if magic != b"SPMG": + raise ConfigManagerError("device does not implement switch-pico management") + if version != PROTOCOL_VERSION: + raise ConfigManagerError( + f"unsupported management protocol version {version}" + ) + if operation != expected_operation: + raise ConfigManagerError( + f"unexpected management operation 0x{operation:02x}" + ) + if len(payload) != RESPONSE_HEADER_SIZE + payload_size: + raise ConfigManagerError("invalid management payload size") + body = bytes(payload[RESPONSE_HEADER_SIZE:]) + if _crc32(body) != payload_crc: + raise ConfigManagerError("management response CRC mismatch") + return Envelope( + operation=operation, + status=status, + flags=flags, + schema_version=schema_version, + generation=generation, + payload_crc=payload_crc, + payload=body, + ) + + +def _raise_status(envelope: Envelope, *, pending_ok: bool = False) -> None: + if envelope.status == STATUS_OK: + return + if envelope.status == STATUS_PENDING and pending_ok: + return + if envelope.status == STATUS_PENDING: + raise ConfigManagerError("device operation is still pending") + raise ConfigManagerError( + STATUS_NAMES.get( + envelope.status, f"unknown device status {envelope.status}" + ) + ) + + +def _control_in(device: UsbDevice, operation: int) -> Envelope: + payload = device.ctrl_transfer( + 0xC0, + operation, + REQUEST_VALUE, + REQUEST_INDEX, + MAXIMUM_RESPONSE_SIZE, + timeout=USB_TIMEOUT_MS, + ) + return parse_response(bytes(payload), operation) + + +def _control_out( + device: UsbDevice, operation: int, payload: bytes = b"" +) -> None: + request = encode_request(operation, payload) + device.ctrl_transfer( + 0x40, + operation, + REQUEST_VALUE, + REQUEST_INDEX, + request, + timeout=USB_TIMEOUT_MS, + ) + + +def read_info(device: UsbDevice) -> DeviceInfo: + envelope = _control_in(device, OP_INFO) + _raise_status(envelope) + if len(envelope.payload) != 8: + raise ConfigManagerError("invalid device-info payload") + return DeviceInfo( + firmware_version=( + envelope.payload[0], + envelope.payload[1], + envelope.payload[2], + ), + board=envelope.payload[3], + active_mode=envelope.payload[4], + maximum_configuration_size=struct.unpack_from( + " AdapterConfiguration: + envelope = _control_in(device, OP_CONFIGURATION_READ) + _raise_status(envelope) + if ( + envelope.schema_version != CONFIGURATION_SCHEMA_VERSION + or len(envelope.payload) != CONFIGURATION_SIZE + or envelope.payload[2:] != b"\x00\x00" + ): + raise ConfigManagerError("unsupported configuration object") + pairing_window_seconds = struct.unpack_from(" TransactionStatus: + envelope = _control_in(device, OP_TRANSACTION_STATUS) + _raise_status(envelope, pending_ok=True) + if len(envelope.payload) != 20: + raise ConfigManagerError("invalid transaction-status payload") + values = struct.unpack(" TransactionStatus: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + status = read_transaction_status(device) + if status.transaction_id != transaction_id: + raise ConfigManagerError("device reported a different transaction") + if status.status == STATUS_OK: + return status + time.sleep(0.05) + raise ConfigManagerError("configuration commit did not finish") + + +def write_configuration( + device: UsbDevice, configuration: AdapterConfiguration, timeout: float +) -> TransactionStatus: + if not ( + PAIRING_WINDOW_SECONDS_MIN + <= configuration.pairing_window_seconds + <= PAIRING_WINDOW_SECONDS_MAX + ): + raise ConfigManagerError( + "pairing window must be between 10 and 300 seconds" + ) + payload = struct.pack(" TransactionStatus: + transaction_id = secrets.randbits(32) or 1 + _control_out( + device, OP_CONFIGURATION_RESET, struct.pack(" PairingSnapshot: + _raise_status(envelope, pending_ok=True) + if len(envelope.payload) < 4: + raise ConfigManagerError("short pairing snapshot") + record_count = envelope.payload[0] + required = 4 + record_count * PAIRING_RECORD_SIZE + if ( + record_count > PAIRING_RECORD_CAPACITY + or len(envelope.payload) != required + ): + raise ConfigManagerError("invalid pairing record count") + records: list[PairingRecord] = [] + offset = 4 + for _ in range(record_count): + records.append( + PairingRecord( + transport=envelope.payload[offset], + address_type=envelope.payload[offset + 1], + address=envelope.payload[offset + 2 : offset + 8], + ) + ) + offset += PAIRING_RECORD_SIZE + return PairingSnapshot( + generation=envelope.generation, + pending=envelope.status == STATUS_PENDING, + overflow=bool(envelope.flags & 1 or envelope.payload[1] & 1), + records=tuple(records), + ) + + +def read_pairings(device: UsbDevice) -> PairingSnapshot: + return parse_pairing_snapshot(_control_in(device, OP_PAIRING_READ)) + + +def _wait_for_pairings( + device: UsbDevice, previous_generation: int, timeout: float +) -> PairingSnapshot: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + snapshot = read_pairings(device) + if not snapshot.pending and snapshot.generation != previous_generation: + return snapshot + time.sleep(0.05) + raise ConfigManagerError("Pico did not finish the pairing operation") + + +def refresh_pairings(device: UsbDevice, timeout: float) -> PairingSnapshot: + initial = read_pairings(device) + _control_out(device, OP_PAIRING_REFRESH) + return _wait_for_pairings(device, initial.generation, timeout) + + +def clear_pairings(device: UsbDevice, timeout: float) -> PairingSnapshot: + initial = read_pairings(device) + _control_out(device, OP_PAIRING_CLEAR) + snapshot = _wait_for_pairings(device, initial.generation, timeout) + if snapshot.records: + raise ConfigManagerError("Pico reported pairings after clear completed") + return snapshot + + +def _candidate_devices() -> Iterable[UsbDevice]: + for vendor_id, product_id in USB_IDENTITIES: + devices = cast( + Iterable[UsbDevice] | None, + usb.core.find( + find_all=True, idVendor=vendor_id, idProduct=product_id + ), + ) + if devices is not None: + yield from devices + + +def find_pico( + bus: int | None, address: int | None, timeout: float = 3.0 +) -> UsbDevice: + deadline = time.monotonic() + timeout + failures: list[Exception] = [] + while True: + matches: list[UsbDevice] = [] + for device in _candidate_devices(): + if bus is not None and getattr(device, "bus", None) != bus: + continue + if address is not None and getattr(device, "address", None) != address: + continue + try: + _ = read_info(device) + except (ConfigManagerError, usb.core.USBError) as exc: + failures.append(exc) + continue + matches.append(device) + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + locations = ", ".join( + f"{device.bus}:{device.address}" for device in matches + ) + raise ConfigManagerError( + f"multiple switch-pico devices found ({locations}); " + "select one with --bus and --address" + ) + if time.monotonic() >= deadline: + break + time.sleep(0.05) + if failures: + raise ConfigManagerError( + "matching USB devices were found, but none accepted the " + f"management request; last error: {failures[-1]}" + ) from failures[-1] + raise ConfigManagerError("no USB-connected switch-pico firmware found") + + +def _print_pairings(snapshot: PairingSnapshot) -> None: + if not snapshot.records: + print("No stored pairings.") + return + for index, record in enumerate(snapshot.records, start=1): + print(f"{index}: {record.transport_text} {record.address_text}") + if snapshot.overflow: + print("Warning: additional pairings did not fit in the response.") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="switch-pico-config", + description="Manage switch-pico persistent configuration and pairings.", + ) + parser.add_argument("--bus", type=int, help="USB bus number") + parser.add_argument("--address", type=int, help="USB device address") + parser.add_argument( + "--timeout", + type=float, + default=3.0, + help="operation timeout in seconds (default: 3)", + ) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("status", help="show firmware and configuration status") + + config = commands.add_parser("config", help="read or change configuration") + config_commands = config.add_subparsers(dest="config_command", required=True) + config_commands.add_parser("show", help="show persistent configuration") + config_set = config_commands.add_parser("set", help="write configuration") + config_set.add_argument( + "--pairing-window-seconds", + required=True, + type=int, + metavar="SECONDS", + ) + config_reset = config_commands.add_parser("reset", help="restore defaults") + config_reset.add_argument("--yes", action="store_true") + + pairings = commands.add_parser("pairings", help="list or clear pairings") + pairing_commands = pairings.add_subparsers( + dest="pairing_command", required=True + ) + pairing_commands.add_parser("list", help="list stored pairings") + pairing_clear = pairing_commands.add_parser("clear", help="clear pairings") + pairing_clear.add_argument("--yes", action="store_true") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if args.timeout <= 0: + print("error: --timeout must be positive", file=sys.stderr) + return 2 + if args.command == "config" and args.config_command == "reset" and not args.yes: + print("error: config reset requires --yes", file=sys.stderr) + return 2 + if args.command == "pairings" and args.pairing_command == "clear" and not args.yes: + print("error: pairings clear requires --yes", file=sys.stderr) + return 2 + + try: + device = find_pico(args.bus, args.address, args.timeout) + if args.command == "status": + info = read_info(device) + configuration = read_configuration(device) + version = ".".join(str(part) for part in info.firmware_version) + mode = "XInput" if info.active_mode else "Switch" + print(f"Firmware: {version}") + print(f"Board: Pico 2 W ({info.board})") + print(f"Active USB mode: {mode}") + print(f"Configuration generation: {configuration.generation}") + print(f"Configuration CRC: {configuration.crc:08x}") + print( + "Pairing window: " + f"{configuration.pairing_window_seconds} seconds" + ) + elif args.command == "config": + if args.config_command == "show": + configuration = read_configuration(device) + print( + f"pairing_window_seconds={configuration.pairing_window_seconds}" + ) + print(f"generation={configuration.generation}") + print(f"crc={configuration.crc:08x}") + elif args.config_command == "set": + before = read_configuration(device) + status = write_configuration( + device, + AdapterConfiguration( + pairing_window_seconds=args.pairing_window_seconds, + generation=before.generation, + crc=before.crc, + ), + args.timeout, + ) + print( + "Stored configuration generation " + f"{status.stored_generation} " + f"(CRC {status.stored_crc:08x})." + ) + else: + status = reset_configuration(device, args.timeout) + print( + "Reset configuration at generation " + f"{status.stored_generation}." + ) + elif args.pairing_command == "list": + _print_pairings(refresh_pairings(device, args.timeout)) + else: + before = refresh_pairings(device, args.timeout) + clear_pairings(device, args.timeout) + print(f"Cleared {len(before.records)} stored pairing(s).") + except ConfigManagerError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + except usb.core.USBError as exc: + print(f"error: USB access failed: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/switch_pico_bridge/pairing_manager.py b/src/switch_pico_bridge/pairing_manager.py deleted file mode 100755 index 837fbd5..0000000 --- a/src/switch_pico_bridge/pairing_manager.py +++ /dev/null @@ -1,292 +0,0 @@ -#!/usr/bin/env python3 -"""Manage Pico 2 W Bluetooth pairings over vendor requests on USB EP0.""" - -from __future__ import annotations - -import argparse -import struct -import sys -import time -from dataclasses import dataclass -from collections.abc import Iterable, Sequence -from typing import Any, Protocol - -import usb.core - -USB_VENDOR_ID = 0x057E -USB_PRODUCT_ID = 0x2009 -REQUEST_CLEAR = 0x50 -REQUEST_GET = 0x51 -REQUEST_REFRESH = 0x52 -REQUEST_VALUE = 0x5350 -REQUEST_INDEX = 0x4D47 -PROTOCOL_VERSION = 1 -RESPONSE_HEADER_SIZE = 12 -RECORD_SIZE = 8 -RECORD_CAPACITY = 16 -MAXIMUM_RESPONSE_SIZE = RESPONSE_HEADER_SIZE + RECORD_CAPACITY * RECORD_SIZE -STATUS_READY = 0 -STATUS_PENDING = 1 -TRANSPORT_CLASSIC = 1 -TRANSPORT_BLE = 2 -USB_TIMEOUT_MS = 1000 - - -class PairingManagerError(RuntimeError): - """Expected discovery, USB transport, or protocol failure.""" - - -@dataclass(frozen=True) -class PairingRecord: - transport: int - address_type: int - address: bytes - - @property - def address_text(self) -> str: - return ":".join(f"{octet:02X}" for octet in self.address) - - @property - def transport_text(self) -> str: - if self.transport == TRANSPORT_CLASSIC: - return "Classic" - if self.transport == TRANSPORT_BLE: - address_types = { - 0: "public", - 1: "random", - 2: "public identity", - 3: "random identity", - } - suffix = address_types.get( - self.address_type, f"type {self.address_type}" - ) - return f"BLE ({suffix})" - return f"unknown transport {self.transport}" - - -class UsbDevice(Protocol): - bus: int | None - address: int | None - - def ctrl_transfer( - self, - bm_request_type: int, - request: int, - value: int = 0, - index: int = 0, - data_or_w_length: Any = None, - timeout: int | None = None, - ) -> Any: - ... - - -@dataclass(frozen=True) -class PairingSnapshot: - generation: int - status: int - overflow: bool - records: tuple[PairingRecord, ...] - - -def parse_snapshot(payload: bytes) -> PairingSnapshot: - if len(payload) < RESPONSE_HEADER_SIZE: - raise PairingManagerError("short pairing-management response") - if payload[:4] != b"SPPM": - raise PairingManagerError("device does not implement pairing management") - if payload[4] != PROTOCOL_VERSION: - raise PairingManagerError( - f"unsupported pairing protocol version {payload[4]}" - ) - - status = payload[5] - record_count = payload[6] - required = RESPONSE_HEADER_SIZE + record_count * RECORD_SIZE - if record_count > RECORD_CAPACITY or len(payload) < required: - raise PairingManagerError("invalid pairing record count") - - generation = int(struct.unpack_from(" bytes: - payload = device.ctrl_transfer( - 0xC0, - REQUEST_GET, - REQUEST_VALUE, - REQUEST_INDEX, - MAXIMUM_RESPONSE_SIZE, - timeout=USB_TIMEOUT_MS, - ) - return bytes(payload) - - -def _control_out(device: UsbDevice, request: int) -> None: - device.ctrl_transfer( - 0x40, - request, - REQUEST_VALUE, - REQUEST_INDEX, - None, - timeout=USB_TIMEOUT_MS, - ) - - -def read_snapshot(device: UsbDevice) -> PairingSnapshot: - return parse_snapshot(_control_in(device)) - - -def wait_for_snapshot( - device: UsbDevice, previous_generation: int, timeout: float -) -> PairingSnapshot: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - snapshot = read_snapshot(device) - if ( - snapshot.status == STATUS_READY - and snapshot.generation != previous_generation - ): - return snapshot - time.sleep(0.05) - raise PairingManagerError("Pico did not finish the pairing operation") - - -def refresh_snapshot(device: UsbDevice, timeout: float) -> PairingSnapshot: - initial = read_snapshot(device) - _control_out(device, REQUEST_REFRESH) - return wait_for_snapshot(device, initial.generation, timeout) - - -def clear_pairings(device: UsbDevice, timeout: float) -> PairingSnapshot: - initial = read_snapshot(device) - _control_out(device, REQUEST_CLEAR) - snapshot = wait_for_snapshot(device, initial.generation, timeout) - if snapshot.records: - raise PairingManagerError("Pico reported pairings after clear completed") - return snapshot - - -def _candidate_devices() -> Iterable[UsbDevice]: - devices = usb.core.find( - find_all=True, - idVendor=USB_VENDOR_ID, - idProduct=USB_PRODUCT_ID, - ) - return () if devices is None else devices - - -def find_pico( - bus: int | None, address: int | None, timeout: float = 3.0 -) -> UsbDevice: - deadline = time.monotonic() + timeout - failures: list[Exception] = [] - while True: - matches: list[UsbDevice] = [] - for device in _candidate_devices(): - if bus is not None and getattr(device, "bus", None) != bus: - continue - if address is not None and getattr(device, "address", None) != address: - continue - try: - _ = read_snapshot(device) - except (PairingManagerError, usb.core.USBError) as exc: - failures.append(exc) - continue - matches.append(device) - - if len(matches) == 1: - return matches[0] - if len(matches) > 1: - locations = ", ".join( - f"{device.bus}:{device.address}" for device in matches - ) - raise PairingManagerError( - f"multiple switch-pico devices found ({locations}); " - "select one with --bus and --address" - ) - if time.monotonic() >= deadline: - break - time.sleep(0.05) - - if failures: - raise PairingManagerError( - "matching USB devices were found, but none accepted the " - f"management request; last error: {failures[-1]}" - ) from failures[-1] - raise PairingManagerError("no USB-connected switch-pico AIO firmware found") - - -def _print_snapshot(snapshot: PairingSnapshot) -> None: - if not snapshot.records: - print("No stored pairings.") - return - for index, record in enumerate(snapshot.records, start=1): - print(f"{index}: {record.transport_text} {record.address_text}") - if snapshot.overflow: - print("Warning: additional pairings did not fit in the response.") - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="switch-pico-pairings", - description="List or clear switch-pico AIO Bluetooth pairings.", - ) - parser.add_argument("--bus", type=int, help="USB bus number") - parser.add_argument("--address", type=int, help="USB device address") - parser.add_argument( - "--timeout", type=float, default=3.0, - help="operation timeout in seconds (default: 3)", - ) - subparsers = parser.add_subparsers(dest="command", required=True) - subparsers.add_parser("list", help="list stored Classic and BLE pairings") - clear_parser = subparsers.add_parser("clear", help="clear all pairings") - clear_parser.add_argument( - "--yes", action="store_true", - help="confirm destructive clearing without prompting", - ) - return parser - - -def main(argv: Sequence[str] | None = None) -> int: - args = build_parser().parse_args(argv) - if args.timeout <= 0: - print("error: --timeout must be positive", file=sys.stderr) - return 2 - if args.command == "clear" and not args.yes: - print("error: clear requires --yes", file=sys.stderr) - return 2 - - try: - device = find_pico(args.bus, args.address, args.timeout) - if args.command == "list": - _print_snapshot(refresh_snapshot(device, args.timeout)) - else: - before = refresh_snapshot(device, args.timeout) - clear_pairings(device, args.timeout) - print(f"Cleared {len(before.records)} stored pairing(s).") - except PairingManagerError as exc: - print(f"error: {exc}", file=sys.stderr) - return 1 - except usb.core.USBError as exc: - print(f"error: USB access failed: {exc}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index e539272..4f0d280 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -285,6 +285,15 @@ uint32_t btstack_run_loop_get_time_ms() { #include "../bluepad32_input_backend.cpp" +void configuration_service_prepare() {} +void configuration_service_initialize_on_storage_core() {} +void configuration_service_task_on_storage_core(uint32_t) {} +void configuration_service_snapshot(ConfigurationServiceSnapshot* output) { + *output = {}; + output->state = ConfigurationServiceState::kReady; + output->configuration.pairing_window_seconds = + ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT; +} #ifdef SWITCH_PICO_ADAPTER_FEASIBILITY AdapterUsbMode test_adapter_mode = AdapterUsbMode::kXInput; AdapterUsbMode adapter_host_probe_mode() { diff --git a/tests/configuration_storage_test.cpp b/tests/configuration_storage_test.cpp new file mode 100644 index 0000000..778312a --- /dev/null +++ b/tests/configuration_storage_test.cpp @@ -0,0 +1,232 @@ +#include "adapter_configuration.h" +#include "configuration_storage.h" +#include "configuration_transaction.h" + +#include +#include +#include + +namespace { + +constexpr size_t kSectorSize = 4096; +constexpr size_t kPageSize = 256; + +struct FakeFlash { + uint8_t bytes[CONFIGURATION_STORAGE_COPY_COUNT][kSectorSize]; + int successful_programs = 0; + int fail_after_programs = -1; + + FakeFlash() { memset(bytes, 0xff, sizeof(bytes)); } +}; + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +bool fake_read(void* context, uint8_t copy, size_t offset, + uint8_t* output, size_t size) { + auto* flash = static_cast(context); + if (copy >= CONFIGURATION_STORAGE_COPY_COUNT || + offset + size > kSectorSize) { + return false; + } + memcpy(output, &flash->bytes[copy][offset], size); + return true; +} + +bool fake_erase(void* context, uint8_t copy) { + auto* flash = static_cast(context); + if (copy >= CONFIGURATION_STORAGE_COPY_COUNT) { + return false; + } + memset(flash->bytes[copy], 0xff, kSectorSize); + return true; +} + +bool fake_program(void* context, uint8_t copy, size_t offset, + const uint8_t* data, size_t size) { + auto* flash = static_cast(context); + if (copy >= CONFIGURATION_STORAGE_COPY_COUNT || size != kPageSize || + offset + size > kSectorSize) { + return false; + } + if (flash->fail_after_programs >= 0 && + flash->successful_programs >= flash->fail_after_programs) { + return false; + } + for (size_t index = 0; index < size; ++index) { + flash->bytes[copy][offset + index] &= data[index]; + } + ++flash->successful_programs; + return true; +} + +ConfigurationStorageIo fake_io(FakeFlash* flash) { + return { + flash, + kSectorSize, + kPageSize, + fake_read, + fake_erase, + fake_program, + }; +} + +void test_schema_encoding() { + AdapterConfiguration configuration{}; + configuration.pairing_window_seconds = 90; + uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; + require(adapter_configuration_encode(configuration, payload, + sizeof(payload)), + "valid configuration did not encode"); + AdapterConfiguration decoded{}; + require(adapter_configuration_decode(payload, sizeof(payload), + &decoded) && + decoded.pairing_window_seconds == 90, + "configuration did not round trip"); + payload[2] = 1; + require(!adapter_configuration_decode(payload, sizeof(payload), + &decoded), + "nonzero reserved configuration byte was accepted"); + configuration.pairing_window_seconds = 9; + require(!adapter_configuration_encode(configuration, payload, + sizeof(payload)), + "out-of-range pairing window was accepted"); +} + +void test_two_copy_recovery() { + FakeFlash flash; + ConfigurationStorage store; + require(store.initialize(fake_io(&flash)), + "storage did not initialize"); + require(!store.snapshot().valid, + "erased storage appeared valid"); + + const uint8_t first[] = {1, 2, 3, 4}; + require(store.commit(1, first, sizeof(first)) == + ConfigurationStorageResult::kOk && + store.snapshot().generation == 1, + "first generation did not commit"); + const int programs_after_first = flash.successful_programs; + require(store.commit(1, first, sizeof(first)) == + ConfigurationStorageResult::kUnchanged && + flash.successful_programs == programs_after_first, + "identical configuration consumed a flash write"); + + const uint8_t second[] = {5, 6, 7, 8}; + require(store.commit(1, second, sizeof(second)) == + ConfigurationStorageResult::kOk && + store.snapshot().generation == 2, + "second generation did not commit"); + + ConfigurationStorage after_power_cycle; + require(after_power_cycle.initialize(fake_io(&flash)) && + after_power_cycle.snapshot().generation == 2 && + memcmp(after_power_cycle.snapshot().payload, second, + sizeof(second)) == 0, + "latest generation did not survive reinitialization"); + + flash.bytes[1][CONFIGURATION_STORAGE_RECORD_HEADER_SIZE] ^= 0x01; + ConfigurationStorage after_corruption; + require(after_corruption.initialize(fake_io(&flash)) && + after_corruption.snapshot().generation == 1 && + memcmp(after_corruption.snapshot().payload, first, + sizeof(first)) == 0, + "corrupt newest generation did not roll back"); +} + +void test_interrupted_write_retains_previous_generation() { + FakeFlash flash; + ConfigurationStorage store; + require(store.initialize(fake_io(&flash)), + "storage did not initialize for interruption test"); + const uint8_t first[] = {9, 8, 7, 6}; + require(store.commit(1, first, sizeof(first)) == + ConfigurationStorageResult::kOk, + "baseline generation did not commit"); + + uint8_t large[300]; + memset(large, 0x5a, sizeof(large)); + flash.fail_after_programs = flash.successful_programs + 1; + require(store.commit(1, large, sizeof(large)) == + ConfigurationStorageResult::kIoError, + "interrupted multi-page write reported success"); + + flash.fail_after_programs = -1; + ConfigurationStorage recovered; + require(recovered.initialize(fake_io(&flash)) && + recovered.snapshot().generation == 1 && + recovered.snapshot().payload_size == sizeof(first) && + memcmp(recovered.snapshot().payload, first, + sizeof(first)) == 0, + "interrupted write replaced the previous generation"); +} + +void test_transaction_validation() { + AdapterConfiguration configuration{}; + configuration.pairing_window_seconds = 120; + uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; + require(adapter_configuration_encode(configuration, payload, + sizeof(payload)), + "transaction payload did not encode"); + const uint32_t crc = configuration_crc32(payload, sizeof(payload)); + + ConfigurationTransaction transaction; + require(transaction.begin(7, ADAPTER_CONFIGURATION_SCHEMA_VERSION, + sizeof(payload), crc) == + ConfigurationTransactionStatus::kReceiving, + "valid transaction did not begin"); + require(transaction.append(7, 1, payload, 1) == + ConfigurationTransactionStatus::kOutOfOrder, + "out-of-order chunk was accepted"); + + require(transaction.begin(8, ADAPTER_CONFIGURATION_SCHEMA_VERSION, + sizeof(payload), crc ^ 1u) == + ConfigurationTransactionStatus::kReceiving, + "replacement transaction did not begin"); + require(transaction.append(8, 0, payload, 2) == + ConfigurationTransactionStatus::kReceiving && + transaction.append(8, 2, &payload[2], 2) == + ConfigurationTransactionStatus::kReceiving, + "ordered chunks were rejected"); + require(transaction.finish(8) == + ConfigurationTransactionStatus::kBadCrc, + "bad transaction CRC was accepted"); + + require(transaction.begin(9, ADAPTER_CONFIGURATION_SCHEMA_VERSION, + sizeof(payload), crc) == + ConfigurationTransactionStatus::kReceiving && + transaction.append(9, 0, payload, sizeof(payload)) == + ConfigurationTransactionStatus::kReceiving && + transaction.finish(9) == + ConfigurationTransactionStatus::kPending, + "valid transaction did not reach pending commit"); + require(transaction.begin(10, ADAPTER_CONFIGURATION_SCHEMA_VERSION, + sizeof(payload), crc) == + ConfigurationTransactionStatus::kBusy, + "pending transaction was replaced"); + + transaction.set_result(ConfigurationTransactionStatus::kCommitted, + 4, crc); + require(transaction.begin(11, 99, sizeof(payload), crc) == + ConfigurationTransactionStatus::kUnsupportedSchema, + "unsupported schema was accepted"); + require(transaction.begin( + 12, ADAPTER_CONFIGURATION_SCHEMA_VERSION, + CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE + 1, crc) == + ConfigurationTransactionStatus::kTooLarge, + "oversized transaction was accepted"); +} + +} // namespace + +int main() { + test_schema_encoding(); + test_two_copy_recovery(); + test_interrupted_write_retains_previous_generation(); + test_transaction_validation(); + return 0; +} diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py new file mode 100644 index 0000000..b002165 --- /dev/null +++ b/tests/test_config_manager.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import struct +import zlib + +import pytest + +import switch_pico_bridge.config_manager as config_manager + + +def make_response( + operation: int, + payload: bytes = b"", + *, + status: int = config_manager.STATUS_OK, + flags: int = 0, + schema: int = 0, + generation: int = 0, +) -> bytes: + return struct.pack( + "<4sBBBBHHII", + b"SPMG", + config_manager.PROTOCOL_VERSION, + operation, + status, + flags, + len(payload), + schema, + generation, + zlib.crc32(payload) & 0xFFFFFFFF, + ) + payload + + +class FakeDevice: + bus = 1 + address = 7 + + def __init__(self) -> None: + self.configuration = struct.pack(" bytes: + payload = bytearray([len(self.records), 0, 0, 0]) + for transport, address_type, address in self.records: + payload.extend([transport, address_type]) + payload.extend(address) + return bytes(payload) + + def _transaction_payload(self) -> bytes: + stored_crc = zlib.crc32(self.configuration) & 0xFFFFFFFF + return struct.pack( + " bytes | int: + assert value == config_manager.REQUEST_VALUE + assert index == config_manager.REQUEST_INDEX + assert timeout == config_manager.USB_TIMEOUT_MS + self.requests.append(request) + if bm_request_type == 0xC0: + if request == config_manager.OP_INFO: + return make_response(request, bytes([0, 2, 0, 2, 0, 0, 0, 2])) + if request == config_manager.OP_CONFIGURATION_READ: + return make_response( + request, + self.configuration, + schema=config_manager.CONFIGURATION_SCHEMA_VERSION, + generation=self.configuration_generation, + ) + if request == config_manager.OP_TRANSACTION_STATUS: + return make_response( + request, + self._transaction_payload(), + status=self.transaction_status, + schema=config_manager.CONFIGURATION_SCHEMA_VERSION, + generation=self.configuration_generation, + ) + if request == config_manager.OP_PAIRING_READ: + return make_response( + request, + self._pairing_payload(), + generation=self.pairing_generation, + ) + raise AssertionError(f"unexpected IN request {request}") + + assert bm_request_type == 0x40 + encoded = bytes(data_or_w_length) + assert encoded[:4] == b"SPMG" + payload_size = struct.unpack_from(" None: + payload = make_response(config_manager.OP_INFO, b"12345678") + envelope = config_manager.parse_response(payload, config_manager.OP_INFO) + assert envelope.payload == b"12345678" + + malformed = [ + b"", + b"NOPE" + bytes(config_manager.RESPONSE_HEADER_SIZE - 4), + make_response(config_manager.OP_INFO, b"12345678")[:-1], + make_response(config_manager.OP_INFO, b"12345678") + b"x", + ] + bad_crc = bytearray(make_response(config_manager.OP_INFO, b"12345678")) + bad_crc[-1] ^= 1 + malformed.append(bytes(bad_crc)) + for response in malformed: + with pytest.raises(config_manager.ConfigManagerError): + config_manager.parse_response(response, config_manager.OP_INFO) + + +def test_configuration_transaction_and_reset() -> None: + device = FakeDevice() + before = config_manager.read_configuration(device) + assert before.pairing_window_seconds == 60 + status = config_manager.write_configuration( + device, + config_manager.AdapterConfiguration(90, before.generation, before.crc), + 1.0, + ) + assert status.stored_generation == 4 + assert config_manager.read_configuration(device).pairing_window_seconds == 90 + reset = config_manager.reset_configuration(device, 1.0) + assert reset.stored_generation == 5 + assert config_manager.read_configuration(device).pairing_window_seconds == 60 + + +def test_status_and_pairing_commands( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + device = FakeDevice() + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device]) + assert config_manager.main(["status"]) == 0 + output = capsys.readouterr().out + assert "Firmware: 0.2.0" in output + assert "Pairing window: 60 seconds" in output + + assert config_manager.main(["pairings", "list"]) == 0 + output = capsys.readouterr().out + assert "Classic 01:02:03:04:05:06" in output + assert "BLE (public identity) A1:A2:A3:A4:A5:A6" in output + + assert config_manager.main(["pairings", "clear"]) == 2 + assert "requires --yes" in capsys.readouterr().err + assert config_manager.main(["pairings", "clear", "--yes"]) == 0 + assert capsys.readouterr().out == "Cleared 2 stored pairing(s).\n" + + +def test_find_requires_selector_for_multiple_picos( + monkeypatch: pytest.MonkeyPatch, +) -> None: + first = FakeDevice() + second = FakeDevice() + second.address = 8 + monkeypatch.setattr( + config_manager, "_candidate_devices", lambda: [first, second] + ) + with pytest.raises( + config_manager.ConfigManagerError, + match="multiple switch-pico devices", + ): + config_manager.find_pico(None, None) + assert config_manager.find_pico(1, 8) is second diff --git a/tests/test_configuration_storage_native.py b/tests/test_configuration_storage_native.py new file mode 100644 index 0000000..0f0839a --- /dev/null +++ b/tests/test_configuration_storage_native.py @@ -0,0 +1,31 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_configuration_storage_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + + executable = tmp_path / "configuration_storage_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root}", + str(root / "tests" / "configuration_storage_test.cpp"), + str(root / "adapter_configuration.cpp"), + str(root / "configuration_storage.cpp"), + str(root / "configuration_transaction.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_pairing_manager.py b/tests/test_pairing_manager.py deleted file mode 100644 index a3901f8..0000000 --- a/tests/test_pairing_manager.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -import struct - -import pytest - -import switch_pico_bridge.pairing_manager as pairing_manager - - -def make_payload( - generation: int, - records: list[tuple[int, int, bytes]], - *, - status: int = pairing_manager.STATUS_READY, - overflow: bool = False, -) -> bytes: - payload = bytearray(b"SPPM") - payload.extend( - [ - pairing_manager.PROTOCOL_VERSION, - status, - len(records), - int(overflow), - ] - ) - payload.extend(struct.pack(" None: - self.generation = 3 - self.records = [ - ( - pairing_manager.TRANSPORT_CLASSIC, - 0xFE, - bytes.fromhex("010203040506"), - ), - ( - pairing_manager.TRANSPORT_BLE, - 2, - bytes.fromhex("A1A2A3A4A5A6"), - ), - ] - self.requests: list[int] = [] - - def ctrl_transfer( - self, - bm_request_type: int, - request: int, - value: int, - index: int, - data_or_w_length: object, - timeout: int, - ) -> bytes | int: - assert value == pairing_manager.REQUEST_VALUE - assert index == pairing_manager.REQUEST_INDEX - assert timeout == pairing_manager.USB_TIMEOUT_MS - self.requests.append(request) - if bm_request_type == 0xC0: - assert request == pairing_manager.REQUEST_GET - return make_payload(self.generation, self.records) - assert bm_request_type == 0x40 - if request == pairing_manager.REQUEST_REFRESH: - self.generation += 1 - elif request == pairing_manager.REQUEST_CLEAR: - self.records = [] - self.generation += 1 - else: - raise AssertionError(f"unexpected request {request}") - return 0 - - -def test_parse_snapshot() -> None: - snapshot = pairing_manager.parse_snapshot( - make_payload( - 0x78563412, - [ - ( - pairing_manager.TRANSPORT_CLASSIC, - 0xFE, - bytes.fromhex("010203040506"), - ), - ( - pairing_manager.TRANSPORT_BLE, - 3, - bytes.fromhex("A1A2A3A4A5A6"), - ), - ], - overflow=True, - ) - ) - assert snapshot.generation == 0x78563412 - assert snapshot.overflow - assert snapshot.records[0].transport_text == "Classic" - assert snapshot.records[0].address_text == "01:02:03:04:05:06" - assert snapshot.records[1].transport_text == "BLE (random identity)" - - -@pytest.mark.parametrize( - "payload", - [ - b"", - b"NOPE" + bytes(8), - b"SPPM\x02" + bytes(7), - b"SPPM\x01\x00\x11\x00" + bytes(4), - ], -) -def test_parse_rejects_invalid_payload(payload: bytes) -> None: - with pytest.raises(pairing_manager.PairingManagerError): - pairing_manager.parse_snapshot(payload) - - -def test_list_and_clear_commands( - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - device = FakeDevice() - monkeypatch.setattr(pairing_manager, "_candidate_devices", lambda: [device]) - - assert pairing_manager.main(["list"]) == 0 - output = capsys.readouterr().out - assert "Classic 01:02:03:04:05:06" in output - assert "BLE (public identity) A1:A2:A3:A4:A5:A6" in output - - assert pairing_manager.main(["clear"]) == 2 - assert "requires --yes" in capsys.readouterr().err - - assert pairing_manager.main(["clear", "--yes"]) == 0 - assert capsys.readouterr().out == "Cleared 2 stored pairing(s).\n" - assert device.records == [] - assert pairing_manager.REQUEST_REFRESH in device.requests - assert pairing_manager.REQUEST_CLEAR in device.requests - - -def test_find_requires_selector_for_multiple_picos( - monkeypatch: pytest.MonkeyPatch, -) -> None: - first = FakeDevice() - second = FakeDevice() - second.address = 8 - monkeypatch.setattr( - pairing_manager, "_candidate_devices", lambda: [first, second] - ) - with pytest.raises( - pairing_manager.PairingManagerError, - match="multiple switch-pico devices", - ): - pairing_manager.find_pico(None, None) - assert pairing_manager.find_pico(1, 8) is second diff --git a/tests/test_usb_pairing_management_native.py b/tests/test_usb_configuration_management_native.py similarity index 75% rename from tests/test_usb_pairing_management_native.py rename to tests/test_usb_configuration_management_native.py index d52aa08..7eb2d6b 100644 --- a/tests/test_usb_pairing_management_native.py +++ b/tests/test_usb_configuration_management_native.py @@ -4,12 +4,12 @@ from pathlib import Path -def test_usb_pairing_management_native(tmp_path: Path) -> None: +def test_usb_configuration_management_native(tmp_path: Path) -> None: root = Path(__file__).resolve().parents[1] compiler = shutil.which("c++") or shutil.which("g++") assert compiler is not None, "a host C++ compiler is required" - executable = tmp_path / "usb_pairing_management_test" + executable = tmp_path / "usb_configuration_management_test" subprocess.run( [ compiler, @@ -20,7 +20,7 @@ def test_usb_pairing_management_native(tmp_path: Path) -> None: "-pedantic", f"-I{root / 'tests' / 'usb_management_native_stubs'}", f"-I{root}", - str(root / "tests" / "usb_pairing_management_test.cpp"), + str(root / "tests" / "usb_configuration_management_test.cpp"), "-o", str(executable), ], diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp new file mode 100644 index 0000000..1ddf306 --- /dev/null +++ b/tests/usb_configuration_management_test.cpp @@ -0,0 +1,301 @@ +#include "usb_configuration_management.h" + +#include +#include +#include +#include + +#include + +namespace { + +Bluepad32PairingSnapshot current_pairings{}; +ConfigurationServiceSnapshot current_configuration{}; +bool refresh_requested = false; +bool clear_requested = false; +std::vector control_payload; +std::vector next_out_payload; +uint32_t begin_transaction_id = 0; +uint32_t append_transaction_id = 0; +uint32_t commit_transaction_id = 0; +size_t append_offset = 0; +std::vector appended_bytes; + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +void write_u16(std::vector* output, size_t offset, + uint16_t value) { + (*output)[offset] = static_cast(value); + (*output)[offset + 1] = static_cast(value >> 8); +} + +void write_u32(std::vector* output, size_t offset, + uint32_t value) { + (*output)[offset] = static_cast(value); + (*output)[offset + 1] = static_cast(value >> 8); + (*output)[offset + 2] = static_cast(value >> 16); + (*output)[offset + 3] = static_cast(value >> 24); +} + +std::vector make_request( + UsbConfigurationManagement::Operation operation, + const std::vector& payload) { + using namespace UsbConfigurationManagement; + std::vector request(kRequestHeaderSize + payload.size()); + memcpy(request.data(), "SPMG", 4); + request[4] = kProtocolVersion; + request[5] = static_cast(operation); + write_u16(&request, 8, static_cast(payload.size())); + write_u32(&request, 12, + configuration_crc32(payload.data(), payload.size())); + memcpy(request.data() + kRequestHeaderSize, payload.data(), + payload.size()); + return request; +} + +tusb_control_request_t setup_request( + UsbConfigurationManagement::Operation operation, uint8_t direction, + uint16_t length) { + tusb_control_request_t request{}; + request.bmRequestType_bit.recipient = TUSB_REQ_RCPT_DEVICE; + request.bmRequestType_bit.type = TUSB_REQ_TYPE_VENDOR; + request.bmRequestType_bit.direction = direction; + request.bRequest = static_cast(operation); + request.wValue = UsbConfigurationManagement::kRequestValue; + request.wIndex = UsbConfigurationManagement::kRequestIndex; + request.wLength = length; + return request; +} + +void test_envelope_encoding() { + using namespace UsbConfigurationManagement; + const uint8_t payload[] = {1, 2, 3}; + uint8_t encoded[32]{}; + const size_t size = encode_response( + Operation::kConfigurationRead, Status::kOk, 5, 1, + 0x78563412, payload, sizeof(payload), encoded, sizeof(encoded)); + require(size == kResponseHeaderSize + sizeof(payload) && + memcmp(encoded, "SPMG", 4) == 0 && + encoded[4] == kProtocolVersion && + encoded[5] == + static_cast(Operation::kConfigurationRead) && + encoded[6] == static_cast(Status::kOk) && + encoded[7] == 5 && encoded[8] == 3 && + encoded[10] == 1 && encoded[12] == 0x12 && + encoded[15] == 0x78 && + memcmp(&encoded[kResponseHeaderSize], payload, + sizeof(payload)) == 0, + "versioned response envelope encoded incorrectly"); + require(encode_response( + Operation::kConfigurationRead, Status::kOk, 0, 1, 0, + payload, sizeof(payload), encoded, size - 1) == 0, + "response encoder accepted a short destination"); +} + +void test_pairing_encoding() { + using namespace UsbConfigurationManagement; + Bluepad32PairingSnapshot snapshot{}; + snapshot.generation = 0x78563412; + snapshot.status = Bluepad32PairingSnapshotStatus::kReady; + snapshot.record_count = 2; + snapshot.overflow = true; + snapshot.records[0].transport = + Bluepad32PairingTransport::kClassic; + snapshot.records[0].address_type = 0xfe; + const uint8_t classic_address[6] = {1, 2, 3, 4, 5, 6}; + memcpy(snapshot.records[0].address, classic_address, 6); + snapshot.records[1].transport = Bluepad32PairingTransport::kBle; + snapshot.records[1].address_type = 2; + const uint8_t ble_address[6] = {6, 5, 4, 3, 2, 1}; + memcpy(snapshot.records[1].address, ble_address, 6); + + uint8_t payload[kMaximumResponseSize]{}; + const size_t size = + encode_pairing_snapshot(snapshot, payload, sizeof(payload)); + require(size == kResponseHeaderSize + kPairingPayloadHeaderSize + + 2 * kPairingRecordSize && + payload[5] == + static_cast(Operation::kPairingRead) && + payload[7] == 1 && payload[12] == 0x12 && + payload[kResponseHeaderSize] == 2 && + payload[kResponseHeaderSize + 1] == 1 && + payload[kResponseHeaderSize + 4] == 1 && + payload[kResponseHeaderSize + 5] == 0xfe && + memcmp(&payload[kResponseHeaderSize + 6], + classic_address, 6) == 0, + "pairings were not migrated into the versioned envelope"); +} + +void perform_out(UsbConfigurationManagement::Operation operation, + const std::vector& payload, + bool expected_ack = true) { + next_out_payload = make_request(operation, payload); + tusb_control_request_t request = setup_request( + operation, TUSB_DIR_OUT, + static_cast(next_out_payload.size())); + require(tud_vendor_control_xfer_cb( + 0, CONTROL_STAGE_SETUP, &request), + "valid OUT setup was rejected"); + require(tud_vendor_control_xfer_cb( + 0, CONTROL_STAGE_ACK, &request) == expected_ack, + "OUT acknowledgement result was incorrect"); +} + +void test_vendor_requests() { + using namespace UsbConfigurationManagement; + current_pairings = {}; + current_pairings.generation = 7; + current_pairings.status = Bluepad32PairingSnapshotStatus::kReady; + current_pairings.record_count = 1; + current_pairings.records[0].transport = + Bluepad32PairingTransport::kClassic; + + tusb_control_request_t request = setup_request( + Operation::kPairingRead, TUSB_DIR_IN, kMaximumResponseSize); + require(tud_vendor_control_xfer_cb( + 0, CONTROL_STAGE_SETUP, &request) && + control_payload[5] == + static_cast(Operation::kPairingRead) && + control_payload[12] == 7, + "pairing read did not use the versioned envelope"); + + perform_out(Operation::kPairingRefresh, {}); + require(refresh_requested, + "pairing refresh was not dispatched"); + perform_out(Operation::kPairingClear, {}); + require(clear_requested, "pairing clear was not dispatched"); + + std::vector begin(12); + write_u32(&begin, 0, 0x11223344); + write_u16(&begin, 4, ADAPTER_CONFIGURATION_SCHEMA_VERSION); + write_u16(&begin, 6, ADAPTER_CONFIGURATION_ENCODED_SIZE); + write_u32(&begin, 8, 0xaabbccdd); + perform_out(Operation::kConfigurationBegin, begin); + require(begin_transaction_id == 0x11223344, + "configuration begin was not dispatched"); + + std::vector chunk(12); + write_u32(&chunk, 0, 0x11223344); + write_u16(&chunk, 4, 0); + write_u16(&chunk, 6, 4); + chunk[8] = 60; + perform_out(Operation::kConfigurationChunk, chunk); + require(append_transaction_id == 0x11223344 && + append_offset == 0 && appended_bytes.size() == 4, + "configuration chunk was not dispatched"); + + std::vector commit(4); + write_u32(&commit, 0, 0x11223344); + perform_out(Operation::kConfigurationCommit, commit); + require(commit_transaction_id == 0x11223344, + "configuration commit was not dispatched"); + + next_out_payload = + make_request(Operation::kPairingRefresh, {}); + next_out_payload[12] ^= 1; + request = setup_request( + Operation::kPairingRefresh, TUSB_DIR_OUT, + static_cast(next_out_payload.size())); + require(tud_vendor_control_xfer_cb( + 0, CONTROL_STAGE_SETUP, &request) && + !tud_vendor_control_xfer_cb( + 0, CONTROL_STAGE_ACK, &request), + "bad request CRC was accepted"); + + request.wValue = 0; + require(!tud_vendor_control_xfer_cb( + 0, CONTROL_STAGE_SETUP, &request), + "request with invalid magic was accepted"); +} + +} // namespace + +uint32_t configuration_crc32(const uint8_t* data, size_t size) { + uint32_t crc = 0xffffffffu; + for (size_t index = 0; index < size; ++index) { + crc ^= data[index]; + for (uint8_t bit = 0; bit < 8; ++bit) { + const uint32_t mask = 0u - (crc & 1u); + crc = (crc >> 1) ^ (0xedb88320u & mask); + } + } + return ~crc; +} + +void configuration_service_snapshot(ConfigurationServiceSnapshot* output) { + *output = current_configuration; +} + +ConfigurationTransactionStatus configuration_service_begin( + uint32_t transaction_id, uint16_t, size_t, uint32_t) { + begin_transaction_id = transaction_id; + return ConfigurationTransactionStatus::kReceiving; +} + +ConfigurationTransactionStatus configuration_service_append( + uint32_t transaction_id, size_t offset, const uint8_t* data, + size_t size) { + append_transaction_id = transaction_id; + append_offset = offset; + appended_bytes.assign(data, data + size); + return ConfigurationTransactionStatus::kReceiving; +} + +ConfigurationTransactionStatus configuration_service_commit( + uint32_t transaction_id) { + commit_transaction_id = transaction_id; + return ConfigurationTransactionStatus::kPending; +} + +ConfigurationTransactionStatus configuration_service_reset(uint32_t) { + return ConfigurationTransactionStatus::kPending; +} + +void bluepad32_input_backend_request_pairing_snapshot() { + refresh_requested = true; +} + +void bluepad32_input_backend_clear_pairings() { + clear_requested = true; +} + +void bluepad32_input_backend_pairing_snapshot( + Bluepad32PairingSnapshot* out) { + *out = current_pairings; +} + +bool tud_control_xfer(uint8_t, const tusb_control_request_t* request, + void* buffer, uint16_t length) { + if (request->bmRequestType_bit.direction == TUSB_DIR_OUT) { + if (next_out_payload.size() != length) { + return false; + } + memcpy(buffer, next_out_payload.data(), length); + } else { + const auto* bytes = static_cast(buffer); + control_payload.assign(bytes, bytes + length); + } + return true; +} + +bool tud_control_status(uint8_t, const tusb_control_request_t*) { + return true; +} + +#include "../adapter_configuration.cpp" +#include "../usb_configuration_management.cpp" + +int main() { + current_configuration.state = ConfigurationServiceState::kReady; + current_configuration.configuration = + adapter_configuration_default(); + test_envelope_encoding(); + test_pairing_encoding(); + test_vendor_requests(); + return 0; +} diff --git a/tests/usb_management_native_stubs/tusb.h b/tests/usb_management_native_stubs/tusb.h index 2196f72..2ec477e 100644 --- a/tests/usb_management_native_stubs/tusb.h +++ b/tests/usb_management_native_stubs/tusb.h @@ -9,6 +9,7 @@ enum { CONTROL_STAGE_ACK = 2, TUSB_REQ_RCPT_DEVICE = 0, TUSB_DIR_OUT = 0, + TUSB_REQ_TYPE_VENDOR = 2, TUSB_DIR_IN = 1, }; diff --git a/tests/usb_pairing_management_test.cpp b/tests/usb_pairing_management_test.cpp deleted file mode 100644 index 9f79ea2..0000000 --- a/tests/usb_pairing_management_test.cpp +++ /dev/null @@ -1,144 +0,0 @@ -#include "usb_pairing_management.h" - -#include -#include -#include -#include - -#include - -namespace { - -Bluepad32PairingSnapshot current_snapshot{}; -bool refresh_requested = false; -bool clear_requested = false; -bool control_status_sent = false; -std::vector control_payload; - -void require(bool condition, const char* message) { - if (!condition) { - std::cerr << message << '\n'; - std::exit(1); - } -} - -void test_encoding() { - Bluepad32PairingSnapshot snapshot{}; - snapshot.generation = 0x78563412; - snapshot.status = Bluepad32PairingSnapshotStatus::kReady; - snapshot.record_count = 2; - snapshot.overflow = true; - snapshot.records[0].transport = - Bluepad32PairingTransport::kClassic; - snapshot.records[0].address_type = 0xfe; - const uint8_t classic_address[6] = {1, 2, 3, 4, 5, 6}; - memcpy(snapshot.records[0].address, classic_address, 6); - snapshot.records[1].transport = Bluepad32PairingTransport::kBle; - snapshot.records[1].address_type = 2; - const uint8_t ble_address[6] = {6, 5, 4, 3, 2, 1}; - memcpy(snapshot.records[1].address, ble_address, 6); - - uint8_t payload[UsbPairingManagement::kMaximumResponseSize]{}; - const size_t size = UsbPairingManagement::encode_snapshot( - snapshot, payload, sizeof(payload)); - require(size == UsbPairingManagement::kResponseHeaderSize + - 2 * UsbPairingManagement::kRecordSize, - "snapshot encoded with the wrong size"); - require(memcmp(payload, "SPPM", 4) == 0 && - payload[4] == UsbPairingManagement::kProtocolVersion && - payload[5] == 0 && payload[6] == 2 && payload[7] == 1, - "snapshot header encoding is invalid"); - require(payload[8] == 0x12 && payload[9] == 0x34 && - payload[10] == 0x56 && payload[11] == 0x78, - "snapshot generation is not little endian"); - require(payload[12] == 1 && payload[13] == 0xfe && - memcmp(&payload[14], classic_address, 6) == 0 && - payload[20] == 2 && payload[21] == 2 && - memcmp(&payload[22], ble_address, 6) == 0, - "pairing records are encoded incorrectly"); - require(UsbPairingManagement::encode_snapshot( - snapshot, payload, size - 1) == 0, - "encoder accepted a short destination buffer"); -} - -void test_vendor_requests() { - current_snapshot = {}; - current_snapshot.generation = 7; - current_snapshot.status = Bluepad32PairingSnapshotStatus::kReady; - current_snapshot.record_count = 1; - current_snapshot.records[0].transport = - Bluepad32PairingTransport::kClassic; - - tusb_control_request_t request{}; - request.bmRequestType_bit.recipient = TUSB_REQ_RCPT_DEVICE; - request.bmRequestType_bit.direction = TUSB_DIR_IN; - request.bRequest = UsbPairingManagement::kRequestGet; - request.wValue = UsbPairingManagement::kRequestValue; - request.wIndex = UsbPairingManagement::kRequestIndex; - request.wLength = UsbPairingManagement::kMaximumResponseSize; - require(tud_vendor_control_xfer_cb( - 0, CONTROL_STAGE_SETUP, &request) && - control_payload.size() == - UsbPairingManagement::kResponseHeaderSize + - UsbPairingManagement::kRecordSize && - control_payload[8] == 7, - "GET request did not return the current pairing snapshot"); - - request.bmRequestType_bit.direction = TUSB_DIR_OUT; - request.wLength = 0; - request.bRequest = UsbPairingManagement::kRequestRefresh; - require(tud_vendor_control_xfer_cb( - 0, CONTROL_STAGE_SETUP, &request) && - refresh_requested && control_status_sent, - "REFRESH request was not acknowledged and queued"); - - control_status_sent = false; - request.bRequest = UsbPairingManagement::kRequestClear; - require(tud_vendor_control_xfer_cb( - 0, CONTROL_STAGE_SETUP, &request) && - clear_requested && control_status_sent, - "CLEAR request was not acknowledged and queued"); - - request.wValue = 0; - require(!tud_vendor_control_xfer_cb( - 0, CONTROL_STAGE_SETUP, &request), - "request with invalid magic was accepted"); - require(tud_vendor_control_xfer_cb( - 0, CONTROL_STAGE_ACK, &request), - "non-setup control stage was rejected"); -} - -} // namespace - -void bluepad32_input_backend_request_pairing_snapshot() { - refresh_requested = true; -} - -void bluepad32_input_backend_clear_pairings() { - clear_requested = true; -} - -void bluepad32_input_backend_pairing_snapshot( - Bluepad32PairingSnapshot* out) { - *out = current_snapshot; -} - -bool tud_control_xfer(uint8_t, const tusb_control_request_t*, - void* buffer, uint16_t length) { - const auto* bytes = static_cast(buffer); - control_payload.assign(bytes, bytes + length); - return true; -} - -bool tud_control_status(uint8_t, const tusb_control_request_t*) { - control_status_sent = true; - return true; -} - -#include "../usb_pairing_management.cpp" - -int main() { - test_encoding(); - test_vendor_requests(); - return 0; -} diff --git a/usb_configuration_management.cpp b/usb_configuration_management.cpp new file mode 100644 index 0000000..697cdb5 --- /dev/null +++ b/usb_configuration_management.cpp @@ -0,0 +1,377 @@ +#include "usb_configuration_management.h" + +#include + +#include "adapter_configuration.h" +#include "tusb.h" +#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#include "adapter_host_probe.h" +#endif + +namespace UsbConfigurationManagement { +namespace { + +uint16_t read_u16(const uint8_t* input) { + return static_cast(input[0]) | + static_cast(input[1] << 8); +} + +uint32_t read_u32(const uint8_t* input) { + return static_cast(input[0]) | + (static_cast(input[1]) << 8) | + (static_cast(input[2]) << 16) | + (static_cast(input[3]) << 24); +} + +void write_u16(uint8_t* output, uint16_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); +} + +void write_u32(uint8_t* output, uint32_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); + output[2] = static_cast(value >> 16); + output[3] = static_cast(value >> 24); +} + +Status transaction_status(ConfigurationTransactionStatus status) { + switch (status) { + case ConfigurationTransactionStatus::kIdle: + case ConfigurationTransactionStatus::kCommitted: + case ConfigurationTransactionStatus::kUnchanged: + return Status::kOk; + case ConfigurationTransactionStatus::kReceiving: + case ConfigurationTransactionStatus::kPending: + return Status::kPending; + case ConfigurationTransactionStatus::kMalformed: + return Status::kMalformed; + case ConfigurationTransactionStatus::kUnsupportedSchema: + return Status::kUnsupportedSchema; + case ConfigurationTransactionStatus::kTooLarge: + return Status::kTooLarge; + case ConfigurationTransactionStatus::kOutOfOrder: + return Status::kOutOfOrder; + case ConfigurationTransactionStatus::kBadCrc: + return Status::kBadCrc; + case ConfigurationTransactionStatus::kBusy: + return Status::kBusy; + case ConfigurationTransactionStatus::kStorageError: + return Status::kStorageError; + } + return Status::kStorageError; +} + +bool valid_out_size(Operation operation, size_t size) { + switch (operation) { + case Operation::kConfigurationBegin: + return size == kRequestHeaderSize + 12; + case Operation::kConfigurationChunk: + return size > kRequestHeaderSize + 8 && + size <= kMaximumRequestSize; + case Operation::kConfigurationCommit: + case Operation::kConfigurationReset: + return size == kRequestHeaderSize + 4; + case Operation::kPairingRefresh: + case Operation::kPairingClear: + return size == kRequestHeaderSize; + default: + return false; + } +} + +size_t encode_configuration(uint8_t* output, size_t output_size) { + ConfigurationServiceSnapshot snapshot{}; + configuration_service_snapshot(&snapshot); + uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; + const bool encoded = adapter_configuration_encode( + snapshot.configuration, payload, sizeof(payload)); + Status status = Status::kOk; + if (snapshot.state == ConfigurationServiceState::kLoading) { + status = Status::kPending; + } else if (snapshot.state == + ConfigurationServiceState::kStorageError) { + status = Status::kStorageError; + } + return encoded + ? encode_response( + Operation::kConfigurationRead, status, 0, + ADAPTER_CONFIGURATION_SCHEMA_VERSION, + snapshot.generation, payload, sizeof(payload), + output, output_size) + : 0; +} + +size_t encode_transaction(uint8_t* output, size_t output_size) { + ConfigurationServiceSnapshot snapshot{}; + configuration_service_snapshot(&snapshot); + const ConfigurationTransactionSnapshot& transaction = + snapshot.transaction; + uint8_t payload[20]{}; + write_u32(&payload[0], transaction.transaction_id); + write_u16(&payload[4], transaction.received_size); + write_u16(&payload[6], transaction.expected_size); + write_u32(&payload[8], transaction.expected_crc); + write_u32(&payload[12], transaction.stored_generation); + write_u32(&payload[16], transaction.stored_crc); + return encode_response( + Operation::kTransactionStatus, + transaction_status(transaction.status), 0, + ADAPTER_CONFIGURATION_SCHEMA_VERSION, + transaction.stored_generation, payload, sizeof(payload), + output, output_size); +} + +size_t encode_info(uint8_t* output, size_t output_size) { + uint8_t payload[8] = { + 0, 2, 0, 2, +#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY + adapter_host_probe_mode() == AdapterUsbMode::kXInput ? 1u : 0u, +#else + 0, +#endif + 0, + static_cast(CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE), + static_cast( + CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE >> 8), + }; + return encode_response(Operation::kInfo, Status::kOk, 0, 0, 0, + payload, sizeof(payload), output, output_size); +} + +} // namespace + +bool decode_request(Operation setup_operation, const uint8_t* input, + size_t input_size, DecodedRequest* output) { + if (input == nullptr || output == nullptr || + input_size < kRequestHeaderSize || + memcmp(input, "SPMG", 4) != 0 || + input[4] != kProtocolVersion || + input[5] != static_cast(setup_operation) || + input[6] != 0 || input[7] != 0 || + read_u16(&input[10]) != 0) { + return false; + } + const uint16_t payload_size = read_u16(&input[8]); + if (input_size != kRequestHeaderSize + payload_size || + configuration_crc32(&input[kRequestHeaderSize], payload_size) != + read_u32(&input[12])) { + return false; + } + output->operation = setup_operation; + output->payload = &input[kRequestHeaderSize]; + output->payload_size = payload_size; + return true; +} + +size_t encode_response(Operation operation, Status status, uint8_t flags, + uint16_t schema_version, uint32_t generation, + const uint8_t* payload, size_t payload_size, + uint8_t* output, size_t output_size) { + const size_t required = kResponseHeaderSize + payload_size; + if (output == nullptr || output_size < required || + payload_size > UINT16_MAX || + (payload_size != 0 && payload == nullptr)) { + return 0; + } + memcpy(output, "SPMG", 4); + output[4] = kProtocolVersion; + output[5] = static_cast(operation); + output[6] = static_cast(status); + output[7] = flags; + write_u16(&output[8], static_cast(payload_size)); + write_u16(&output[10], schema_version); + write_u32(&output[12], generation); + write_u32(&output[16], + configuration_crc32(payload, payload_size)); + if (payload_size != 0) { + memcpy(&output[kResponseHeaderSize], payload, payload_size); + } + return required; +} + +size_t encode_pairing_snapshot(const Bluepad32PairingSnapshot& snapshot, + uint8_t* output, size_t output_size) { + if (snapshot.record_count > BLUEPAD32_PAIRING_RECORD_CAPACITY) { + return 0; + } + uint8_t payload[kPairingPayloadHeaderSize + + BLUEPAD32_PAIRING_RECORD_CAPACITY * + kPairingRecordSize]{}; + payload[0] = snapshot.record_count; + payload[1] = snapshot.overflow ? 1 : 0; + size_t offset = kPairingPayloadHeaderSize; + for (uint8_t index = 0; index < snapshot.record_count; ++index) { + const Bluepad32PairingRecord& record = snapshot.records[index]; + payload[offset] = static_cast(record.transport); + payload[offset + 1] = record.address_type; + memcpy(&payload[offset + 2], record.address, + sizeof(record.address)); + offset += kPairingRecordSize; + } + return encode_response( + Operation::kPairingRead, + snapshot.status == Bluepad32PairingSnapshotStatus::kReady + ? Status::kOk + : Status::kPending, + snapshot.overflow ? 1 : 0, 0, snapshot.generation, + payload, offset, output, output_size); +} + +} // namespace UsbConfigurationManagement + +namespace { + +uint8_t g_request_buffer[ + UsbConfigurationManagement::kMaximumRequestSize]{}; +UsbConfigurationManagement::Operation g_pending_operation = + UsbConfigurationManagement::Operation::kInfo; +bool g_out_pending = false; +size_t g_pending_request_size = 0; + +bool process_out_request() { + using namespace UsbConfigurationManagement; + DecodedRequest request{}; + if (!decode_request(g_pending_operation, g_request_buffer, + g_pending_request_size, &request)) { + return false; + } + + const uint8_t* payload = request.payload; + switch (request.operation) { + case Operation::kConfigurationBegin: + configuration_service_begin( + static_cast(payload[0]) | + (static_cast(payload[1]) << 8) | + (static_cast(payload[2]) << 16) | + (static_cast(payload[3]) << 24), + static_cast(payload[4] | + (payload[5] << 8)), + static_cast(payload[6] | + (payload[7] << 8)), + static_cast(payload[8]) | + (static_cast(payload[9]) << 8) | + (static_cast(payload[10]) << 16) | + (static_cast(payload[11]) << 24)); + return true; + case Operation::kConfigurationChunk: { + const uint16_t chunk_size = + static_cast(payload[6] | + (payload[7] << 8)); + if (request.payload_size != 8 + chunk_size || + chunk_size > kMaximumChunkSize) { + return false; + } + configuration_service_append( + static_cast(payload[0]) | + (static_cast(payload[1]) << 8) | + (static_cast(payload[2]) << 16) | + (static_cast(payload[3]) << 24), + static_cast(payload[4] | + (payload[5] << 8)), + &payload[8], chunk_size); + return true; + } + case Operation::kConfigurationCommit: + configuration_service_commit( + static_cast(payload[0]) | + (static_cast(payload[1]) << 8) | + (static_cast(payload[2]) << 16) | + (static_cast(payload[3]) << 24)); + return true; + case Operation::kConfigurationReset: + configuration_service_reset( + static_cast(payload[0]) | + (static_cast(payload[1]) << 8) | + (static_cast(payload[2]) << 16) | + (static_cast(payload[3]) << 24)); + return true; + case Operation::kPairingRefresh: + bluepad32_input_backend_request_pairing_snapshot(); + return true; + case Operation::kPairingClear: + bluepad32_input_backend_clear_pairings(); + return true; + default: + return false; + } +} + +} // namespace + +extern "C" bool tud_vendor_control_xfer_cb( + uint8_t rhport, uint8_t stage, + tusb_control_request_t const* request) { +#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY + 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 || + request->bmRequestType_bit.recipient != TUSB_REQ_RCPT_DEVICE || + request->wValue != kRequestValue || + request->wIndex != kRequestIndex) { + return false; + } + + const Operation operation = + static_cast(request->bRequest); + if (stage == CONTROL_STAGE_ACK) { + if (request->bmRequestType_bit.direction == TUSB_DIR_IN) { + return true; + } + if (!g_out_pending || operation != g_pending_operation) { + return false; + } + g_out_pending = false; + return process_out_request(); + } + if (stage == CONTROL_STAGE_DATA) { + return true; + } + if (stage != CONTROL_STAGE_SETUP) { + return false; + } + + if (request->bmRequestType_bit.direction == TUSB_DIR_OUT) { + if (!valid_out_size(operation, request->wLength)) { + return false; + } + g_pending_operation = operation; + g_pending_request_size = request->wLength; + g_out_pending = true; + return tud_control_xfer(rhport, request, g_request_buffer, + request->wLength); + } + + static uint8_t response[kMaximumResponseSize]{}; + size_t response_size = 0; + switch (operation) { + case Operation::kInfo: + response_size = encode_info(response, sizeof(response)); + break; + case Operation::kConfigurationRead: + response_size = + encode_configuration(response, sizeof(response)); + break; + case Operation::kTransactionStatus: + response_size = + encode_transaction(response, sizeof(response)); + break; + case Operation::kPairingRead: { + Bluepad32PairingSnapshot snapshot{}; + bluepad32_input_backend_pairing_snapshot(&snapshot); + response_size = encode_pairing_snapshot( + snapshot, response, sizeof(response)); + break; + } + default: + return false; + } + return response_size != 0 && + tud_control_xfer( + rhport, request, response, + static_cast(response_size)); +} diff --git a/usb_configuration_management.h b/usb_configuration_management.h new file mode 100644 index 0000000..738a132 --- /dev/null +++ b/usb_configuration_management.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include + +#include "bluepad32_input_backend.h" +#include "configuration_service.h" + +namespace UsbConfigurationManagement { + +constexpr uint16_t kRequestValue = 0x5350; +constexpr uint16_t kRequestIndex = 0x0001; +constexpr uint8_t kProtocolVersion = 1; +constexpr size_t kRequestHeaderSize = 16; +constexpr size_t kResponseHeaderSize = 20; +constexpr size_t kPairingRecordSize = 8; +constexpr size_t kPairingPayloadHeaderSize = 4; +constexpr size_t kMaximumRequestSize = 64; +constexpr size_t kMaximumResponseSize = + kResponseHeaderSize + kPairingPayloadHeaderSize + + BLUEPAD32_PAIRING_RECORD_CAPACITY * kPairingRecordSize; +constexpr size_t kMaximumChunkSize = + kMaximumRequestSize - kRequestHeaderSize - 8; + +enum class Operation : uint8_t { + kInfo = 0x01, + kConfigurationRead = 0x10, + kConfigurationBegin = 0x11, + kConfigurationChunk = 0x12, + kConfigurationCommit = 0x13, + kConfigurationReset = 0x14, + kTransactionStatus = 0x15, + kPairingRead = 0x20, + kPairingRefresh = 0x21, + kPairingClear = 0x22, +}; + +enum class Status : uint8_t { + kOk = 0, + kPending = 1, + kMalformed = 2, + kUnsupportedSchema = 3, + kTooLarge = 4, + kOutOfOrder = 5, + kBadCrc = 6, + kBusy = 7, + kStorageError = 8, +}; + +struct DecodedRequest { + Operation operation = Operation::kInfo; + const uint8_t* payload = nullptr; + uint16_t payload_size = 0; +}; + +bool decode_request(Operation setup_operation, const uint8_t* input, + size_t input_size, DecodedRequest* output); +size_t encode_response(Operation operation, Status status, uint8_t flags, + uint16_t schema_version, uint32_t generation, + const uint8_t* payload, size_t payload_size, + uint8_t* output, size_t output_size); +size_t encode_pairing_snapshot(const Bluepad32PairingSnapshot& snapshot, + uint8_t* output, size_t output_size); + +} // namespace UsbConfigurationManagement diff --git a/usb_pairing_management.cpp b/usb_pairing_management.cpp deleted file mode 100644 index dcb48e4..0000000 --- a/usb_pairing_management.cpp +++ /dev/null @@ -1,100 +0,0 @@ -#include "usb_pairing_management.h" - -#include - -#include "tusb.h" -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY -#include "adapter_host_probe.h" -#endif - -namespace UsbPairingManagement { - -size_t encode_snapshot(const Bluepad32PairingSnapshot& snapshot, - uint8_t* output, size_t output_size) { - const size_t required = - kResponseHeaderSize + snapshot.record_count * kRecordSize; - if (output == nullptr || output_size < required || - snapshot.record_count > BLUEPAD32_PAIRING_RECORD_CAPACITY) { - return 0; - } - - output[0] = 'S'; - output[1] = 'P'; - output[2] = 'P'; - output[3] = 'M'; - output[4] = kProtocolVersion; - output[5] = static_cast(snapshot.status); - output[6] = snapshot.record_count; - output[7] = snapshot.overflow ? 1 : 0; - output[8] = static_cast(snapshot.generation); - output[9] = static_cast(snapshot.generation >> 8); - output[10] = static_cast(snapshot.generation >> 16); - output[11] = static_cast(snapshot.generation >> 24); - - size_t offset = kResponseHeaderSize; - for (uint8_t index = 0; index < snapshot.record_count; ++index) { - const Bluepad32PairingRecord& record = snapshot.records[index]; - output[offset] = static_cast(record.transport); - output[offset + 1] = record.address_type; - memcpy(&output[offset + 2], record.address, - sizeof(record.address)); - offset += kRecordSize; - } - return required; -} - -} // namespace UsbPairingManagement - -extern "C" bool tud_vendor_control_xfer_cb( - uint8_t rhport, uint8_t stage, - tusb_control_request_t const* request) { -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY - if (adapter_host_probe_vendor_control(rhport, stage, request)) { - return true; - } -#endif - if (stage != CONTROL_STAGE_SETUP) { - return true; - } - if (request == nullptr || - request->bmRequestType_bit.recipient != TUSB_REQ_RCPT_DEVICE || - request->wValue != UsbPairingManagement::kRequestValue || - request->wIndex != UsbPairingManagement::kRequestIndex) { - return false; - } - - switch (request->bRequest) { - case UsbPairingManagement::kRequestGet: { - if (request->bmRequestType_bit.direction != TUSB_DIR_IN) { - return false; - } - static uint8_t response[ - UsbPairingManagement::kMaximumResponseSize]; - Bluepad32PairingSnapshot snapshot{}; - bluepad32_input_backend_pairing_snapshot(&snapshot); - const size_t response_size = - UsbPairingManagement::encode_snapshot( - snapshot, response, sizeof(response)); - return response_size != 0 && - tud_control_xfer( - rhport, request, response, - static_cast(response_size)); - } - case UsbPairingManagement::kRequestRefresh: - if (request->bmRequestType_bit.direction != TUSB_DIR_OUT || - request->wLength != 0) { - return false; - } - bluepad32_input_backend_request_pairing_snapshot(); - return tud_control_status(rhport, request); - case UsbPairingManagement::kRequestClear: - if (request->bmRequestType_bit.direction != TUSB_DIR_OUT || - request->wLength != 0) { - return false; - } - bluepad32_input_backend_clear_pairings(); - return tud_control_status(rhport, request); - default: - return false; - } -} diff --git a/usb_pairing_management.h b/usb_pairing_management.h deleted file mode 100644 index a952485..0000000 --- a/usb_pairing_management.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include -#include - -#include "bluepad32_input_backend.h" - -namespace UsbPairingManagement { - -constexpr uint8_t kRequestClear = 0x50; -constexpr uint8_t kRequestGet = 0x51; -constexpr uint8_t kRequestRefresh = 0x52; -constexpr uint16_t kRequestValue = 0x5350; -constexpr uint16_t kRequestIndex = 0x4d47; -constexpr uint8_t kProtocolVersion = 1; -constexpr size_t kResponseHeaderSize = 12; -constexpr size_t kRecordSize = 8; -constexpr size_t kMaximumResponseSize = - kResponseHeaderSize + - BLUEPAD32_PAIRING_RECORD_CAPACITY * kRecordSize; - -size_t encode_snapshot(const Bluepad32PairingSnapshot& snapshot, - uint8_t* output, size_t output_size); - -} // namespace UsbPairingManagement