Complete controller profile runtime

This commit is contained in:
Joey Yakimowich-Payne 2026-09-02 20:28:45 -06:00
commit 7358670e63
26 changed files with 1565 additions and 324 deletions

View file

@ -75,21 +75,21 @@ 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. |
| Persistent configuration protocol | Complete | Adapter settings and identity-keyed profiles use separate two-copy CRC/generation stores with bounded transactions and recovery. |
| 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. |
| PlayStation Classic mode | Missing | Add strict one-controller legacy descriptor/report mode. |
| Mega Drive mode | Missing | Add strict one-controller legacy descriptor/report mode. |
| Manual output-mode selection | Missing | Add persistent PC command and controller chord. |
| General button remapping | Partial | Only per-controller ABXY swap exists. |
| Stick sensitivity | Missing in AIO | Add inner deadzone, outer saturation, curve, inversion, and center calibration. |
| Trigger ranges | Partial | Full analog values are preserved; profile-configurable lower/upper range, curve, and digital threshold remain. |
| Vibration intensity | Missing as configuration | Transport works; add per-profile weak/strong scaling. |
| Macros | Missing | Add a bounded deterministic macro engine. |
| Turbo and Auto Burst | Missing | Add exact 15 Hz behavior and cancellation rules. |
| Persistent profiles | Missing | Hotkey state currently resets on disconnect/reboot. |
| Profile switching | Missing | Add controller chord, USB command, rumble, and LED confirmation. |
| General button remapping | Complete | Sixteen positional logical inputs map directly to supported logical outputs per profile. |
| Stick sensitivity | Complete | Per-stick center calibration, inner deadzone, outer saturation, fixed-point curve, and inversion run before every output serializer. |
| Trigger ranges | Complete for current outputs | Per-trigger deadzone, saturation, curve, and digital threshold preserve analog XInput values and configured Switch thresholds. |
| Vibration intensity | Complete | Independent weak/strong profile scales apply to host rumble; local confirmation policy remains separate. |
| Macros | Complete | One bounded eight-step deterministic macro per profile supports buttons, D-pad, sticks, triggers, waits, and explicit end. |
| Turbo and Auto Burst | Complete | Fixed-point phase accumulation produces 15 activations per second with deterministic cancellation. |
| Persistent profiles | Complete | Global fallback plus sixteen stable identities each store four fixed 256-byte profiles in a two-bank atomic database. |
| Profile switching | Complete | PC commands and a configurable controller chord persist selection with isolated rumble/onboard/transient controller LED confirmation. |
| Firmware updater | Partial | UF2 updating works; version query and guided reboot/install tool are missing. |
| Switch 2 | Unverified | Requires real-hardware qualification. |
| Windows/SteamOS/Linux/Android compatibility | Partial | Windows XInput feasibility passed; other host/output combinations need qualification. |
@ -322,7 +322,7 @@ Core completion evidence:
and feasibility firmware reflash, after which reset stored the 60-second
default as generation 2
### Phase 3 — Mapping, tuning, profiles, and macros
### Phase 3 — Mapping, tuning, profiles, and macros — Complete
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.
@ -393,6 +393,29 @@ Acceptance:
- No synthetic input remains stuck after any cancellation path.
- Four controllers can use different profiles simultaneously.
Completion evidence:
- strict 256-byte profile schema and 17,696-byte fixed database support four
profiles for the global fallback and each of sixteen stable identities
- profile and adapter stores remain separate from each other and BTstack bonds;
profile commits use one flash-safe batched inactive-bank replacement
- schema-v1 profile databases migrate inherited trigger defaults to schema v2
without losing custom thresholds, identities, active profiles, or other data
- direct mapping, stick/trigger fixed-point transforms, Switch thresholds,
XInput analog values, rumble scaling, macros, Turbo, Auto Burst, and all
cancellation paths have deterministic native coverage
- the AIO backend uses BTstack GAP connection type for stable Classic/BLE
identity, not the stale cached Bluepad protocol field
- profile switching is serialized against host writes, applies only after
commit, and uses generation-safe per-slot feedback
- profile colors/player counts are transient on connect/switch and restore the
persistent USB slot indication after the final 75 ms gap
- 70 tests passed; UART, AIO, and feasibility variants linked and published
- hardware verified separate 8BitDo `057e:2009` and DualSense `054c:0ce6`
identities, isolated active profiles and feedback, persistent 2.2-second
profile commits, button remapping, stick tuning, 15 Hz Turbo, a releasing
macro, and transient profile-to-slot LED restoration
### Phase 4 — Production USB output modes
Implement in this order:
@ -536,8 +559,7 @@ Do not mark a host/controller combination complete from descriptor inspection or
## Next action
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.
Begin Phase 4 by placing the existing Switch and verified XInput
implementations behind one production output-driver boundary. Preserve the
Phase 3 transform/runtime seam before serialization, then add DInput, Mac,
PlayStation Classic, and Mega Drive descriptors in the documented order.

View file

@ -85,7 +85,7 @@ The Pico 2 W onboard LED reports the overall Bluetooth state:
- **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 configuration and pairings from a PC
### Managing configuration, profiles, 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. `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.
@ -94,25 +94,34 @@ 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 profiles list
uv run switch-pico-config profiles export 1 profile.json --identity 0
uv run switch-pico-config profiles import 2 profile.json --identity 0
uv run switch-pico-config profiles activate 2 --identity 0
uv run switch-pico-config profiles reset all --identity 0 --yes
uv run switch-pico-config pairings list
uv run switch-pico-config pairings clear --yes
```
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.
Adapter configuration records use version, size, generation, and CRC fields in two dedicated flash sectors. Profiles use a separate two-bank atomic store before the adapter and Bluepad32 bond regions. Profile writes are chunked, verified before the new bank is committed, recover the previous generation after interruption or corruption, skip unchanged data, and are rate-limited.
`profiles list` prints identity index `0` for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns four persistent profiles and one active index. Exported JSON contains direct logical button mappings, independent stick and trigger calibration/curves, digital trigger thresholds, weak/strong rumble scales, a profile-switching chord, one bounded eight-step macro, and per-button Turbo modes. Profile numbers shown to users are `1` through `4`; `--identity` uses the zero-based index from `profiles list`.
`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
### Per-controller profiles
Each connected AIO controller can toggle its own ABXY layout by pressing **L + R + Select + Start** together. On DualSense, use **L1 + R1 + Create + Options**. The controller gives one short rumble when the toggle is accepted; release the chord before toggling again.
The default profile-switching chord is **L + R + Select + Start**. On DualSense, use **L1 + R1 + Create + Options**. A profile can replace this chord with any nonzero logical-button mask; a stored zero selects the default chord.
- **Standard**: south→B, east→A, west→Y, north→X.
- **Swapped**: south→A, east→B, west→X, north→Y.
- The chord is consumed locally and is not forwarded to the Switch.
- Other controller slots are unaffected.
- Layout returns to the configured default after disconnect or reboot.
- The chord cycles persistent profiles `1 → 2 → 3 → 4 → 1`.
- Chord buttons are consumed locally and are not forwarded to the host.
- The new profile applies only after its atomic flash commit completes.
- Confirmation uses one to four 75 ms pulses matching the active profile number.
- The profile policy independently enables rumble and LED feedback.
- On connection and profile changes, RGB/player LEDs briefly show the active profile color/count, then return to the persistent USB slot color/player number.
- Each controller identity and each of the four active USB slots remain isolated.
Edit `controller_hotkey_config.h` to change the chord, default layout, or confirmation pulse.
Profile input processing is deterministic: physical input is mapped and tuned first, Turbo or Auto Burst gates configured buttons second, and active macro overrides apply last. Turbo runs at 15 activations per second while held. Auto Burst starts on one press and stops on a second press or the configured macro-cancel button. Macros contain at most eight state steps plus an explicit end and are cancelled on disconnect, profile/output-mode change, configured cancellation, or adapter configuration reset.
### Per-controller motion toggle
@ -271,7 +280,7 @@ Filters you can use:
- When testing on a PC before plugging into a Switch, you can verify activity with the lightweight `switch_pico_bridge.switch_pico_uart` helper or the Windows "Game Controllers" panel.
## Building and flashing firmware
Prereqs: Pico SDK + CMake toolchain set up.
Prereqs: Pico SDK, Arm GNU toolchain, CMake, and `picotool`.
### Using `build.py`
@ -279,6 +288,14 @@ Prereqs: Pico SDK + CMake toolchain set up.
were created, copies the release artifacts into `firmware/`, and flashes the ELF
with `picotool`.
`build.py` automatically locates the Pico SDK and Arm GNU toolchain from valid
existing `build/`, `build-aio/`, or `build-feasibility/` CMake caches, then from
project-local `build/_deps/pico_sdk-src` and `build/toolchain` installs, and
finally from conventional user and system locations. A compiler already on
`PATH` is used without setting a toolchain override. Explicit `PICO_SDK_PATH`
and `PICO_TOOLCHAIN_PATH` values always take precedence; an invalid explicit
path is reported instead of silently falling back.
Before running it:
1. Install the Pico SDK, CMake toolchain, and `picotool`.
@ -316,16 +333,19 @@ If the tools or artifacts are in non-default locations, use these environment
variables:
```sh
PICO_SDK_PATH=/path/to/pico-sdk \
PICO_TOOLCHAIN_PATH=/path/to/arm-none-eabi-toolchain \
PICOTOOL_PATH=/path/to/picotool \
ELF_PATH=/path/to/switch-pico.elf \
UF2_PATH=/path/to/switch-pico.uf2 \
python3 build.py
```
`PICOTOOL_PATH` selects the flashing tool, `ELF_PATH` selects the ELF that is
checked and flashed, and `UF2_PATH` selects the UF2 that is checked after the
build. Their defaults are `picotool` from `PATH`, `build/switch-pico.elf`, and
`build/switch-pico.uf2`, respectively.
`PICO_SDK_PATH` and `PICO_TOOLCHAIN_PATH` explicitly select the SDK and
cross-compiler installations. `PICOTOOL_PATH` selects the flashing tool,
`ELF_PATH` selects the ELF that is checked and flashed, and `UF2_PATH` selects
the UF2 that is checked after the build. Their defaults are `picotool` from
`PATH`, `build/switch-pico.elf`, and `build/switch-pico.uf2`, respectively.
### Manual build
```sh

View file

@ -43,6 +43,10 @@ constexpr uint8_t kAllBlePairingMethods =
constexpr uint16_t kProfileFeedbackPhaseDurationMs = 75;
constexpr uint8_t kProfileFeedbackWeakMagnitude = UINT8_MAX;
constexpr uint8_t kProfileFeedbackStrongMagnitude = UINT8_MAX;
// One initial indication can be followed by one committed switch before the
// Core 1 timer drains the queue. Profile commits are rate-limited well beyond
// the longest feedback sequence.
constexpr uint8_t kProfileFeedbackQueueCapacity = 2;
constexpr SwitchRgbColor kProfileLightbarPalette[CONTROLLER_PROFILE_COUNT] = {
{0x00, 0x55, 0xff},
{0x00, 0xcc, 0x66},
@ -152,12 +156,13 @@ struct BackendSlot {
bool motion_hotkey_latched;
bool feedback_pending;
uint32_t feedback_until_ms;
bool profile_feedback_pending;
uint8_t pending_profile_feedback_count;
RumbleEnvelope pending_rumble;
bool retained_host_rumble_valid;
RumbleEnvelope retained_host_rumble;
FeedbackEnvelope pending_feedback;
ProfileFeedbackEnvelope pending_profile_feedback;
ProfileFeedbackEnvelope
pending_profile_feedback[kProfileFeedbackQueueCapacity];
ProfileFeedbackSequence profile_feedback;
};
@ -298,23 +303,29 @@ ControllerIdentity identity_for_device(const uni_hid_device_t* device) {
if (device == nullptr) {
return controller_identity_global();
}
if (device->conn.protocol == UNI_BT_CONN_PROTOCOL_BR_EDR) {
ControllerIdentity identity{};
identity.stable = true;
identity.transport = ControllerTransport::kClassic;
identity.address_type = BD_ADDR_TYPE_UNKNOWN;
memcpy(identity.address, device->conn.btaddr,
sizeof(identity.address));
identity.vendor_id = device->vendor_id;
identity.product_id = device->product_id;
return identity;
}
if (device->conn.protocol == UNI_BT_CONN_PROTOCOL_BLE) {
const BleIdentityMapping* mapping = find_ble_identity_mapping(
device->conn.handle, device->conn.btaddr);
if (mapping != nullptr) {
return make_ble_identity(*mapping, device);
switch (gap_get_connection_type(device->conn.handle)) {
case GAP_CONNECTION_ACL: {
ControllerIdentity identity{};
identity.stable = true;
identity.transport = ControllerTransport::kClassic;
identity.address_type = BD_ADDR_TYPE_UNKNOWN;
memcpy(identity.address, device->conn.btaddr,
sizeof(identity.address));
identity.vendor_id = device->vendor_id;
identity.product_id = device->product_id;
return identity;
}
case GAP_CONNECTION_LE: {
const BleIdentityMapping* mapping = find_ble_identity_mapping(
device->conn.handle, device->conn.btaddr);
if (mapping != nullptr) {
return make_ble_identity(*mapping, device);
}
break;
}
case GAP_CONNECTION_INVALID:
case GAP_CONNECTION_SCO:
break;
}
return controller_identity_global();
}
@ -325,7 +336,8 @@ void publish_ble_identity(const BleIdentityMapping& mapping) {
critical_section_enter_blocking(&g_state_lock);
for (BackendSlot& slot : g_slots) {
if (slot.device != nullptr &&
slot.device->conn.protocol == UNI_BT_CONN_PROTOCOL_BLE &&
gap_get_connection_type(slot.device->conn.handle) ==
GAP_CONNECTION_LE &&
slot.device->conn.handle == mapping.connection_handle &&
addresses_equal(slot.device->conn.btaddr,
mapping.connection_address)) {
@ -374,7 +386,8 @@ void clear_ble_identity_for_handle(hci_con_handle_t connection_handle) {
critical_section_enter_blocking(&g_state_lock);
for (BackendSlot& slot : g_slots) {
if (slot.device != nullptr &&
slot.device->conn.protocol == UNI_BT_CONN_PROTOCOL_BLE &&
gap_get_connection_type(slot.device->conn.handle) ==
GAP_CONNECTION_LE &&
slot.device->conn.handle == connection_handle) {
slot.identity = controller_identity_global();
}
@ -383,8 +396,7 @@ void clear_ble_identity_for_handle(hci_con_handle_t connection_handle) {
}
void clear_ble_identity_for_device(const uni_hid_device_t* device) {
if (device == nullptr ||
device->conn.protocol != UNI_BT_CONN_PROTOCOL_BLE) {
if (device == nullptr) {
return;
}
for (BleIdentityMapping& mapping : g_ble_identity_mappings) {
@ -452,6 +464,19 @@ void apply_profile_lighting(
}
}
bool lighting_target_is_current(
uint8_t slot_index, uint32_t connection_generation,
const uni_hid_device_t* device) {
critical_section_enter_blocking(&g_state_lock);
const bool current =
slot_index < kSlotCount && g_slots[slot_index].active &&
g_slots[slot_index].device == device &&
g_slots[slot_index].connection_generation ==
connection_generation;
critical_section_exit(&g_state_lock);
return current;
}
ConnectionStatus compute_connection_status() {
@ -498,8 +523,11 @@ void publish_all_neutral() {
slot.retained_host_rumble = {};
slot.feedback_pending = false;
slot.feedback_until_ms = 0;
slot.profile_feedback_pending = false;
slot.pending_profile_feedback = {};
slot.pending_profile_feedback_count = 0;
for (ProfileFeedbackEnvelope& feedback :
slot.pending_profile_feedback) {
feedback = {};
}
slot.profile_feedback = {};
++slot.state_generation;
++slot.connection_generation;
@ -790,8 +818,11 @@ void reset_slot_hotkeys(BackendSlot& slot) {
slot.feedback_pending = false;
slot.feedback_until_ms = 0;
slot.pending_feedback = {};
slot.profile_feedback_pending = false;
slot.pending_profile_feedback = {};
slot.pending_profile_feedback_count = 0;
for (ProfileFeedbackEnvelope& feedback :
slot.pending_profile_feedback) {
feedback = {};
}
slot.profile_feedback = {};
slot.retained_host_rumble_valid = false;
slot.retained_host_rumble = {};
@ -1216,11 +1247,11 @@ void update_status_led() {
}
void process_configuration_timer(btstack_timer_source_t* timer) {
btstack_run_loop_set_timer(timer, kConfigurationPollIntervalMs);
btstack_run_loop_add_timer(timer);
const uint32_t now_ms = btstack_run_loop_get_time_ms();
configuration_service_task_on_storage_core(now_ms);
profile_service_task_on_storage_core(now_ms);
btstack_run_loop_set_timer(timer, kConfigurationPollIntervalMs);
btstack_run_loop_add_timer(timer);
}
void process_rumble_timer(btstack_timer_source_t* timer) {
@ -1239,7 +1270,10 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
FeedbackEnvelope feedback{};
ProfileFeedbackEnvelope profile_feedback{};
uni_hid_device_t* device = nullptr;
uni_hid_device_t* profile_lighting_device = nullptr;
uint32_t profile_lighting_generation = 0;
bool profile_lighting_dispatch = false;
bool profile_lighting_restore = false;
bool profile_rumble_dispatch = false;
bool feedback_dispatch = false;
bool host_dispatch = false;
@ -1266,6 +1300,10 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
slot.profile_feedback.active;
const bool completed_feedback_had_rumble =
slot.profile_feedback.rumble_enabled;
const bool completed_feedback_had_led =
slot.profile_feedback.led_enabled;
const uint32_t completed_feedback_generation =
slot.profile_feedback.connection_generation;
profile_rumble_dispatch =
advance_profile_feedback(&slot.profile_feedback, now_ms);
if (profile_feedback_was_active &&
@ -1275,6 +1313,17 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
slot.pending_rumble = slot.retained_host_rumble;
slot.rumble_pending = true;
}
if (profile_feedback_was_active &&
!slot.profile_feedback.active &&
completed_feedback_had_led && slot.active &&
slot.device != nullptr &&
completed_feedback_generation ==
slot.connection_generation) {
profile_lighting_device = slot.device;
profile_lighting_generation =
completed_feedback_generation;
profile_lighting_restore = true;
}
if (profile_rumble_dispatch) {
device = slot.device;
}
@ -1282,9 +1331,15 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
const bool feedback_active =
static_cast<int32_t>(now_ms - slot.feedback_until_ms) < 0;
if (!slot.profile_feedback.active && !feedback_active &&
slot.profile_feedback_pending) {
profile_feedback = slot.pending_profile_feedback;
slot.profile_feedback_pending = false;
slot.pending_profile_feedback_count != 0) {
profile_feedback = slot.pending_profile_feedback[0];
if (slot.pending_profile_feedback_count == 2) {
slot.pending_profile_feedback[0] =
slot.pending_profile_feedback[1];
}
--slot.pending_profile_feedback_count;
slot.pending_profile_feedback[
slot.pending_profile_feedback_count] = {};
const uint8_t policy =
static_cast<uint8_t>(profile_feedback.policy);
if (slot.active && slot.device != nullptr &&
@ -1311,6 +1366,9 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
kLed)) != 0,
};
device = slot.device;
profile_lighting_device = slot.device;
profile_lighting_generation =
slot.profile_feedback.connection_generation;
profile_lighting_dispatch =
slot.profile_feedback.led_enabled;
profile_rumble_dispatch =
@ -1353,9 +1411,20 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
}
critical_section_exit(&g_state_lock);
if (profile_lighting_dispatch) {
if (profile_lighting_restore &&
lighting_target_is_current(
slot_index, profile_lighting_generation,
profile_lighting_device)) {
apply_slot_lighting(slot_index,
profile_lighting_device);
}
if (profile_lighting_dispatch &&
lighting_target_is_current(
slot_index, profile_lighting_generation,
profile_lighting_device)) {
apply_profile_lighting(
profile_feedback.active_profile_number, device);
profile_feedback.active_profile_number,
profile_lighting_device);
}
if (profile_rumble_dispatch && device != nullptr &&
device->report_parser.play_dual_rumble != nullptr) {
@ -1515,6 +1584,7 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
bool occupied_mismatch = false;
bool became_active = false;
uint32_t lighting_generation = 0;
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
occupied_mismatch = slot.device != nullptr && slot.device != device;
@ -1528,6 +1598,7 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
reset_slot_hotkeys(slot);
++slot.state_generation;
became_active = true;
lighting_generation = slot.connection_generation;
}
}
critical_section_exit(&g_state_lock);
@ -1536,7 +1607,12 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
return UNI_ERROR_NO_SLOTS;
}
if (became_active) {
apply_slot_lighting(static_cast<uint8_t>(slot_index), device);
if (lighting_target_is_current(
static_cast<uint8_t>(slot_index),
lighting_generation, device)) {
apply_slot_lighting(
static_cast<uint8_t>(slot_index), device);
}
if (connection_identity.stable) {
profile_service_observe_identity_on_storage_core(
connection_identity);
@ -1812,9 +1888,16 @@ void bluepad32_input_backend_queue_profile_feedback(
BackendSlot& slot = g_slots[slot_index];
if (slot.active && slot.device != nullptr &&
slot.connection_generation == connection_generation) {
slot.pending_profile_feedback = {
const ProfileFeedbackEnvelope feedback{
connection_generation, active_profile_number, policy};
slot.profile_feedback_pending = true;
if (slot.pending_profile_feedback_count <
kProfileFeedbackQueueCapacity) {
slot.pending_profile_feedback[
slot.pending_profile_feedback_count++] = feedback;
} else {
slot.pending_profile_feedback[
kProfileFeedbackQueueCapacity - 1u] = feedback;
}
}
critical_section_exit(&g_state_lock);
}

View file

@ -58,8 +58,10 @@ void bluepad32_input_backend_pairing_snapshot(
void bluepad32_input_backend_report_sent(uint8_t slot);
void bluepad32_input_backend_queue_rumble(
uint8_t slot, const ControllerRumbleOutput& rumble);
// Schedule bounded local profile confirmation only for the matching live
// connection generation. Profile numbers are one-based (1..4).
// Enqueue bounded local profile confirmation for the matching live connection
// generation. The two-entry per-slot FIFO preserves initial-then-switch
// ordering. Profile lighting is transient and restored to the steady slot
// indication after the final gap. Profile numbers are one-based (1..4).
void bluepad32_input_backend_queue_profile_feedback(
uint8_t slot, uint32_t connection_generation,
uint8_t active_profile_number,

232
build.py
View file

@ -35,6 +35,232 @@ MACROS = tuple(
for component in ("R", "G", "B")
)
CMAKE_CACHE_PATHS = tuple(
build_dir / "CMakeCache.txt"
for build_dir in (BUILD_DIR, AIO_BUILD_DIR, FEASIBILITY_BUILD_DIR)
)
TOOLCHAIN_COMPILER = (
"arm-none-eabi-gcc.exe" if os.name == "nt" else "arm-none-eabi-gcc"
)
class BuildEnvironmentError(RuntimeError):
"""A required Pico build dependency could not be resolved."""
def parse_cmake_cache(cache_path):
"""Return the simple key/value entries from an existing CMake cache."""
cache_path = Path(cache_path)
try:
lines = cache_path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError):
return {}
entries = {}
for line in lines:
if not line or line.startswith(("//", "#")) or "=" not in line:
continue
key_and_type, value = line.split("=", 1)
key = key_and_type.split(":", 1)[0]
if key:
entries[key] = value
return entries
def _cache_path(value, cache_path):
path = Path(value).expanduser()
if not path.is_absolute():
path = cache_path.parent / path
return path
def _versioned_candidates(parent):
try:
return sorted(
(path for path in parent.iterdir() if path.is_dir()),
key=lambda path: path.name,
reverse=True,
)
except OSError:
return []
def _sdk_fallback_candidates():
yield ("project-local install", BUILD_DIR / "_deps" / "pico_sdk-src")
pico_sdk_home = Path.home() / ".pico-sdk" / "sdk"
for path in _versioned_candidates(pico_sdk_home):
yield ("user Pico SDK install", path)
yield ("user Pico SDK install", Path.home() / "pico" / "pico-sdk")
yield ("user Pico SDK install", Path.home() / "pico-sdk")
yield ("system Pico SDK install", Path("/opt/pico-sdk"))
yield ("system Pico SDK install", Path("/usr/local/pico-sdk"))
yield ("system Pico SDK install", Path("/usr/share/pico-sdk"))
def _toolchain_fallback_candidates():
yield ("project-local install", BUILD_DIR / "toolchain")
pico_toolchain_home = Path.home() / ".pico-sdk" / "toolchain"
for path in _versioned_candidates(pico_toolchain_home):
yield ("user Pico toolchain install", path)
yield (
"user Pico toolchain install",
Path.home() / "pico" / "arm-none-eabi-gcc",
)
yield ("user Pico toolchain install", Path.home() / "arm-none-eabi-gcc")
yield ("system Pico toolchain install", Path("/opt/arm-none-eabi-gcc"))
yield (
"system Pico toolchain install",
Path("/usr/local/arm-none-eabi-gcc"),
)
def _valid_sdk(path):
return (path / "pico_sdk_init.cmake").is_file()
def _valid_toolchain(path):
return (path / "bin" / TOOLCHAIN_COMPILER).is_file()
def _first_valid_candidate(candidates, validator):
for source, candidate in candidates:
path = Path(candidate).expanduser()
if validator(path):
return path, source
return None, None
def _cache_candidates(cache_paths, variables):
for cache_path in cache_paths:
cache_path = Path(cache_path)
entries = parse_cmake_cache(cache_path)
for variable, compiler_path in variables:
value = entries.get(variable)
if not value:
continue
path = _cache_path(value, cache_path)
if compiler_path:
path = path.parent.parent
yield (f"cache {cache_path}", path)
def _explicit_path(environ, variable, validator, expected):
if variable not in environ:
return None
value = environ[variable]
if not value:
raise BuildEnvironmentError(f"{variable} is set but empty.")
path = Path(value).expanduser()
if not validator(path):
raise BuildEnvironmentError(
f"{variable} is set to {path}, but {expected} was not found."
)
return path
def configure_pico_environment(
*,
environ=None,
cache_paths=None,
sdk_candidates=None,
toolchain_candidates=None,
which=None,
):
"""Resolve Pico dependencies and apply auto-detected environment values."""
environ = os.environ if environ is None else environ
cache_paths = CMAKE_CACHE_PATHS if cache_paths is None else cache_paths
which = shutil.which if which is None else which
explicit_sdk = _explicit_path(
environ,
"PICO_SDK_PATH",
_valid_sdk,
"pico_sdk_init.cmake",
)
explicit_toolchain = _explicit_path(
environ,
"PICO_TOOLCHAIN_PATH",
_valid_toolchain,
f"bin/{TOOLCHAIN_COMPILER}",
)
sdk_path = explicit_sdk
sdk_source = None
if sdk_path is None:
sdk_path, sdk_source = _first_valid_candidate(
_cache_candidates(
cache_paths,
(("PICO_SDK_PATH", False),),
),
_valid_sdk,
)
if sdk_path is None:
sdk_path, sdk_source = _first_valid_candidate(
(
_sdk_fallback_candidates()
if sdk_candidates is None
else sdk_candidates
),
_valid_sdk,
)
compiler_on_path = which(
TOOLCHAIN_COMPILER,
path=environ.get("PATH", ""),
)
toolchain_path = explicit_toolchain
toolchain_source = None
if toolchain_path is None and compiler_on_path is None:
toolchain_path, toolchain_source = _first_valid_candidate(
_cache_candidates(
cache_paths,
(
("PICO_TOOLCHAIN_PATH", False),
("CMAKE_C_COMPILER", True),
("PICO_COMPILER_CC", True),
),
),
_valid_toolchain,
)
if toolchain_path is None:
toolchain_path, toolchain_source = _first_valid_candidate(
(
_toolchain_fallback_candidates()
if toolchain_candidates is None
else toolchain_candidates
),
_valid_toolchain,
)
missing = []
if sdk_path is None:
missing.append("Pico SDK (set PICO_SDK_PATH)")
if toolchain_path is None and compiler_on_path is None:
missing.append(
"Arm GNU toolchain "
"(set PICO_TOOLCHAIN_PATH or add arm-none-eabi-gcc to PATH)"
)
if missing:
raise BuildEnvironmentError(
"Missing build prerequisite(s): " + "; ".join(missing) + "."
)
updates = {}
detected = []
if explicit_sdk is None:
updates["PICO_SDK_PATH"] = str(sdk_path)
detected.append(("PICO_SDK_PATH", sdk_path, sdk_source))
if explicit_toolchain is None and compiler_on_path is None:
updates["PICO_TOOLCHAIN_PATH"] = str(toolchain_path)
detected.append(
("PICO_TOOLCHAIN_PATH", toolchain_path, toolchain_source)
)
environ.update(updates)
for variable, path, source in detected:
print(f"Auto-detected {variable}={path} ({source})")
return updates
def parse_args():
parser = argparse.ArgumentParser(
description="Build and flash the project, optionally setting grip colors.",
@ -197,6 +423,12 @@ def flash(elf_path, allow_elf_override):
def main():
args = parse_args()
try:
configure_pico_environment()
except BuildEnvironmentError as exc:
sys.stderr.write(f"Error: {exc}\n")
sys.exit(1)
color = None
if args.random_grip_color:

View file

@ -27,6 +27,10 @@ struct ControllerProfileRuntimeContext {
uint32_t switching_transaction_id = 0;
bool profile_change_pending = false;
ControllerProfileRuntimeProfileChangeEvent pending_profile_change{};
bool initial_profile_indication_resolved = false;
bool initial_profile_indication_pending = false;
ControllerProfileRuntimeProfileChangeEvent
pending_initial_profile_indication{};
};
ControllerProfileRuntimeContext
@ -98,6 +102,9 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
context->switching_transaction_id = 0;
context->profile_change_pending = false;
context->pending_profile_change = {};
context->initial_profile_indication_resolved = false;
context->initial_profile_indication_pending = false;
context->pending_initial_profile_indication = {};
}
controller_synthetic_input_cancel(&context->synthetic,
@ -111,6 +118,21 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
context->active_profile_index = snapshot.valid ? snapshot.profile_index : 0;
context->profile_snapshot_valid = snapshot.valid;
context->profile = snapshot.valid ? snapshot.profile : g_default_profile;
if (!context->initial_profile_indication_resolved && snapshot.valid) {
context->initial_profile_indication_resolved = true;
const uint8_t policy = static_cast<uint8_t>(
controller_profile_confirmation_policy(context->profile));
if ((policy & static_cast<uint8_t>(
ControllerProfileConfirmationPolicy::kLed)) != 0) {
context->pending_initial_profile_indication = {
connection_generation,
context->database_generation,
static_cast<uint8_t>(context->active_profile_index + 1u),
ControllerProfileConfirmationPolicy::kLed,
};
context->initial_profile_indication_pending = true;
}
}
if (context->switching_chord_held &&
!context->switching_activation_requested) {
context->switching_target_profile_index =
@ -273,6 +295,26 @@ ControllerProfileTransformResult controller_profile_runtime_transform(
&context->synthetic, consumed_input, context->profile, now_ms);
}
bool controller_profile_runtime_take_initial_profile_indication(
uint8_t slot, ControllerProfileRuntimeProfileChangeEvent* output) {
if (output == nullptr) {
return false;
}
*output = {};
if (slot >= CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT) {
return false;
}
ControllerProfileRuntimeContext& context = g_contexts[slot];
if (!context.initial_profile_indication_pending) {
return false;
}
*output = context.pending_initial_profile_indication;
context.initial_profile_indication_pending = false;
context.pending_initial_profile_indication = {};
return true;
}
bool controller_profile_runtime_take_profile_change(
uint8_t slot, ControllerProfileRuntimeProfileChangeEvent* output) {
if (output == nullptr) {

View file

@ -27,6 +27,12 @@ struct ControllerProfileRuntimeProfileChangeEvent {
ControllerProfileConfirmationPolicy::kNone;
};
// Take the one-shot LED-only indication for the first committed profile
// resolved on a connection. Identity promotion and database refresh do not
// publish it again.
bool controller_profile_runtime_take_initial_profile_indication(
uint8_t slot, ControllerProfileRuntimeProfileChangeEvent* output);
struct ControllerProfileRuntimeLocalConfirmation {
ControllerRumbleOutput rumble{};
ControllerProfileConfirmationPolicy policy =

Binary file not shown.

Binary file not shown.

View file

@ -37,20 +37,57 @@ static_assert(PICO_FLASH_BANK_STORAGE_OFFSET +
PICO_FLASH_SIZE_BYTES,
"BTstack storage exceeds flash");
struct FlashMutation {
bool erase;
uint32_t offset;
const uint8_t* data;
struct FlashBankReplacement {
uint8_t bank;
const uint8_t* payload;
size_t payload_size;
const uint8_t* header;
bool replaced;
};
void perform_flash_mutation(void* context) {
const auto* mutation = static_cast<const FlashMutation*>(context);
if (mutation->erase) {
flash_range_erase(mutation->offset, FLASH_SECTOR_SIZE);
} else {
flash_range_program(mutation->offset, mutation->data,
FLASH_PAGE_SIZE);
void perform_flash_bank_replacement(void* context) {
auto* replacement =
static_cast<FlashBankReplacement*>(context);
replacement->replaced = false;
const uint32_t bank_offset =
kProfileStorageOffset +
replacement->bank * PROFILE_STORAGE_BANK_SIZE;
for (size_t offset = 0; offset < PROFILE_STORAGE_BANK_SIZE;
offset += FLASH_SECTOR_SIZE) {
flash_range_erase(bank_offset + offset, FLASH_SECTOR_SIZE);
}
uint8_t final_page[FLASH_PAGE_SIZE]{};
for (size_t offset = 0; offset < replacement->payload_size;
offset += FLASH_PAGE_SIZE) {
const size_t remaining =
replacement->payload_size - offset;
const uint8_t* page = &replacement->payload[offset];
if (remaining < FLASH_PAGE_SIZE) {
memcpy(final_page, page, remaining);
page = final_page;
}
flash_range_program(
bank_offset + PROFILE_STORAGE_RECORD_HEADER_SIZE + offset,
page, FLASH_PAGE_SIZE);
}
const auto* stored_payload = reinterpret_cast<const uint8_t*>(
XIP_BASE + bank_offset +
PROFILE_STORAGE_RECORD_HEADER_SIZE);
if (memcmp(stored_payload, replacement->payload,
replacement->payload_size) != 0) {
return;
}
flash_range_program(bank_offset, replacement->header,
PROFILE_STORAGE_RECORD_HEADER_SIZE);
const auto* stored_header = reinterpret_cast<const uint8_t*>(
XIP_BASE + bank_offset);
replacement->replaced =
memcmp(stored_header, replacement->header,
PROFILE_STORAGE_RECORD_HEADER_SIZE) == 0;
}
bool storage_region_available() {
@ -74,41 +111,29 @@ bool read_storage(void*, uint8_t bank, size_t offset, uint8_t* output,
return true;
}
bool erase_storage_sector(void*, uint8_t bank, size_t offset) {
if (bank >= PROFILE_STORAGE_BANK_COUNT ||
offset % FLASH_SECTOR_SIZE != 0 ||
offset > PROFILE_STORAGE_BANK_SIZE ||
FLASH_SECTOR_SIZE > PROFILE_STORAGE_BANK_SIZE - offset ||
bool replace_storage_bank(void*, uint8_t bank,
const uint8_t* payload,
size_t payload_size,
const uint8_t* header,
size_t header_size) {
if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr ||
header == nullptr ||
payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE ||
!storage_region_available()) {
return false;
}
FlashMutation mutation{
true,
static_cast<uint32_t>(
kProfileStorageOffset + bank * PROFILE_STORAGE_BANK_SIZE + offset),
nullptr,
};
return flash_safe_execute(perform_flash_mutation, &mutation,
UINT32_MAX) == PICO_OK;
}
bool program_storage(void*, uint8_t bank, size_t offset,
const uint8_t* data, size_t size) {
if (bank >= PROFILE_STORAGE_BANK_COUNT || data == nullptr ||
size != FLASH_PAGE_SIZE || offset % FLASH_PAGE_SIZE != 0 ||
offset > PROFILE_STORAGE_BANK_SIZE ||
size > PROFILE_STORAGE_BANK_SIZE - offset ||
!storage_region_available()) {
return false;
}
FlashMutation mutation{
FlashBankReplacement replacement{
bank,
payload,
payload_size,
header,
false,
static_cast<uint32_t>(
kProfileStorageOffset + bank * PROFILE_STORAGE_BANK_SIZE + offset),
data,
};
return flash_safe_execute(perform_flash_mutation, &mutation,
UINT32_MAX) == PICO_OK;
return flash_safe_execute(
perform_flash_bank_replacement, &replacement,
UINT32_MAX) == PICO_OK &&
replacement.replaced;
}
} // namespace
@ -120,7 +145,6 @@ ProfileStorageIo pico_profile_storage_io() {
FLASH_SECTOR_SIZE,
FLASH_PAGE_SIZE,
read_storage,
erase_storage_sector,
program_storage,
replace_storage_bank,
};
}

View file

@ -50,6 +50,8 @@ uint8_t g_active_profile_count = 0;
ProfileTransaction g_transaction;
PendingCommand g_command;
PendingCommand g_internal_activation;
alignas(4) uint8_t
g_encoded_database[CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE]{};
bool g_identity_dirty = false;
bool g_has_committed = false;
uint32_t g_last_commit_ms = 0;
@ -309,7 +311,8 @@ void profile_service_task_on_storage_core(uint32_t now_ms) {
return;
}
const ProfileStorageResult storage_result = g_storage.commit(g_database);
const ProfileStorageResult storage_result = g_storage.commit(
g_database, g_encoded_database, sizeof(g_encoded_database));
ConfigurationTransactionStatus status =
ConfigurationTransactionStatus::kStorageError;
if (storage_result == ProfileStorageResult::kOk) {

View file

@ -77,13 +77,14 @@ bool ProfileStorage::initialize(const ProfileStorageIo& io,
ControllerProfileDatabase* database) {
io_ = io;
snapshot_ = {};
initialized_ = database != nullptr && io_.read != nullptr &&
io_.erase_sector != nullptr && io_.program != nullptr &&
io_.bank_size >= PROFILE_STORAGE_BANK_SIZE &&
io_.sector_size == PROFILE_STORAGE_SECTOR_SIZE &&
io_.page_size == PROFILE_STORAGE_PAGE_SIZE &&
io_.bank_size % io_.sector_size == 0 &&
io_.sector_size % io_.page_size == 0;
initialized_ =
database != nullptr && io_.read != nullptr &&
io_.replace_bank != nullptr &&
io_.bank_size >= PROFILE_STORAGE_BANK_SIZE &&
io_.sector_size == PROFILE_STORAGE_SECTOR_SIZE &&
io_.page_size == PROFILE_STORAGE_PAGE_SIZE &&
io_.bank_size % io_.sector_size == 0 &&
io_.sector_size % io_.page_size == 0;
if (!initialized_) {
return false;
}
@ -120,102 +121,46 @@ bool ProfileStorage::initialize(const ProfileStorageIo& io,
}
ProfileStorageResult ProfileStorage::commit(
const ControllerProfileDatabase& database) {
if (!initialized_ || !controller_profile_database_validate(database)) {
const ControllerProfileDatabase& database,
uint8_t* encoded_database, size_t encoded_database_size) {
if (!initialized_ || !controller_profile_database_validate(database) ||
encoded_database == nullptr ||
encoded_database_size < CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
!controller_profile_database_encode_range(
database, 0, encoded_database,
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)) {
return ProfileStorageResult::kInvalidArgument;
}
if (snapshot_.valid && payload_matches(snapshot_.active_bank, database)) {
if (snapshot_.valid &&
payload_matches_encoded(
snapshot_.active_bank, encoded_database,
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE)) {
return ProfileStorageResult::kUnchanged;
}
uint8_t page[PROFILE_STORAGE_PAGE_SIZE]{};
uint32_t crc = 0xffffffffu;
for (size_t offset = 0;
offset < CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE;
offset += sizeof(page)) {
const size_t size =
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset < sizeof(page)
? CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset
: sizeof(page);
if (!controller_profile_database_encode_range(database, offset,
page, size)) {
return ProfileStorageResult::kInvalidArgument;
}
crc = crc32_update(crc, page, size);
}
crc = ~crc;
const uint8_t target_bank = snapshot_.valid
? snapshot_.active_bank ^ 1u
: 0;
for (size_t offset = 0; offset < PROFILE_STORAGE_BANK_SIZE;
offset += PROFILE_STORAGE_SECTOR_SIZE) {
if (!io_.erase_sector(io_.context, target_bank, offset)) {
return ProfileStorageResult::kIoError;
}
}
for (size_t offset = 0;
offset < CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE;
offset += sizeof(page)) {
memset(page, 0, sizeof(page));
const size_t size =
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset < sizeof(page)
? CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset
: sizeof(page);
if (!controller_profile_database_encode_range(database, offset,
page, size) ||
!io_.program(io_.context, target_bank,
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset,
page, sizeof(page))) {
return ProfileStorageResult::kIoError;
}
}
uint8_t stored_page[PROFILE_STORAGE_PAGE_SIZE]{};
uint32_t stored_crc = 0xffffffffu;
bool stored_payload_matches = true;
for (size_t offset = 0;
offset < CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE;
offset += sizeof(stored_page)) {
const size_t size =
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset <
sizeof(stored_page)
? CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset
: sizeof(stored_page);
if (!io_.read(io_.context, target_bank,
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset,
stored_page, size)) {
return ProfileStorageResult::kIoError;
}
if (!controller_profile_database_encode_range(database, offset,
page, size)) {
return ProfileStorageResult::kInvalidArgument;
}
for (size_t index = 0; index < size; ++index) {
stored_payload_matches &=
stored_page[index] == page[index];
}
stored_crc = crc32_update(stored_crc, stored_page, size);
}
if (!stored_payload_matches || ~stored_crc != crc) {
return ProfileStorageResult::kIoError;
}
memset(page, 0, sizeof(page));
memcpy(page, kRecordMagic, sizeof(kRecordMagic));
storage_write_u16(&page[4], kRecordFormatVersion);
storage_write_u16(&page[6],
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION);
const uint32_t crc = profile_storage_crc32(
encoded_database, CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE);
const uint8_t target_bank =
snapshot_.valid ? snapshot_.active_bank ^ 1u : 0;
const uint32_t generation =
snapshot_.valid ? snapshot_.generation + 1u : 1u;
storage_write_u32(&page[8], generation);
storage_write_u32(&page[12],
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE);
storage_write_u32(&page[16], crc);
storage_write_u32(&page[kHeaderCrcOffset],
profile_storage_crc32(page, kHeaderCrcOffset));
if (!io_.program(io_.context, target_bank, 0, page, sizeof(page))) {
uint8_t header[PROFILE_STORAGE_RECORD_HEADER_SIZE]{};
memcpy(header, kRecordMagic, sizeof(kRecordMagic));
storage_write_u16(&header[4], kRecordFormatVersion);
storage_write_u16(
&header[6], CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION);
storage_write_u32(&header[8], generation);
storage_write_u32(
&header[12], CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE);
storage_write_u32(&header[16], crc);
storage_write_u32(
&header[kHeaderCrcOffset],
profile_storage_crc32(header, kHeaderCrcOffset));
if (!io_.replace_bank(
io_.context, target_bank, encoded_database,
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE, header,
sizeof(header))) {
return ProfileStorageResult::kIoError;
}
@ -231,7 +176,7 @@ const ProfileStorageSnapshot& ProfileStorage::snapshot() const {
}
bool ProfileStorage::read_header(uint8_t bank, BankHeader* output) const {
uint8_t header[kHeaderFieldsSize]{};
uint8_t header[PROFILE_STORAGE_RECORD_HEADER_SIZE]{};
if (bank >= PROFILE_STORAGE_BANK_COUNT || output == nullptr ||
!io_.read(io_.context, bank, 0, header, sizeof(header)) ||
memcmp(header, kRecordMagic, sizeof(kRecordMagic)) != 0 ||
@ -246,6 +191,12 @@ bool ProfileStorage::read_header(uint8_t bank, BankHeader* output) const {
storage_read_u32(&header[kHeaderCrcOffset])) {
return false;
}
for (size_t offset = kHeaderFieldsSize;
offset < sizeof(header); ++offset) {
if (header[offset] != 0) {
return false;
}
}
output->generation = storage_read_u32(&header[8]);
output->payload_crc = storage_read_u32(&header[16]);
return true;
@ -279,23 +230,21 @@ bool ProfileStorage::decode_bank(
database);
}
bool ProfileStorage::payload_matches(
uint8_t bank, const ControllerProfileDatabase& database) const {
bool ProfileStorage::payload_matches_encoded(
uint8_t bank, const uint8_t* payload,
size_t payload_size) const {
uint8_t stored[PROFILE_STORAGE_PAGE_SIZE]{};
uint8_t encoded[PROFILE_STORAGE_PAGE_SIZE]{};
for (size_t offset = 0;
offset < CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE;
for (size_t offset = 0; offset < payload_size;
offset += sizeof(stored)) {
const size_t size =
CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset < sizeof(stored)
? CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE - offset
payload_size - offset < sizeof(stored)
? payload_size - offset
: sizeof(stored);
if (!io_.read(io_.context, bank,
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset,
stored, size) ||
!controller_profile_database_encode_range(database, offset,
encoded, size) ||
memcmp(stored, encoded, size) != 0) {
if (!io_.read(
io_.context, bank,
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset,
stored, size) ||
memcmp(stored, &payload[offset], size) != 0) {
return false;
}
}

View file

@ -37,10 +37,11 @@ struct ProfileStorageIo {
size_t page_size = 0;
bool (*read)(void* context, uint8_t bank, size_t offset,
uint8_t* output, size_t size) = nullptr;
bool (*erase_sector)(void* context, uint8_t bank,
size_t offset) = nullptr;
bool (*program)(void* context, uint8_t bank, size_t offset,
const uint8_t* data, size_t size) = nullptr;
// Replaces one bank and verifies the payload before publishing the
// complete header page.
bool (*replace_bank)(void* context, uint8_t bank,
const uint8_t* payload, size_t payload_size,
const uint8_t* header, size_t header_size) = nullptr;
};
struct ProfileStorageSnapshot {
@ -56,7 +57,10 @@ class ProfileStorage {
public:
bool initialize(const ProfileStorageIo& io,
ControllerProfileDatabase* database);
ProfileStorageResult commit(const ControllerProfileDatabase& database);
ProfileStorageResult commit(
const ControllerProfileDatabase& database,
uint8_t* encoded_database,
size_t encoded_database_size);
const ProfileStorageSnapshot& snapshot() const;
private:
@ -69,8 +73,9 @@ private:
bool validate_payload(uint8_t bank, uint32_t expected_crc) const;
bool decode_bank(uint8_t bank,
ControllerProfileDatabase* database) const;
bool payload_matches(uint8_t bank,
const ControllerProfileDatabase& database) const;
bool payload_matches_encoded(uint8_t bank,
const uint8_t* payload,
size_t payload_size) const;
ProfileStorageIo io_{};
ProfileStorageSnapshot snapshot_{};

View file

@ -27,6 +27,7 @@ MAXIMUM_REQUEST_SIZE = 64
MAXIMUM_RESPONSE_SIZE = 293
MAXIMUM_CHUNK_SIZE = 40
USB_TIMEOUT_MS = 1000
DEFAULT_OPERATION_TIMEOUT_SECONDS = 15.0
OP_INFO = 0x01
OP_CONFIGURATION_READ = 0x10
@ -1788,8 +1789,11 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--timeout",
type=float,
default=3.0,
help="operation timeout in seconds (default: 3)",
default=DEFAULT_OPERATION_TIMEOUT_SECONDS,
help=(
"operation timeout in seconds "
f"(default: {DEFAULT_OPERATION_TIMEOUT_SECONDS:g})"
),
)
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("status", help="show firmware and configuration status")

View file

@ -320,13 +320,25 @@ int main() {
const ControllerProfileTransformResult transformed =
controller_profile_runtime_transform(
instance, snapshot, now_ms, output_mode);
ControllerProfileRuntimeProfileChangeEvent profile_change{};
if (controller_profile_runtime_take_profile_change(
instance, &profile_change)) {
ControllerProfileRuntimeProfileChangeEvent
initial_profile_indication{};
if (controller_profile_runtime_take_initial_profile_indication(
instance, &initial_profile_indication)) {
bluepad32_input_backend_queue_profile_feedback(
instance, profile_change.connection_generation,
profile_change.active_profile_number,
profile_change.policy);
instance,
initial_profile_indication.connection_generation,
initial_profile_indication.active_profile_number,
initial_profile_indication.policy);
} else {
ControllerProfileRuntimeProfileChangeEvent
profile_change{};
if (controller_profile_runtime_take_profile_change(
instance, &profile_change)) {
bluepad32_input_backend_queue_profile_feedback(
instance, profile_change.connection_generation,
profile_change.active_profile_number,
profile_change.policy);
}
}
g_user_states[instance] = transformed.state;
#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY

View file

@ -40,12 +40,15 @@ int ble_bond_count = 0;
bool flash_core_init_result = true;
int flash_core_init_calls = 0;
int core1_launch_calls = 0;
bool expect_configuration_timer_prearmed = false;
uint32_t expected_configuration_timer_add_count = 0;
int cyw43_init_calls = 0;
int uni_init_calls = 0;
int device_disconnect_calls = 0;
uni_hid_device_t* last_disconnected_device = nullptr;
uni_hid_device_t* lookup_devices[8]{};
size_t lookup_device_count = 0;
gap_connection_type_t gap_connection_types[256]{};
struct CoreStopped {};
@ -90,6 +93,12 @@ uni_hid_device_t device(
result.vendor_id = static_cast<uint16_t>(0x1000 + idx);
result.product_id = static_cast<uint16_t>(0x2000 + idx);
result.report_parser.play_dual_rumble = play_rumble;
gap_connection_types[result.conn.handle] =
protocol == UNI_BT_CONN_PROTOCOL_BR_EDR
? GAP_CONNECTION_ACL
: protocol == UNI_BT_CONN_PROTOCOL_BLE
? GAP_CONNECTION_LE
: GAP_CONNECTION_INVALID;
return result;
}
@ -170,6 +179,15 @@ void uni_bt_del_keys_unsafe() {
ble_bond_count = 0;
}
gap_connection_type_t gap_get_connection_type(
hci_con_handle_t connection_handle) {
return connection_handle <
sizeof(gap_connection_types) /
sizeof(gap_connection_types[0])
? gap_connection_types[connection_handle]
: GAP_CONNECTION_INVALID;
}
int gap_link_key_iterator_init(btstack_link_key_iterator_t* iterator) {
iterator->index = 0;
return 1;
@ -420,10 +438,22 @@ ControllerIdentity observed_profile_identities[8]{};
size_t observed_profile_identity_count = 0;
void configuration_service_prepare() {}
void configuration_service_initialize_on_storage_core() {}
void configuration_service_task_on_storage_core(uint32_t) {}
void configuration_service_task_on_storage_core(uint32_t) {
if (expect_configuration_timer_prearmed) {
require(g_configuration_timer.add_count ==
expected_configuration_timer_add_count,
"configuration work ran before its timer was rearmed");
}
}
void profile_service_prepare() {}
void profile_service_initialize_on_storage_core() {}
void profile_service_task_on_storage_core(uint32_t) {}
void profile_service_task_on_storage_core(uint32_t) {
if (expect_configuration_timer_prearmed) {
require(g_configuration_timer.add_count ==
expected_configuration_timer_add_count,
"profile work ran before its timer was rearmed");
}
}
bool profile_service_observe_identity_on_storage_core(
const ControllerIdentity& identity) {
require(observed_profile_identity_count <
@ -736,6 +766,19 @@ void test_independent_lifecycle() {
test_identity_encoding_contract();
start_pairing_backend();
uni_hid_device_t invalid_transport =
device(6, true, UNI_BT_CONN_PROTOCOL_BLE);
invalid_transport.conn.handle = 0xffff;
require(controller_identity_is_global(
identity_for_device(&invalid_transport)),
"invalid GAP handles must remain on the global identity");
uni_hid_device_t sco_transport =
device(7, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
gap_connection_types[sco_transport.conn.handle] = GAP_CONNECTION_SCO;
require(controller_identity_is_global(
identity_for_device(&sco_transport)),
"SCO links must remain on the global identity");
uni_hid_device_t aborted = device(0);
const uint32_t aborted_generation = g_slots[0].connection_generation;
platform_on_device_connected(&aborted);
@ -765,10 +808,14 @@ void test_independent_lifecycle() {
"pre-ready disconnect must restart Classic and BLE scans");
uni_hid_device_t devices[kSlotCount] = {
device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR),
device(1, true, UNI_BT_CONN_PROTOCOL_BLE),
device(2, true, UNI_BT_CONN_PROTOCOL_BLE),
device(3, true, UNI_BT_CONN_PROTOCOL_BLE)};
device(0, true, UNI_BT_CONN_PROTOCOL_NONE),
device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR),
device(2, true, UNI_BT_CONN_PROTOCOL_NONE),
device(3, true, UNI_BT_CONN_PROTOCOL_BR_EDR)};
gap_connection_types[devices[0].conn.handle] = GAP_CONNECTION_ACL;
gap_connection_types[devices[1].conn.handle] = GAP_CONNECTION_LE;
gap_connection_types[devices[2].conn.handle] = GAP_CONNECTION_LE;
gap_connection_types[devices[3].conn.handle] = GAP_CONNECTION_LE;
const bd_addr_t classic_address =
{0x10, 0x11, 0x12, 0x13, 0x14, 0x15};
const bd_addr_t resolved_connection_address =
@ -785,6 +832,18 @@ void test_independent_lifecycle() {
sizeof(created_connection_address));
memcpy(devices[3].conn.btaddr, reencrypted_connection_address,
sizeof(reencrypted_connection_address));
require(
devices[0].conn.protocol == UNI_BT_CONN_PROTOCOL_NONE &&
gap_get_connection_type(devices[0].conn.handle) ==
GAP_CONNECTION_ACL &&
devices[1].conn.protocol == UNI_BT_CONN_PROTOCOL_BR_EDR &&
gap_get_connection_type(devices[1].conn.handle) ==
GAP_CONNECTION_LE &&
devices[2].conn.protocol == UNI_BT_CONN_PROTOCOL_NONE &&
gap_get_connection_type(devices[2].conn.handle) ==
GAP_CONNECTION_LE,
"identity fixtures must expose authoritative GAP transports over "
"missing or stale cached protocols");
const bd_addr_t resolved_address =
{0x20, 0x21, 0x22, 0x23, 0x24, 0x25};
const bd_addr_t reencrypted_address =
@ -878,6 +937,18 @@ void test_independent_lifecycle() {
observed_profile_identities[2],
lifecycle_snapshots[0].identity),
"only stable ready identities must be enrolled for profiles");
size_t classic_identity_observations = 0;
for (size_t index = 0; index < observed_profile_identity_count;
++index) {
if (controller_identity_equal(
observed_profile_identities[index],
lifecycle_snapshots[0].identity)) {
++classic_identity_observations;
}
}
require(classic_identity_observations == 1,
"active GAP ACL with no cached protocol must enroll its stable "
"Classic identity exactly once");
require(baseline_connection_generations[0] ==
first_pending_generation + 1 &&
baseline_connection_generations[1] ==
@ -1021,6 +1092,15 @@ void test_independent_lifecycle() {
device(3, true, UNI_BT_CONN_PROTOCOL_BLE);
memcpy(slot_three_replacement.conn.btaddr,
devices[3].conn.btaddr, sizeof(devices[3].conn.btaddr));
require(
slot_three_replacement.conn.handle == devices[3].conn.handle &&
gap_get_connection_type(slot_three_replacement.conn.handle) ==
GAP_CONNECTION_LE &&
memcmp(slot_three_replacement.conn.btaddr,
devices[3].conn.btaddr, sizeof(devices[3].conn.btaddr)) ==
0,
"replacement isolation fixture must reuse the active BLE handle "
"and address");
require(platform_on_device_ready(&slot_three_replacement) ==
UNI_ERROR_SUCCESS,
"slot 3 replacement must bind to the freed indexed slot");
@ -1319,7 +1399,7 @@ void test_profile_feedback_scheduler() {
generations[slot] = snapshot.connection_generation;
require(devices[slot].rumble_calls == 0 &&
!g_slots[slot].profile_feedback.active &&
!g_slots[slot].profile_feedback_pending,
g_slots[slot].pending_profile_feedback_count == 0,
"initial controller/profile load scheduled confirmation feedback");
bluepad32_input_backend_queue_profile_feedback(
slot, generations[slot], static_cast<uint8_t>(slot + 1u),
@ -1404,6 +1484,12 @@ void test_profile_feedback_scheduler() {
"four-pulse confirmation did not release host rumble at 600 ms");
const int slot_zero_lightbar_calls = devices[0].lightbar_calls;
const int slot_one_player_led_calls_before_slot_zero =
devices[1].player_led_calls;
const int slot_two_lightbar_calls_before_slot_zero =
devices[2].lightbar_calls;
const int slot_three_player_led_calls_before_slot_zero =
devices[3].player_led_calls;
bluepad32_input_backend_queue_profile_feedback(
0, generations[0], 2,
ControllerProfileConfirmationPolicy::kNone);
@ -1436,7 +1522,8 @@ void test_profile_feedback_scheduler() {
kProfileLightbarPalette[1].blue &&
observed_status_led_on &&
g_slots[0].rumble_pending,
"LED policy did not set persistent profile color and first onboard blink");
"LED policy did not set transient profile color and first "
"onboard blink");
now_ms = 875;
process_rumble_timer(&g_rumble_timer);
require(!observed_status_led_on &&
@ -1453,10 +1540,24 @@ void test_profile_feedback_scheduler() {
"host rumble interrupted the final onboard off phase");
now_ms = 1100;
process_rumble_timer(&g_rumble_timer);
const SwitchRgbColor slot_zero_color =
switch_pro_get_slot_light_color(0);
require(devices[0].rumble_calls == 4 &&
devices[0].last_high == 0x42 &&
!g_slots[0].rumble_pending,
"LED-only sequence did not release deferred host rumble");
!g_slots[0].rumble_pending &&
devices[0].lightbar_calls ==
slot_zero_lightbar_calls + 2 &&
devices[0].lightbar_red == slot_zero_color.red &&
devices[0].lightbar_green == slot_zero_color.green &&
devices[0].lightbar_blue == slot_zero_color.blue &&
devices[1].player_led_calls ==
slot_one_player_led_calls_before_slot_zero &&
devices[2].lightbar_calls ==
slot_two_lightbar_calls_before_slot_zero &&
devices[3].player_led_calls ==
slot_three_player_led_calls_before_slot_zero,
"LED-only sequence did not restore its slot color in "
"isolation after the final gap");
const int slot_one_player_led_calls =
devices[1].player_led_calls;
@ -1482,13 +1583,22 @@ void test_profile_feedback_scheduler() {
kXInputHostRumbleDurationMs
? 7
: 6) &&
!g_slots[1].profile_feedback.active,
"combined three-pulse sequence did not terminate");
!g_slots[1].profile_feedback.active &&
devices[1].player_led_calls ==
slot_one_player_led_calls + 2 &&
devices[1].player_leds == (1u << 1u),
"combined three-pulse sequence did not terminate and "
"restore slot player lighting");
const int old_rumble_calls = devices[2].rumble_calls;
bluepad32_input_backend_queue_profile_feedback(
2, generations[2], 4,
ControllerProfileConfirmationPolicy::kRumbleAndLed);
bluepad32_input_backend_queue_profile_feedback(
2, generations[2], 1,
ControllerProfileConfirmationPolicy::kLed);
require(g_slots[2].pending_profile_feedback_count == 2,
"two queued profile events did not fill the bounded FIFO");
platform_on_device_disconnected(&devices[2]);
uni_hid_device_t replacement = device(2);
replacement.report_parser.set_lightbar_color = set_lightbar;
@ -1501,7 +1611,7 @@ void test_profile_feedback_scheduler() {
replacement.rumble_calls == 0 &&
replacement.lightbar_calls == 1 &&
!g_slots[2].profile_feedback.active &&
!g_slots[2].profile_feedback_pending,
g_slots[2].pending_profile_feedback_count == 0,
"slot replacement accepted stale queued profile feedback");
bluepad32_input_backend_queue_profile_feedback(
2, generations[2], 4,
@ -1534,8 +1644,12 @@ void test_profile_feedback_scheduler() {
process_rumble_timer(&g_rumble_timer);
require(!g_slots[3].profile_feedback.active &&
devices[3].rumble_calls ==
slot_three_rumble_calls,
"one-blink LED-only profile indication did not terminate cleanly");
slot_three_rumble_calls &&
devices[3].player_led_calls ==
slot_three_player_led_calls + 2 &&
devices[3].player_leds == (1u << 3u),
"one-blink LED-only profile indication did not restore "
"slot lighting cleanly");
bluepad32_input_backend_queue_profile_feedback(
3, generations[3], 4,
@ -1543,7 +1657,7 @@ void test_profile_feedback_scheduler() {
now_ms = 2000;
process_rumble_timer(&g_rumble_timer);
require(devices[3].player_leds == 0x0f,
"profile 4 did not persist four player LEDs");
"profile 4 player count was not shown during its sequence");
for (uint8_t pulse = 1; pulse <= 4; ++pulse) {
require(observed_status_led_on &&
g_slots[3].profile_feedback.on &&
@ -1565,8 +1679,122 @@ void test_profile_feedback_scheduler() {
process_rumble_timer(&g_rumble_timer);
require(!g_slots[3].profile_feedback.active &&
devices[3].rumble_calls ==
slot_three_rumble_calls,
"four-blink LED-only profile indication did not terminate");
slot_three_rumble_calls &&
devices[3].player_led_calls ==
slot_three_player_led_calls + 4 &&
devices[3].player_leds == (1u << 3u),
"four-blink LED-only profile indication did not restore "
"slot lighting");
Bluepad32SlotSnapshot replacement_snapshot{};
bluepad32_input_backend_snapshot(2, &replacement_snapshot);
const int replacement_lightbar_calls = replacement.lightbar_calls;
bluepad32_input_backend_queue_profile_feedback(
2, replacement_snapshot.connection_generation, 1,
ControllerProfileConfirmationPolicy::kLed);
now_ms = 2700;
process_rumble_timer(&g_rumble_timer);
require(replacement.lightbar_calls ==
replacement_lightbar_calls + 1,
"current-generation profile lighting was not applied");
now_ms = 2775;
process_rumble_timer(&g_rumble_timer);
platform_on_device_disconnected(&replacement);
uni_hid_device_t second_replacement = device(2);
second_replacement.report_parser.set_lightbar_color =
set_lightbar;
require(platform_on_device_ready(&second_replacement) ==
UNI_ERROR_SUCCESS &&
second_replacement.lightbar_calls == 1,
"second replacement did not receive steady slot lighting");
now_ms = 2850;
process_rumble_timer(&g_rumble_timer);
require(second_replacement.lightbar_calls == 1 &&
replacement.lightbar_calls ==
replacement_lightbar_calls + 1 &&
!g_slots[2].profile_feedback.active,
"stale final-gap restore touched a replacement connection");
const int fifo_lightbar_calls = devices[0].lightbar_calls;
const int fifo_rumble_calls = devices[0].rumble_calls;
const int isolated_slot_one_lighting =
devices[1].player_led_calls;
const int isolated_slot_two_lighting =
second_replacement.lightbar_calls;
const int isolated_slot_three_lighting =
devices[3].player_led_calls;
bluepad32_input_backend_queue_profile_feedback(
0, generations[0], 1,
ControllerProfileConfirmationPolicy::kLed);
bluepad32_input_backend_queue_profile_feedback(
0, generations[0], 2,
ControllerProfileConfirmationPolicy::kRumbleAndLed);
require(g_slots[0].pending_profile_feedback_count == 2,
"initial and switched profile events were not queued");
now_ms = 3000;
process_rumble_timer(&g_rumble_timer);
require(g_slots[0].profile_feedback.active &&
g_slots[0].profile_feedback.pulse_count == 1 &&
!g_slots[0].profile_feedback.rumble_enabled &&
g_slots[0].pending_profile_feedback_count == 1 &&
devices[0].rumble_calls == fifo_rumble_calls &&
devices[0].lightbar_calls ==
fifo_lightbar_calls + 1 &&
devices[0].lightbar_red ==
kProfileLightbarPalette[0].red,
"LED-only initial event did not run first from the FIFO");
now_ms = 3075;
process_rumble_timer(&g_rumble_timer);
now_ms = 3150;
process_rumble_timer(&g_rumble_timer);
require(g_slots[0].profile_feedback.active &&
g_slots[0].profile_feedback.pulse_count == 2 &&
g_slots[0].profile_feedback.rumble_enabled &&
g_slots[0].pending_profile_feedback_count == 0 &&
devices[0].rumble_calls == fifo_rumble_calls + 1 &&
devices[0].lightbar_calls ==
fifo_lightbar_calls + 3 &&
devices[0].lightbar_red ==
kProfileLightbarPalette[1].red,
"switched profile event did not follow initial indication "
"after its final gap");
now_ms = 3225;
process_rumble_timer(&g_rumble_timer);
now_ms = 3300;
process_rumble_timer(&g_rumble_timer);
now_ms = 3375;
process_rumble_timer(&g_rumble_timer);
now_ms = 3450;
process_rumble_timer(&g_rumble_timer);
const SwitchRgbColor final_slot_zero_color =
switch_pro_get_slot_light_color(0);
const bool stateful_host_rumble =
host_rumble_duration_ms() == kXInputHostRumbleDurationMs;
require(!g_slots[0].profile_feedback.active &&
devices[0].rumble_calls ==
fifo_rumble_calls +
(stateful_host_rumble ? 3 : 2) &&
(!stateful_host_rumble ||
(devices[0].last_high == 0x42 &&
devices[0].last_low == 0x32)) &&
devices[0].lightbar_calls ==
fifo_lightbar_calls + 4 &&
devices[0].lightbar_red ==
final_slot_zero_color.red &&
devices[0].lightbar_green ==
final_slot_zero_color.green &&
devices[0].lightbar_blue ==
final_slot_zero_color.blue &&
devices[1].player_led_calls ==
isolated_slot_one_lighting &&
second_replacement.lightbar_calls ==
isolated_slot_two_lighting &&
devices[3].player_led_calls ==
isolated_slot_three_lighting,
"ordered profile FIFO did not restore or remain slot-local");
}
void test_stateful_host_rumble_restore() {
@ -1932,6 +2160,19 @@ void test_clear_pairings() {
"pairing reset request must execute only once");
}
void test_configuration_timer_rearms_before_storage_work() {
const uint32_t adds_before = g_configuration_timer.add_count;
expected_configuration_timer_add_count = adds_before + 1;
expect_configuration_timer_prearmed = true;
process_configuration_timer(&g_configuration_timer);
expect_configuration_timer_prearmed = false;
require(g_configuration_timer.add_count ==
expected_configuration_timer_add_count &&
g_configuration_timer.timeout_ms ==
kConfigurationPollIntervalMs,
"configuration timer did not remain recurring");
}
void test_flash_core_start_contract() {
bluepad32_input_backend_init();
flash_core_init_result = false;
@ -1997,6 +2238,8 @@ int main(int argc, char** argv) {
test_host_rumble_mode_duration();
} else if (scenario == "clear-pairings") {
test_clear_pairings();
} else if (scenario == "configuration-timer") {
test_configuration_timer_rearms_before_storage_work();
} else if (scenario == "flash-core-start") {
test_flash_core_start_contract();
} else if (scenario == "flash-core-failure") {

View file

@ -5,6 +5,7 @@
struct btstack_timer_source_t {
void (*handler)(btstack_timer_source_t*);
uint32_t timeout_ms;
uint32_t add_count;
};
inline void btstack_run_loop_set_timer_handler(
@ -18,6 +19,8 @@ inline void btstack_run_loop_set_timer(btstack_timer_source_t* timer,
timer->timeout_ms = timeout_ms;
}
inline void btstack_run_loop_add_timer(btstack_timer_source_t*) {}
inline void btstack_run_loop_add_timer(btstack_timer_source_t* timer) {
++timer->add_count;
}
uint32_t btstack_run_loop_get_time_ms();
inline void btstack_run_loop_execute() {}

View file

@ -21,6 +21,13 @@ enum hci_link_type_t {
HCI_LINK_TYPE_ACL = 1,
};
enum gap_connection_type_t {
GAP_CONNECTION_INVALID,
GAP_CONNECTION_ACL,
GAP_CONNECTION_SCO,
GAP_CONNECTION_LE,
};
struct btstack_link_key_iterator_t {
int index;
};
@ -174,6 +181,8 @@ void uni_bt_bredr_scan_stop();
void uni_bt_le_scan_start();
void uni_bt_le_scan_stop();
void uni_bt_del_keys_unsafe();
gap_connection_type_t gap_get_connection_type(
hci_con_handle_t connection_handle);
int gap_link_key_iterator_init(btstack_link_key_iterator_t* iterator);
int gap_link_key_iterator_get_next(
btstack_link_key_iterator_t* iterator, bd_addr_t address,

View file

@ -109,6 +109,15 @@ ControllerProfileRuntimeProfileChangeEvent take_profile_change(
return event;
}
ControllerProfileRuntimeProfileChangeEvent
take_initial_profile_indication(uint8_t slot, bool* available) {
ControllerProfileRuntimeProfileChangeEvent event{};
*available =
controller_profile_runtime_take_initial_profile_indication(
slot, &event);
return event;
}
constexpr uint16_t logical_button_bit(
ControllerProfileLogicalButton button) {
return static_cast<uint16_t>(
@ -285,6 +294,101 @@ void test_analog_thresholds_rumble_and_local_confirmation() {
"local confirmation was scaled or lost its profile policy");
}
void test_initial_profile_indication_once_per_connection() {
prepare_profiles();
std::array<Bluepad32SlotSnapshot,
CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT>
snapshots{};
for (uint8_t slot = 0;
slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) {
rows[slot].active_profile = slot;
rows[slot].profiles[slot].confirmation_policy =
ControllerProfileConfirmationPolicy::kRumbleAndLed;
snapshots[slot] = make_snapshot(slot);
(void)runtime_transform(slot, snapshots[slot]);
}
constexpr uint8_t kTakeOrder[CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT] = {
2, 0, 3, 1};
for (const uint8_t slot : kTakeOrder) {
bool available = false;
const ControllerProfileRuntimeProfileChangeEvent event =
take_initial_profile_indication(slot, &available);
require(available &&
event.connection_generation == 1 &&
event.database_generation == database_generation &&
event.active_profile_number == slot + 1u &&
event.policy ==
ControllerProfileConfirmationPolicy::kLed,
"initial profile 1..4 indication was not isolated, "
"LED-only, or generation-bound");
(void)take_initial_profile_indication(slot, &available);
require(!available,
"initial profile indication repeated without a new "
"connection");
(void)take_profile_change(slot, &available);
require(!available,
"initial profile resolution published switch feedback");
}
++database_generation;
for (uint8_t slot = 0;
slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) {
(void)runtime_transform(slot, snapshots[slot]);
bool available = true;
(void)take_initial_profile_indication(slot, &available);
require(!available,
"database refresh repeated initial profile indication");
}
rows[2].identity = controller_identity_global();
snapshots[0].identity = controller_identity_global();
snapshots[0].connection_generation = 2;
(void)runtime_transform(0, snapshots[0]);
bool available = false;
const ControllerProfileRuntimeProfileChangeEvent unresolved_event =
take_initial_profile_indication(0, &available);
require(available &&
unresolved_event.connection_generation == 2 &&
unresolved_event.active_profile_number == 3,
"first committed unresolved-identity profile was not "
"indicated");
snapshots[0].identity = rows[1].identity;
(void)runtime_transform(0, snapshots[0]);
(void)take_initial_profile_indication(0, &available);
require(!available,
"identity promotion repeated initial profile indication");
snapshots[0].connection_generation = 3;
(void)runtime_transform(0, snapshots[0]);
const ControllerProfileRuntimeProfileChangeEvent reconnect_event =
take_initial_profile_indication(0, &available);
require(available &&
reconnect_event.connection_generation == 3 &&
reconnect_event.active_profile_number == 2 &&
reconnect_event.policy ==
ControllerProfileConfirmationPolicy::kLed,
"true reconnection did not publish one fresh LED-only "
"profile indication");
rows[1].profiles[1].confirmation_policy =
ControllerProfileConfirmationPolicy::kRumble;
snapshots[1].connection_generation = 2;
(void)runtime_transform(1, snapshots[1]);
(void)take_initial_profile_indication(1, &available);
require(!available,
"rumble-only initial policy disturbed controller feedback");
rows[3].profiles[3].confirmation_policy =
ControllerProfileConfirmationPolicy::kNone;
snapshots[3].connection_generation = 2;
(void)runtime_transform(3, snapshots[3]);
(void)take_initial_profile_indication(3, &available);
require(!available,
"disabled initial policy disturbed controller feedback");
}
void test_default_switching_retry_commit_and_feedback() {
prepare_profiles();
ControllerProfile& initial_profile = rows[0].profiles[0];
@ -782,6 +886,7 @@ int main() {
test_four_slot_cache_and_unchanged_generation();
test_activation_disconnect_and_default_preservation();
test_analog_thresholds_rumble_and_local_confirmation();
test_initial_profile_indication_once_per_connection();
test_default_switching_retry_commit_and_feedback();
test_identity_promotion_preserves_held_switching();
test_switching_uses_pre_hotkey_buttons_only();

View file

@ -12,6 +12,7 @@ namespace {
struct FakeFlash {
uint8_t bytes[PROFILE_STORAGE_BANK_COUNT][PROFILE_STORAGE_BANK_SIZE];
int bank_replacements = 0;
};
FakeFlash flash{};
@ -35,33 +36,27 @@ bool fake_read(void* context, uint8_t bank, size_t offset,
return true;
}
bool fake_erase_sector(void* context, uint8_t bank, size_t offset) {
bool fake_replace_bank(void* context, uint8_t bank,
const uint8_t* payload, size_t payload_size,
const uint8_t* header, size_t header_size) {
auto* storage = static_cast<FakeFlash*>(context);
if (bank >= PROFILE_STORAGE_BANK_COUNT ||
offset % PROFILE_STORAGE_SECTOR_SIZE != 0 ||
offset > PROFILE_STORAGE_BANK_SIZE ||
PROFILE_STORAGE_SECTOR_SIZE > PROFILE_STORAGE_BANK_SIZE - offset) {
if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr ||
header == nullptr ||
payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE) {
return false;
}
memset(&storage->bytes[bank][offset], 0xff,
PROFILE_STORAGE_SECTOR_SIZE);
return true;
}
bool fake_program(void* context, uint8_t bank, size_t offset,
const uint8_t* data, size_t size) {
auto* storage = static_cast<FakeFlash*>(context);
if (bank >= PROFILE_STORAGE_BANK_COUNT || data == nullptr ||
size != PROFILE_STORAGE_PAGE_SIZE ||
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
offset > PROFILE_STORAGE_BANK_SIZE ||
size > PROFILE_STORAGE_BANK_SIZE - offset) {
return false;
}
for (size_t index = 0; index < size; ++index) {
storage->bytes[bank][offset + index] &= data[index];
}
return true;
++storage->bank_replacements;
memset(storage->bytes[bank], 0xff, PROFILE_STORAGE_BANK_SIZE);
memcpy(
&storage->bytes[bank][PROFILE_STORAGE_RECORD_HEADER_SIZE],
payload, payload_size);
memcpy(storage->bytes[bank], header, header_size);
return memcmp(
&storage->bytes[bank][
PROFILE_STORAGE_RECORD_HEADER_SIZE],
payload, payload_size) == 0 &&
memcmp(storage->bytes[bank], header, header_size) == 0;
}
ProfileStorageIo fake_io() {
@ -71,8 +66,7 @@ ProfileStorageIo fake_io() {
PROFILE_STORAGE_SECTOR_SIZE,
PROFILE_STORAGE_PAGE_SIZE,
fake_read,
fake_erase_sector,
fake_program,
fake_replace_bank,
};
}
@ -305,6 +299,105 @@ void test_host_and_controller_mutations_are_serialized() {
"controller activation did not publish while preserving host-visible status");
}
void test_completed_write_then_dirty_identity_activation() {
const int replacements_before = flash.bank_replacements;
const ControllerIdentity global = controller_identity_global();
constexpr uint8_t kWrittenProfileIndex = 1;
ControllerProfile customized =
controller_profile_default(global, kWrittenProfileIndex);
customized.strong_rumble_scale = 31;
customized.weak_rumble_scale = 47;
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(customized, encoded, sizeof(encoded)),
"sequential mutation fixture profile did not encode");
constexpr uint32_t kWriteTransactionId = 0x31415926;
require(profile_service_begin(
kWriteTransactionId, global, kWrittenProfileIndex,
CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded),
profile_storage_crc32(encoded, sizeof(encoded))) ==
ConfigurationTransactionStatus::kReceiving &&
profile_service_append(kWriteTransactionId, 0, encoded,
sizeof(encoded)) ==
ConfigurationTransactionStatus::kReceiving &&
profile_service_commit(kWriteTransactionId) ==
ConfigurationTransactionStatus::kPending,
"sequential profile write did not reach pending");
profile_service_task_on_storage_core(6000);
const ProfileServiceTransactionSnapshot written =
transaction_snapshot();
require(written.transaction.transaction_id == kWriteTransactionId &&
written.transaction.status ==
ConfigurationTransactionStatus::kCommitted &&
flash.bank_replacements == replacements_before + 1,
"completed write lost correlation or used multiple bank replacements");
ControllerIdentity connected{};
connected.stable = true;
connected.transport = ControllerTransport::kClassic;
connected.address[0] = 0x10;
connected.address[1] = 0x20;
connected.address[2] = 0x30;
connected.address[3] = 0x40;
connected.address[4] = 0x50;
connected.address[5] = 0x60;
connected.vendor_id = 0x1234;
connected.product_id = 0xabcd;
require(profile_service_observe_identity_on_storage_core(connected),
"connected identity did not enter the dirty database");
constexpr uint32_t kActivateTransactionId = 0x27182818;
constexpr uint8_t kActivatedProfileIndex = 2;
require(profile_service_activate(
kActivateTransactionId, connected,
kActivatedProfileIndex) ==
ConfigurationTransactionStatus::kPending,
"activation after completed write did not reach pending");
const ProfileServiceTransactionSnapshot pending =
transaction_snapshot();
require(pending.transaction.transaction_id ==
kActivateTransactionId &&
pending.transaction.status ==
ConfigurationTransactionStatus::kPending &&
pending.transaction.stored_generation == 0 &&
pending.transaction.stored_crc == 0,
"pending activation was not correlated to its own transaction");
profile_service_task_on_storage_core(7000);
const ProfileServiceTransactionSnapshot activated =
transaction_snapshot();
require(activated.transaction.transaction_id ==
kActivateTransactionId &&
activated.transaction.status ==
ConfigurationTransactionStatus::kCommitted &&
activated.transaction.stored_generation ==
written.transaction.stored_generation + 1 &&
flash.bank_replacements == replacements_before + 2,
"activation did not complete as one next correlated bank replacement");
const ControllerProfileDatabase recovered = reload_database(
activated, activated.transaction.stored_generation);
const ControllerProfileDatabaseEntry* connected_entry =
controller_profile_database_find(recovered, connected);
require(connected_entry != nullptr &&
connected_entry->active_profile ==
kActivatedProfileIndex &&
recovered.fallback_profiles[kWrittenProfileIndex]
.strong_rumble_scale ==
customized.strong_rumble_scale &&
recovered.fallback_profiles[kWrittenProfileIndex]
.weak_rumble_scale ==
customized.weak_rumble_scale,
"activation did not atomically persist the dirty identity and prior write");
const ProfileServiceActiveProfileSnapshot active =
active_profile_snapshot(connected);
require(active.valid &&
active.metadata.generation ==
activated.transaction.stored_generation &&
active.profile_index == kActivatedProfileIndex,
"completed activation did not publish the dirty identity");
}
} // namespace
ProfileStorageIo pico_profile_storage_io() {
@ -314,5 +407,6 @@ ProfileStorageIo pico_profile_storage_io() {
int main() {
test_pending_commands_are_not_decoded_as_profile_writes();
test_host_and_controller_mutations_are_serialized();
test_completed_write_then_dirty_identity_activation();
return 0;
}

View file

@ -14,14 +14,17 @@ struct FakeFlash {
int successful_programs = 0;
int fail_after_programs = -1;
bool corrupt_next_program = false;
int corrupt_header_padding_offset = -1;
bool fail_reads_after_header_program = false;
bool header_programmed = false;
int erase_count = 0;
int bank_replacements = 0;
};
FakeFlash flash{};
ControllerProfileDatabase database{};
ControllerProfileDatabase recovered_database{};
uint8_t encoded_database[CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE]{};
void require(bool condition, const char* message) {
if (!condition) {
@ -35,9 +38,11 @@ void erase_all() {
flash.successful_programs = 0;
flash.fail_after_programs = -1;
flash.corrupt_next_program = false;
flash.corrupt_header_padding_offset = -1;
flash.fail_reads_after_header_program = false;
flash.header_programmed = false;
flash.erase_count = 0;
flash.bank_replacements = 0;
}
bool fake_read(void* context, uint8_t bank, size_t offset,
@ -56,47 +61,68 @@ bool fake_read(void* context, uint8_t bank, size_t offset,
return true;
}
bool fake_erase_sector(void* context, uint8_t bank, size_t offset) {
bool fake_replace_bank(void* context, uint8_t bank,
const uint8_t* payload, size_t payload_size,
const uint8_t* header, size_t header_size) {
auto* storage = static_cast<FakeFlash*>(context);
if (bank >= PROFILE_STORAGE_BANK_COUNT ||
offset % PROFILE_STORAGE_SECTOR_SIZE != 0 ||
offset > PROFILE_STORAGE_BANK_SIZE ||
PROFILE_STORAGE_SECTOR_SIZE >
PROFILE_STORAGE_BANK_SIZE - offset) {
if (bank >= PROFILE_STORAGE_BANK_COUNT || payload == nullptr ||
header == nullptr ||
payload_size != CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE ||
header_size != PROFILE_STORAGE_RECORD_HEADER_SIZE) {
return false;
}
++storage->erase_count;
memset(&storage->bytes[bank][offset], 0xff,
PROFILE_STORAGE_SECTOR_SIZE);
return true;
}
bool fake_program(void* context, uint8_t bank, size_t offset,
const uint8_t* data, size_t size) {
auto* storage = static_cast<FakeFlash*>(context);
if (bank >= PROFILE_STORAGE_BANK_COUNT || data == nullptr ||
size != PROFILE_STORAGE_PAGE_SIZE ||
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
offset > PROFILE_STORAGE_BANK_SIZE ||
size > PROFILE_STORAGE_BANK_SIZE - offset) {
++storage->bank_replacements;
memset(storage->bytes[bank], 0xff, PROFILE_STORAGE_BANK_SIZE);
storage->erase_count +=
static_cast<int>(PROFILE_STORAGE_SECTORS_PER_BANK);
uint8_t final_page[PROFILE_STORAGE_PAGE_SIZE]{};
for (size_t offset = 0; offset < payload_size;
offset += PROFILE_STORAGE_PAGE_SIZE) {
if (storage->fail_after_programs >= 0 &&
storage->successful_programs >=
storage->fail_after_programs) {
return false;
}
const size_t remaining = payload_size - offset;
const uint8_t* page = &payload[offset];
if (remaining < PROFILE_STORAGE_PAGE_SIZE) {
memcpy(final_page, page, remaining);
page = final_page;
}
memcpy(
&storage->bytes[bank][
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset],
page, PROFILE_STORAGE_PAGE_SIZE);
if (storage->corrupt_next_program) {
storage->bytes[bank][
PROFILE_STORAGE_RECORD_HEADER_SIZE + offset] ^= 1;
storage->corrupt_next_program = false;
}
++storage->successful_programs;
}
if (memcmp(
&storage->bytes[bank][PROFILE_STORAGE_RECORD_HEADER_SIZE],
payload, payload_size) != 0) {
return false;
}
if (storage->fail_after_programs >= 0 &&
storage->successful_programs >= storage->fail_after_programs) {
return false;
}
for (size_t index = 0; index < size; ++index) {
storage->bytes[bank][offset + index] &= data[index];
}
if (storage->corrupt_next_program) {
storage->bytes[bank][offset] ^= 1;
storage->corrupt_next_program = false;
}
if (offset == 0) {
storage->header_programmed = true;
}
memcpy(storage->bytes[bank], header, header_size);
storage->header_programmed = true;
++storage->successful_programs;
return true;
if (storage->corrupt_header_padding_offset >= 24 &&
static_cast<size_t>(
storage->corrupt_header_padding_offset) < header_size) {
storage->bytes[bank][
static_cast<size_t>(
storage->corrupt_header_padding_offset)] ^= 1;
}
return memcmp(storage->bytes[bank], header, header_size) == 0;
}
ProfileStorageIo fake_io() {
@ -106,8 +132,7 @@ ProfileStorageIo fake_io() {
PROFILE_STORAGE_SECTOR_SIZE,
PROFILE_STORAGE_PAGE_SIZE,
fake_read,
fake_erase_sector,
fake_program,
fake_replace_bank,
};
}
@ -196,6 +221,15 @@ void install_legacy_database_bank_fixture() {
fixture_write_u32(&record[20], profile_storage_crc32(record, 20));
}
void test_initialize_requires_batch_replacement() {
erase_all();
ProfileStorageIo io = fake_io();
io.replace_bank = nullptr;
ProfileStorage storage;
require(!storage.initialize(io, &database),
"profile storage initialized without bank replacement");
}
void test_two_bank_recovery() {
erase_all();
controller_profile_database_default(&database);
@ -203,16 +237,22 @@ void test_two_bank_recovery() {
require(storage.initialize(fake_io(), &database) &&
!storage.snapshot().valid,
"erased profile storage did not initialize empty");
require(storage.commit(database) == ProfileStorageResult::kOk &&
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk &&
storage.snapshot().generation == 1,
"first profile database did not commit");
const int programs_after_first = flash.successful_programs;
require(storage.commit(database) == ProfileStorageResult::kUnchanged &&
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kUnchanged &&
flash.successful_programs == programs_after_first,
"unchanged profile database consumed flash writes");
database.fallback_profiles[0].button_map[0] = 1;
require(storage.commit(database) == ProfileStorageResult::kOk &&
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk &&
storage.snapshot().generation == 2,
"second profile database generation did not commit");
ProfileStorage reloaded;
@ -235,11 +275,15 @@ void test_interrupted_commit_retains_previous_bank() {
controller_profile_database_default(&database);
ProfileStorage storage;
require(storage.initialize(fake_io(), &database) &&
storage.commit(database) == ProfileStorageResult::kOk,
storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk,
"interruption baseline did not commit");
database.fallback_profiles[1].button_map[2] = 3;
flash.fail_after_programs = flash.successful_programs + 1;
require(storage.commit(database) == ProfileStorageResult::kIoError,
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kIoError,
"interrupted profile write reported success");
flash.fail_after_programs = -1;
@ -255,13 +299,17 @@ void test_successful_header_program_is_commit_point() {
controller_profile_database_default(&database);
ProfileStorage storage;
require(storage.initialize(fake_io(), &database) &&
storage.commit(database) == ProfileStorageResult::kOk,
storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk,
"commit-point baseline did not commit");
database.fallback_profiles[1].strong_rumble_scale = 17;
flash.header_programmed = false;
flash.fail_reads_after_header_program = true;
require(storage.commit(database) == ProfileStorageResult::kOk &&
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk &&
storage.snapshot().generation == 2,
"successful header program was rolled back by a later read");
@ -279,7 +327,9 @@ void test_payload_corruption_prevents_header_publication() {
controller_profile_database_default(&database);
ProfileStorage storage;
require(storage.initialize(fake_io(), &database) &&
storage.commit(database) == ProfileStorageResult::kOk,
storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk,
"corruption baseline did not commit");
const ProfileStorageSnapshot previous = storage.snapshot();
const uint8_t target_bank = previous.active_bank ^ 1u;
@ -291,7 +341,9 @@ void test_payload_corruption_prevents_header_publication() {
database.fallback_profiles[1].button_map[2] = 3;
flash.corrupt_next_program = true;
require(storage.commit(database) == ProfileStorageResult::kIoError &&
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kIoError &&
flash.successful_programs ==
programs_before_corruption + kPayloadProgramCount,
"corrupt payload programming reached the header program");
@ -314,6 +366,115 @@ void test_payload_corruption_prevents_header_publication() {
"headerless corrupt payload was recovered");
}
void test_batched_bank_replacement_is_one_atomic_operation() {
erase_all();
controller_profile_database_default(&database);
ProfileStorage storage;
constexpr int kPayloadProgramCount =
(CONTROLLER_PROFILE_DATABASE_ENCODED_SIZE +
PROFILE_STORAGE_PAGE_SIZE - 1) /
PROFILE_STORAGE_PAGE_SIZE;
require(storage.initialize(fake_io(), &database) &&
storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk &&
flash.bank_replacements == 1 &&
flash.erase_count == static_cast<int>(
PROFILE_STORAGE_SECTORS_PER_BANK) &&
flash.successful_programs ==
kPayloadProgramCount + 1,
"batched commit did not replace one bank in one operation");
const ProfileStorageSnapshot previous = storage.snapshot();
const uint8_t target_bank = previous.active_bank ^ 1u;
const int programs_before_corruption = flash.successful_programs;
database.fallback_profiles[1].button_map[2] = 3;
flash.corrupt_next_program = true;
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kIoError &&
flash.bank_replacements == 2 &&
flash.successful_programs ==
programs_before_corruption +
kPayloadProgramCount,
"corrupt batched payload reached header publication");
for (size_t index = 0; index < PROFILE_STORAGE_RECORD_HEADER_SIZE;
++index) {
require(flash.bytes[target_bank][index] == 0xff,
"failed batched replacement published a header");
}
require(storage.snapshot().generation == previous.generation &&
storage.snapshot().payload_crc ==
previous.payload_crc &&
storage.snapshot().active_bank ==
previous.active_bank,
"failed batched replacement changed the committed snapshot");
ProfileStorage recovered;
require(recovered.initialize(fake_io(), &recovered_database) &&
recovered.snapshot().generation ==
previous.generation &&
recovered.snapshot().active_bank ==
previous.active_bank &&
recovered_database.fallback_profiles[1]
.button_map[2] == 2,
"headerless batched payload replaced the prior bank");
}
void test_header_padding_corruption_fails_commit_and_recovery() {
erase_all();
controller_profile_database_default(&database);
ProfileStorage storage;
require(storage.initialize(fake_io(), &database) &&
storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk,
"header padding baseline did not commit");
const ProfileStorageSnapshot previous = storage.snapshot();
const uint8_t previous_scale =
database.fallback_profiles[1].strong_rumble_scale;
database.fallback_profiles[1].strong_rumble_scale = 17;
for (size_t offset = 24;
offset < PROFILE_STORAGE_RECORD_HEADER_SIZE; ++offset) {
flash.corrupt_header_padding_offset =
static_cast<int>(offset);
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kIoError,
"corrupt header padding did not fail the commit");
ProfileStorage recovered;
require(recovered.initialize(fake_io(), &recovered_database) &&
recovered.snapshot().generation ==
previous.generation &&
recovered.snapshot().active_bank ==
previous.active_bank &&
recovered_database.fallback_profiles[1]
.strong_rumble_scale == previous_scale,
"corrupt header padding was accepted on recovery");
}
flash.corrupt_header_padding_offset = -1;
require(storage.commit(database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk,
"valid full header page did not commit");
for (size_t offset = 24;
offset < PROFILE_STORAGE_RECORD_HEADER_SIZE; ++offset) {
require(
flash.bytes[storage.snapshot().active_bank][offset] == 0,
"valid committed header contained nonzero padding");
}
ProfileStorage recovered;
require(recovered.initialize(fake_io(), &recovered_database) &&
recovered.snapshot().generation ==
previous.generation + 1u &&
recovered_database.fallback_profiles[1]
.strong_rumble_scale == 17,
"valid full header page was rejected on recovery");
}
void test_legacy_database_bank_migration() {
install_legacy_database_bank_fixture();
ProfileStorage storage;
@ -395,7 +556,8 @@ void test_legacy_database_bank_migration() {
"legacy entry raw trigger ranges were not preserved");
recovered_database.fallback_profiles[2].weak_rumble_scale = 17;
require(storage.commit(recovered_database) ==
require(storage.commit(recovered_database, encoded_database,
sizeof(encoded_database)) ==
ProfileStorageResult::kOk &&
storage.snapshot().active_bank == 0 &&
storage.snapshot().generation == 42,
@ -485,9 +647,12 @@ void test_legacy_database_bank_migration() {
} // namespace
int main() {
test_two_bank_recovery();
test_initialize_requires_batch_replacement();
test_interrupted_commit_retains_previous_bank();
test_successful_header_program_is_commit_point();
test_payload_corruption_prevents_header_publication();
test_batched_bank_replacement_is_one_atomic_operation();
test_header_padding_corruption_fails_commit_and_recovery();
test_legacy_database_bank_migration();
return 0;
}

View file

@ -49,6 +49,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
"analog-state",
"rumble-mode",
"clear-pairings",
"configuration-timer",
"flash-core-start",
"flash-core-failure",
):

208
tests/test_build.py Normal file
View file

@ -0,0 +1,208 @@
import importlib.util
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SPEC = importlib.util.spec_from_file_location("switch_pico_build", ROOT / "build.py")
build_script = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(build_script)
def make_sdk(path):
path.mkdir(parents=True)
(path / "pico_sdk_init.cmake").touch()
return path
def make_toolchain(path):
bin_dir = path / "bin"
bin_dir.mkdir(parents=True)
(bin_dir / build_script.TOOLCHAIN_COMPILER).touch()
return path
def no_compiler(_name, *, path):
return None
def test_parse_cmake_cache_ignores_comments_and_malformed_lines(tmp_path):
cache = tmp_path / "CMakeCache.txt"
cache.write_text(
"// comment\n"
"# internal comment\n"
"PICO_SDK_PATH:PATH=/sdk\n"
"VALUE_WITH_EQUALS:STRING=left=right\n"
"malformed\n",
encoding="utf-8",
)
assert build_script.parse_cmake_cache(cache) == {
"PICO_SDK_PATH": "/sdk",
"VALUE_WITH_EQUALS": "left=right",
}
def test_explicit_environment_wins_over_cache_and_fallback(tmp_path):
explicit_sdk = make_sdk(tmp_path / "explicit-sdk")
explicit_toolchain = make_toolchain(tmp_path / "explicit-toolchain")
cached_sdk = make_sdk(tmp_path / "cached-sdk")
cached_toolchain = make_toolchain(tmp_path / "cached-toolchain")
fallback_sdk = make_sdk(tmp_path / "fallback-sdk")
fallback_toolchain = make_toolchain(tmp_path / "fallback-toolchain")
cache = tmp_path / "build" / "CMakeCache.txt"
cache.parent.mkdir()
cache.write_text(
f"PICO_SDK_PATH:PATH={cached_sdk}\n"
f"PICO_TOOLCHAIN_PATH:PATH={cached_toolchain}\n",
encoding="utf-8",
)
environ = {
"PATH": "",
"PICO_SDK_PATH": str(explicit_sdk),
"PICO_TOOLCHAIN_PATH": str(explicit_toolchain),
}
updates = build_script.configure_pico_environment(
environ=environ,
cache_paths=[cache],
sdk_candidates=[("fallback", fallback_sdk)],
toolchain_candidates=[("fallback", fallback_toolchain)],
which=no_compiler,
)
assert updates == {}
assert environ["PICO_SDK_PATH"] == str(explicit_sdk)
assert environ["PICO_TOOLCHAIN_PATH"] == str(explicit_toolchain)
@pytest.mark.parametrize(
("variable", "invalid", "expected"),
[
("PICO_SDK_PATH", "stale-sdk", "pico_sdk_init.cmake"),
(
"PICO_TOOLCHAIN_PATH",
"stale-toolchain",
f"bin/{build_script.TOOLCHAIN_COMPILER}",
),
],
)
def test_stale_explicit_environment_is_an_error(
tmp_path, variable, invalid, expected
):
sdk = make_sdk(tmp_path / "sdk")
toolchain = make_toolchain(tmp_path / "toolchain")
environ = {
"PATH": "",
"PICO_SDK_PATH": str(sdk),
"PICO_TOOLCHAIN_PATH": str(toolchain),
variable: str(tmp_path / invalid),
}
with pytest.raises(build_script.BuildEnvironmentError) as error:
build_script.configure_pico_environment(
environ=environ,
cache_paths=[],
sdk_candidates=[],
toolchain_candidates=[],
which=no_compiler,
)
assert variable in str(error.value)
assert expected in str(error.value)
def test_cache_paths_win_over_fallbacks(tmp_path, capsys):
cached_sdk = make_sdk(tmp_path / "cached-sdk")
cached_toolchain = make_toolchain(tmp_path / "cached-toolchain")
fallback_sdk = make_sdk(tmp_path / "fallback-sdk")
fallback_toolchain = make_toolchain(tmp_path / "fallback-toolchain")
cache = tmp_path / "build" / "CMakeCache.txt"
cache.parent.mkdir()
cache.write_text(
f"PICO_SDK_PATH:PATH={cached_sdk}\n"
f"CMAKE_C_COMPILER:FILEPATH="
f"{cached_toolchain / 'bin' / build_script.TOOLCHAIN_COMPILER}\n",
encoding="utf-8",
)
environ = {"PATH": ""}
updates = build_script.configure_pico_environment(
environ=environ,
cache_paths=[cache],
sdk_candidates=[("fallback", fallback_sdk)],
toolchain_candidates=[("fallback", fallback_toolchain)],
which=no_compiler,
)
assert updates == {
"PICO_SDK_PATH": str(cached_sdk),
"PICO_TOOLCHAIN_PATH": str(cached_toolchain),
}
output = capsys.readouterr().out
assert f"Auto-detected PICO_SDK_PATH={cached_sdk}" in output
assert f"Auto-detected PICO_TOOLCHAIN_PATH={cached_toolchain}" in output
assert str(cache) in output
def test_project_local_fallback_ignores_stale_cache(tmp_path, monkeypatch):
project_build = tmp_path / "build"
local_sdk = make_sdk(project_build / "_deps" / "pico_sdk-src")
local_toolchain = make_toolchain(project_build / "toolchain")
cache = tmp_path / "old-build" / "CMakeCache.txt"
cache.parent.mkdir()
cache.write_text(
f"PICO_SDK_PATH:PATH={tmp_path / 'missing-sdk'}\n"
f"PICO_TOOLCHAIN_PATH:PATH={tmp_path / 'missing-toolchain'}\n",
encoding="utf-8",
)
monkeypatch.setattr(build_script, "BUILD_DIR", project_build)
environ = {"PATH": ""}
updates = build_script.configure_pico_environment(
environ=environ,
cache_paths=[cache],
which=no_compiler,
)
assert updates == {
"PICO_SDK_PATH": str(local_sdk),
"PICO_TOOLCHAIN_PATH": str(local_toolchain),
}
def test_compiler_on_path_avoids_toolchain_override(tmp_path):
sdk = make_sdk(tmp_path / "sdk")
compiler = tmp_path / "path-bin" / build_script.TOOLCHAIN_COMPILER
environ = {"PATH": str(compiler.parent), "PICO_SDK_PATH": str(sdk)}
updates = build_script.configure_pico_environment(
environ=environ,
cache_paths=[],
sdk_candidates=[],
toolchain_candidates=[],
which=lambda name, *, path: str(compiler),
)
assert updates == {}
assert "PICO_TOOLCHAIN_PATH" not in environ
def test_missing_dependencies_name_only_actionable_overrides():
environ = {"PATH": ""}
with pytest.raises(build_script.BuildEnvironmentError) as error:
build_script.configure_pico_environment(
environ=environ,
cache_paths=[],
sdk_candidates=[],
toolchain_candidates=[],
which=no_compiler,
)
assert str(error.value) == (
"Missing build prerequisite(s): Pico SDK (set PICO_SDK_PATH); "
"Arm GNU toolchain (set PICO_TOOLCHAIN_PATH or add "
"arm-none-eabi-gcc to PATH)."
)

View file

@ -658,6 +658,15 @@ def test_trigger_threshold_uses_transformed_output_domain() -> None:
)
def test_default_timeout_covers_batched_profile_commit() -> None:
args = config_manager.build_parser().parse_args(["profiles", "list"])
assert (
args.timeout
== config_manager.DEFAULT_OPERATION_TIMEOUT_SECONDS
== 15.0
)
def test_profile_list_select_read_and_chunked_commit() -> None:
device = FakeDevice()
entries = config_manager.list_profiles(device)