Add Switch 2 wake capture and replay
This commit is contained in:
parent
8818f1f6e9
commit
6f58d345e0
22 changed files with 1436 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -14,3 +14,4 @@ __pycache__
|
||||||
pytest-*/*
|
pytest-*/*
|
||||||
build-*/*
|
build-*/*
|
||||||
.ignore
|
.ignore
|
||||||
|
src/firmware/platform/pico/switch2_wake_config.h
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
||||||
${SWITCH_PICO_SOURCE_DIR}/configuration/configuration_transaction.cpp
|
${SWITCH_PICO_SOURCE_DIR}/configuration/configuration_transaction.cpp
|
||||||
${SWITCH_PICO_SOURCE_DIR}/core/controller_identity.cpp
|
${SWITCH_PICO_SOURCE_DIR}/core/controller_identity.cpp
|
||||||
${SWITCH_PICO_SOURCE_DIR}/input/bluepad32_input_backend.cpp
|
${SWITCH_PICO_SOURCE_DIR}/input/bluepad32_input_backend.cpp
|
||||||
|
${SWITCH_PICO_SOURCE_DIR}/input/switch2_wake.cpp
|
||||||
${SWITCH_PICO_SOURCE_DIR}/platform/pico/bootsel_pairing_button.cpp
|
${SWITCH_PICO_SOURCE_DIR}/platform/pico/bootsel_pairing_button.cpp
|
||||||
${SWITCH_PICO_SOURCE_DIR}/platform/pico/pico_configuration_storage.cpp
|
${SWITCH_PICO_SOURCE_DIR}/platform/pico/pico_configuration_storage.cpp
|
||||||
${SWITCH_PICO_SOURCE_DIR}/platform/pico/pico_profile_storage.cpp
|
${SWITCH_PICO_SOURCE_DIR}/platform/pico/pico_profile_storage.cpp
|
||||||
|
|
|
||||||
96
README.md
96
README.md
|
|
@ -66,6 +66,102 @@ The default `python3 build.py` command and `firmware/switch-pico.*` artifacts re
|
||||||
|
|
||||||
Both `build.py --aio` and direct AIO CMake configuration copy the pinned Bluepad32 source into the active build directory and apply `patches/bluepad32-sdl3-imu.patch` there before compiling. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. The `external/bluepad32` submodule remains pristine; patch or source-revision drift fails configuration.
|
Both `build.py --aio` and direct AIO CMake configuration copy the pinned Bluepad32 source into the active build directory and apply `patches/bluepad32-sdl3-imu.patch` there before compiling. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. The `external/bluepad32` submodule remains pristine; patch or source-revision drift fails configuration.
|
||||||
|
|
||||||
|
### Switch 2 wake from L + R + Home, PS, or Xbox
|
||||||
|
|
||||||
|
The AIO firmware can wake a sleeping Switch 2 when a connected controller's
|
||||||
|
physical **L + R + System** chord becomes held: L + R + Home on
|
||||||
|
Nintendo-style controllers, L1 + R1 + PS on PlayStation controllers, or
|
||||||
|
LB + RB + Xbox on Xbox controllers. Plain Home, PS, or Xbox remains a normal
|
||||||
|
button and does not start wake advertising. Setup needs one wake advertisement
|
||||||
|
captured from a Joy-Con 2 already paired with that Switch 2. The generated
|
||||||
|
configuration is console-specific and is intentionally ignored by Git.
|
||||||
|
|
||||||
|
The implementation replays the captured, unencrypted Switch 2 BLE wake
|
||||||
|
advertisement for two seconds at a 20 ms base interval. The CYW43439 has one
|
||||||
|
controller-wide public Bluetooth address, so the firmware temporarily changes
|
||||||
|
from the Pico's normal identity to the captured Joy-Con identity for the wake
|
||||||
|
burst and restores it afterward. The explicit chord confines the resulting
|
||||||
|
input-controller disconnect to an intentional wake attempt. This follows the
|
||||||
|
packet format documented by
|
||||||
|
[`ndeadly/switch2_controller_research`](https://github.com/ndeadly/switch2_controller_research/blob/master/bluetooth_interface.md)
|
||||||
|
and the capture/replay approach demonstrated by
|
||||||
|
[`alexvnesta/switch2controller`](https://github.com/alexvnesta/switch2controller)
|
||||||
|
and the MIT-licensed
|
||||||
|
[`Switch2-Wake-Beacon-ESPHome`](https://github.com/sickyj/Switch2-Wake-Beacon-ESPHome).
|
||||||
|
|
||||||
|
Back up the complete Pico flash before replacing the AIO firmware with the
|
||||||
|
temporary capture image:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
picotool save -a -v switch-pico-before-wake-capture.uf2
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
1. Connect the Pico 2 W to the computer and build/flash the one-shot capture
|
||||||
|
firmware:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 build.py --wake-capture
|
||||||
|
```
|
||||||
|
|
||||||
|
This also publishes `firmware/switch-pico-wake-capture.elf` and
|
||||||
|
`firmware/switch-pico-wake-capture.uf2`.
|
||||||
|
|
||||||
|
2. Start the configuration tool. It auto-detects a single Pico USB serial
|
||||||
|
port; use `--port /dev/ttyACM0` when more than one Pico is attached:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/configure_switch2_wake.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Detach a Joy-Con 2 that is already paired with the target Switch 2, put the
|
||||||
|
console to sleep, and press that Joy-Con's Home button. Do not press its
|
||||||
|
sync button. The capture firmware accepts the first public `ADV_IND` packet
|
||||||
|
with Nintendo's Switch 2 wake flag and nonzero console address, stops
|
||||||
|
scanning automatically, lights the onboard LED solid, and repeats the
|
||||||
|
captured record until the tool receives it.
|
||||||
|
|
||||||
|
4. The tool validates the packet and atomically writes
|
||||||
|
`src/firmware/platform/pico/switch2_wake_config.h`.
|
||||||
|
|
||||||
|
5. Restore the AIO firmware with the generated wake configuration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 build.py --aio
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
The capture tool also accepts a saved serial log:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/configure_switch2_wake.py --input switch2-joycon-capture.log
|
||||||
|
```
|
||||||
|
|
||||||
|
With the configured AIO firmware powered while the console sleeps, first turn
|
||||||
|
on the paired input controller with Home, PS, or Xbox and let it reconnect to
|
||||||
|
the Pico. Then hold L + R and press its system button to send one wake burst.
|
||||||
|
Holding the chord does not retrigger it; release at least one chord button
|
||||||
|
before another attempt. Plain Home, PS, or Xbox is forwarded normally and does
|
||||||
|
not disturb the radio.
|
||||||
|
|
||||||
|
The input controller disconnects during the intentional wake burst because
|
||||||
|
the CYW43439 cannot retain its normal public identity while transmitting the
|
||||||
|
captured controller's public identity. It can reconnect after the Pico restores
|
||||||
|
its address. Avoiding that disconnect requires a second BLE radio dedicated to
|
||||||
|
wake transmission; keeping the captured identity throughout gameplay caused
|
||||||
|
severe Classic Bluetooth latency in hardware testing.
|
||||||
|
|
||||||
|
The Pico must remain powered for wireless wake. If the Switch or dock removes
|
||||||
|
USB power during sleep, use a powered USB arrangement that preserves the
|
||||||
|
Pico-to-Switch data connection. Keep the captured Joy-Con inactive during the
|
||||||
|
two-second wake burst to avoid two radios using one address.
|
||||||
|
|
||||||
|
To target another Switch 2, repeat the capture and configuration steps. To
|
||||||
|
disable wake, delete the generated `switch2_wake_config.h` and rebuild the AIO
|
||||||
|
firmware. Restore the full-flash backup only if you need to recover the exact
|
||||||
|
pre-capture firmware and persistent state.
|
||||||
|
|
||||||
### Pairing up to four controllers
|
### Pairing up to four controllers
|
||||||
|
|
||||||
1. Flash and connect the Pico 2 W to the Switch.
|
1. Flash and connect the Pico 2 W to the Switch.
|
||||||
|
|
|
||||||
62
build.py
62
build.py
|
|
@ -15,6 +15,8 @@ CONFIG_FILE = FIRMWARE_SOURCE_DIR / "platform" / "pico" / "controller_color_conf
|
||||||
BUILD_DIR = SCRIPT_DIR / "build"
|
BUILD_DIR = SCRIPT_DIR / "build"
|
||||||
AIO_BUILD_DIR = SCRIPT_DIR / "build-aio"
|
AIO_BUILD_DIR = SCRIPT_DIR / "build-aio"
|
||||||
FEASIBILITY_BUILD_DIR = SCRIPT_DIR / "build-feasibility"
|
FEASIBILITY_BUILD_DIR = SCRIPT_DIR / "build-feasibility"
|
||||||
|
WAKE_CAPTURE_SOURCE_DIR = SCRIPT_DIR / "tools" / "switch2_wake_capture"
|
||||||
|
WAKE_CAPTURE_BUILD_DIR = SCRIPT_DIR / "build-wake-capture"
|
||||||
FIRMWARE_DIR = SCRIPT_DIR / "firmware"
|
FIRMWARE_DIR = SCRIPT_DIR / "firmware"
|
||||||
FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico.elf"
|
FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico.elf"
|
||||||
FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico.uf2"
|
FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico.uf2"
|
||||||
|
|
@ -26,6 +28,12 @@ FEASIBILITY_FIRMWARE_ELF_PATH = (
|
||||||
FEASIBILITY_FIRMWARE_UF2_PATH = (
|
FEASIBILITY_FIRMWARE_UF2_PATH = (
|
||||||
FIRMWARE_DIR / "switch-pico-adapter-feasibility.uf2"
|
FIRMWARE_DIR / "switch-pico-adapter-feasibility.uf2"
|
||||||
)
|
)
|
||||||
|
WAKE_CAPTURE_FIRMWARE_ELF_PATH = (
|
||||||
|
FIRMWARE_DIR / "switch-pico-wake-capture.elf"
|
||||||
|
)
|
||||||
|
WAKE_CAPTURE_FIRMWARE_UF2_PATH = (
|
||||||
|
FIRMWARE_DIR / "switch-pico-wake-capture.uf2"
|
||||||
|
)
|
||||||
|
|
||||||
ELF_PATH = Path(os.environ.get("ELF_PATH", BUILD_DIR / "switch-pico.elf")).expanduser()
|
ELF_PATH = Path(os.environ.get("ELF_PATH", BUILD_DIR / "switch-pico.elf")).expanduser()
|
||||||
UF2_PATH = Path(os.environ.get("UF2_PATH", BUILD_DIR / "switch-pico.uf2")).expanduser()
|
UF2_PATH = Path(os.environ.get("UF2_PATH", BUILD_DIR / "switch-pico.uf2")).expanduser()
|
||||||
|
|
@ -38,7 +46,12 @@ MACROS = tuple(
|
||||||
|
|
||||||
CMAKE_CACHE_PATHS = tuple(
|
CMAKE_CACHE_PATHS = tuple(
|
||||||
build_dir / "CMakeCache.txt"
|
build_dir / "CMakeCache.txt"
|
||||||
for build_dir in (BUILD_DIR, AIO_BUILD_DIR, FEASIBILITY_BUILD_DIR)
|
for build_dir in (
|
||||||
|
BUILD_DIR,
|
||||||
|
AIO_BUILD_DIR,
|
||||||
|
FEASIBILITY_BUILD_DIR,
|
||||||
|
WAKE_CAPTURE_BUILD_DIR,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
TOOLCHAIN_COMPILER = (
|
TOOLCHAIN_COMPILER = (
|
||||||
"arm-none-eabi-gcc.exe" if os.name == "nt" else "arm-none-eabi-gcc"
|
"arm-none-eabi-gcc.exe" if os.name == "nt" else "arm-none-eabi-gcc"
|
||||||
|
|
@ -279,6 +292,11 @@ def parse_args():
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Build and flash the Pico 2 W automatic Switch/XInput prototype.",
|
help="Build and flash the Pico 2 W automatic Switch/XInput prototype.",
|
||||||
)
|
)
|
||||||
|
mode_group.add_argument(
|
||||||
|
"--wake-capture",
|
||||||
|
action="store_true",
|
||||||
|
help="Build and flash the automatic Switch 2 wake capture firmware.",
|
||||||
|
)
|
||||||
group = parser.add_mutually_exclusive_group()
|
group = parser.add_mutually_exclusive_group()
|
||||||
group.add_argument(
|
group.add_argument(
|
||||||
"--random-grip-color",
|
"--random-grip-color",
|
||||||
|
|
@ -290,7 +308,10 @@ def parse_args():
|
||||||
metavar="RRGGBB",
|
metavar="RRGGBB",
|
||||||
help="Set every emulated controller slot to the provided hex color.",
|
help="Set every emulated controller slot to the provided hex color.",
|
||||||
)
|
)
|
||||||
return parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
if args.wake_capture and (args.random_grip_color or args.grip_color):
|
||||||
|
parser.error("wake capture firmware does not use grip-color options")
|
||||||
|
return args
|
||||||
|
|
||||||
def random_hex_color():
|
def random_hex_color():
|
||||||
return "".join(f"{random.randrange(256):02X}" for _ in range(3))
|
return "".join(f"{random.randrange(256):02X}" for _ in range(3))
|
||||||
|
|
@ -409,6 +430,39 @@ def build(
|
||||||
print(f"Copied UF2: {firmware_uf2_path}")
|
print(f"Copied UF2: {firmware_uf2_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_wake_capture():
|
||||||
|
elf_path = WAKE_CAPTURE_BUILD_DIR / "switch2-wake-capture.elf"
|
||||||
|
uf2_path = WAKE_CAPTURE_BUILD_DIR / "switch2-wake-capture.uf2"
|
||||||
|
run_cmd(
|
||||||
|
[
|
||||||
|
"cmake",
|
||||||
|
"-S",
|
||||||
|
str(WAKE_CAPTURE_SOURCE_DIR),
|
||||||
|
"-B",
|
||||||
|
str(WAKE_CAPTURE_BUILD_DIR),
|
||||||
|
"-DPICO_BOARD=pico2_w",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
run_cmd(["cmake", "--build", str(WAKE_CAPTURE_BUILD_DIR)])
|
||||||
|
missing_artifacts = [
|
||||||
|
path for path in (elf_path, uf2_path) if not path.is_file()
|
||||||
|
]
|
||||||
|
if missing_artifacts:
|
||||||
|
missing = ", ".join(str(path) for path in missing_artifacts)
|
||||||
|
sys.stderr.write(
|
||||||
|
f"Error: Wake capture build did not produce: {missing}\n"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
FIRMWARE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(elf_path, WAKE_CAPTURE_FIRMWARE_ELF_PATH)
|
||||||
|
shutil.copy2(uf2_path, WAKE_CAPTURE_FIRMWARE_UF2_PATH)
|
||||||
|
print(f"Built wake capture ELF: {elf_path}")
|
||||||
|
print(f"Built wake capture UF2: {uf2_path}")
|
||||||
|
print(f"Copied ELF: {WAKE_CAPTURE_FIRMWARE_ELF_PATH}")
|
||||||
|
print(f"Copied UF2: {WAKE_CAPTURE_FIRMWARE_UF2_PATH}")
|
||||||
|
return elf_path
|
||||||
|
|
||||||
|
|
||||||
def flash(elf_path, allow_elf_override):
|
def flash(elf_path, allow_elf_override):
|
||||||
picotool = resolve_picotool()
|
picotool = resolve_picotool()
|
||||||
if not elf_path.exists():
|
if not elf_path.exists():
|
||||||
|
|
@ -428,6 +482,10 @@ def main():
|
||||||
except BuildEnvironmentError as exc:
|
except BuildEnvironmentError as exc:
|
||||||
sys.stderr.write(f"Error: {exc}\n")
|
sys.stderr.write(f"Error: {exc}\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
if args.wake_capture:
|
||||||
|
wake_capture_elf = build_wake_capture()
|
||||||
|
flash(wake_capture_elf, allow_elf_override=False)
|
||||||
|
return
|
||||||
|
|
||||||
color = None
|
color = None
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
BIN
firmware/switch-pico-wake-capture.elf
Executable file
BIN
firmware/switch-pico-wake-capture.elf
Executable file
Binary file not shown.
BIN
firmware/switch-pico-wake-capture.uf2
Normal file
BIN
firmware/switch-pico-wake-capture.uf2
Normal file
Binary file not shown.
|
|
@ -1,5 +1,6 @@
|
||||||
#include "input/bluepad32_input_backend.h"
|
#include "input/bluepad32_input_backend.h"
|
||||||
#include "input/controller_hotkey_config.h"
|
#include "input/controller_hotkey_config.h"
|
||||||
|
#include "input/switch2_wake.h"
|
||||||
#include "configuration/configuration_service.h"
|
#include "configuration/configuration_service.h"
|
||||||
#include "profile/profile_service.h"
|
#include "profile/profile_service.h"
|
||||||
#include <limits.h>
|
#include <limits.h>
|
||||||
|
|
@ -500,6 +501,8 @@ ConnectionStatus compute_connection_status() {
|
||||||
: ConnectionStatus::Scanning;
|
: ConnectionStatus::Scanning;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
void publish_device_state(uint8_t slot, uni_hid_device_t* device,
|
void publish_device_state(uint8_t slot, uni_hid_device_t* device,
|
||||||
uint16_t pre_hotkey_button_mask,
|
uint16_t pre_hotkey_button_mask,
|
||||||
const ControllerState& state) {
|
const ControllerState& state) {
|
||||||
|
|
@ -632,6 +635,21 @@ constexpr uint16_t logical_button_bit(
|
||||||
return static_cast<uint16_t>(
|
return static_cast<uint16_t>(
|
||||||
1u << static_cast<uint8_t>(button));
|
1u << static_cast<uint8_t>(button));
|
||||||
}
|
}
|
||||||
|
bool wake_chord_rising_edge(uint8_t slot, uni_hid_device_t* device,
|
||||||
|
uint16_t button_mask) {
|
||||||
|
const uint16_t chord =
|
||||||
|
logical_button_bit(ControllerProfileLogicalButton::kLeftShoulder) |
|
||||||
|
logical_button_bit(ControllerProfileLogicalButton::kRightShoulder) |
|
||||||
|
logical_button_bit(ControllerProfileLogicalButton::kSystem);
|
||||||
|
critical_section_enter_blocking(&g_state_lock);
|
||||||
|
const BackendSlot& previous = g_slots[slot];
|
||||||
|
const bool rising =
|
||||||
|
previous.active && previous.device == device &&
|
||||||
|
(button_mask & chord) == chord &&
|
||||||
|
(previous.pre_hotkey_button_mask & chord) != chord;
|
||||||
|
critical_section_exit(&g_state_lock);
|
||||||
|
return rising;
|
||||||
|
}
|
||||||
|
|
||||||
constexpr uint16_t logical_button_mask(
|
constexpr uint16_t logical_button_mask(
|
||||||
uint32_t dpad, uint32_t buttons, uint32_t misc_buttons) {
|
uint32_t dpad, uint32_t buttons, uint32_t misc_buttons) {
|
||||||
|
|
@ -1462,6 +1480,7 @@ void platform_on_init_complete() {
|
||||||
g_identity_event_callback.callback = handle_btstack_event;
|
g_identity_event_callback.callback = handle_btstack_event;
|
||||||
sm_add_event_handler(&g_identity_event_callback);
|
sm_add_event_handler(&g_identity_event_callback);
|
||||||
hci_add_event_handler(&g_pairing_event_callback);
|
hci_add_event_handler(&g_pairing_event_callback);
|
||||||
|
switch2_wake_initialize();
|
||||||
refresh_pairing_snapshot();
|
refresh_pairing_snapshot();
|
||||||
// Keep Bluepad32 autoconnect active whenever at least one slot is free.
|
// Keep Bluepad32 autoconnect active whenever at least one slot is free.
|
||||||
btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer);
|
btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer);
|
||||||
|
|
@ -1624,6 +1643,11 @@ void platform_on_controller_data(uni_hid_device_t* device,
|
||||||
uni_gamepad_t gamepad = controller->gamepad;
|
uni_gamepad_t gamepad = controller->gamepad;
|
||||||
const uint16_t pre_hotkey_button_mask =
|
const uint16_t pre_hotkey_button_mask =
|
||||||
logical_button_mask(gamepad);
|
logical_button_mask(gamepad);
|
||||||
|
if (wake_chord_rising_edge(
|
||||||
|
static_cast<uint8_t>(slot_index), device,
|
||||||
|
pre_hotkey_button_mask)) {
|
||||||
|
switch2_wake_request();
|
||||||
|
}
|
||||||
const HotkeyDecision hotkeys = update_controller_hotkeys(
|
const HotkeyDecision hotkeys = update_controller_hotkeys(
|
||||||
static_cast<uint8_t>(slot_index), device);
|
static_cast<uint8_t>(slot_index), device);
|
||||||
const uint16_t output_button_mask = pre_hotkey_button_mask;
|
const uint16_t output_button_mask = pre_hotkey_button_mask;
|
||||||
|
|
|
||||||
316
src/firmware/input/switch2_wake.cpp
Normal file
316
src/firmware/input/switch2_wake.cpp
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
#include "input/switch2_wake.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include <btstack.h>
|
||||||
|
|
||||||
|
#if !defined(SWITCH2_WAKE_CONFIGURED)
|
||||||
|
#if __has_include("platform/pico/switch2_wake_config.h")
|
||||||
|
#include "platform/pico/switch2_wake_config.h"
|
||||||
|
#else
|
||||||
|
#define SWITCH2_WAKE_CONFIGURED 0
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr uint32_t kRetryIntervalMs = 5;
|
||||||
|
constexpr uint32_t kCommandTimeoutMs = 1000;
|
||||||
|
constexpr uint32_t kBurstDurationMs = 2000;
|
||||||
|
constexpr uint16_t kAdvertisingIntervalUnits = 0x0020; // 20 ms + BLE delay.
|
||||||
|
constexpr uint8_t kAdvertisingTypeNonConnectable = 3;
|
||||||
|
constexpr uint16_t kWritePublicAddressOpcode = 0xfc01;
|
||||||
|
|
||||||
|
#if SWITCH2_WAKE_CONFIGURED
|
||||||
|
const uint8_t kWakeAddress[6] = SWITCH2_WAKE_SOURCE_ADDRESS_BYTES;
|
||||||
|
uint8_t kWakeAdvertisement[31] = SWITCH2_WAKE_ADVERTISEMENT_DATA_BYTES;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
const hci_cmd_t kWritePublicAddress = {kWritePublicAddressOpcode, "B"};
|
||||||
|
const bd_addr_t kUnusedPeerAddress{};
|
||||||
|
|
||||||
|
enum class Phase : uint8_t {
|
||||||
|
kDisabled,
|
||||||
|
kIdle,
|
||||||
|
kSetWakeAddress,
|
||||||
|
kSetParameters,
|
||||||
|
kSetData,
|
||||||
|
kEnableAdvertising,
|
||||||
|
kAdvertising,
|
||||||
|
kDisableAdvertising,
|
||||||
|
kRestoreAddress,
|
||||||
|
kFailed,
|
||||||
|
};
|
||||||
|
|
||||||
|
Phase g_phase = Phase::kDisabled;
|
||||||
|
bd_addr_t g_original_address{};
|
||||||
|
btstack_packet_callback_registration_t g_event_registration{};
|
||||||
|
btstack_timer_source_t g_timer{};
|
||||||
|
uint16_t g_pending_opcode = 0;
|
||||||
|
uint32_t g_command_deadline_ms = 0;
|
||||||
|
uint32_t g_burst_deadline_ms = 0;
|
||||||
|
uint32_t g_accepted_requests = 0;
|
||||||
|
uint32_t g_completed_bursts = 0;
|
||||||
|
uint32_t g_failures = 0;
|
||||||
|
bool g_initialized = false;
|
||||||
|
bool g_configured = false;
|
||||||
|
bool g_timer_armed = false;
|
||||||
|
bool g_address_changed = false;
|
||||||
|
bool g_advertising = false;
|
||||||
|
|
||||||
|
bool deadline_reached(uint32_t now, uint32_t deadline) {
|
||||||
|
return static_cast<int32_t>(now - deadline) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool phase_needs_work() {
|
||||||
|
return g_phase != Phase::kDisabled &&
|
||||||
|
g_phase != Phase::kIdle &&
|
||||||
|
g_phase != Phase::kFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel_task() {
|
||||||
|
if (g_timer_armed) {
|
||||||
|
btstack_run_loop_remove_timer(&g_timer);
|
||||||
|
g_timer_armed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void schedule_task(uint32_t delay_ms) {
|
||||||
|
cancel_task();
|
||||||
|
btstack_run_loop_set_timer(&g_timer, delay_ms);
|
||||||
|
btstack_run_loop_add_timer(&g_timer);
|
||||||
|
g_timer_armed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void schedule_for_phase(uint32_t now_ms) {
|
||||||
|
if (g_pending_opcode != 0) {
|
||||||
|
schedule_task(
|
||||||
|
deadline_reached(now_ms, g_command_deadline_ms)
|
||||||
|
? 0
|
||||||
|
: g_command_deadline_ms - now_ms);
|
||||||
|
} else if (g_phase == Phase::kAdvertising) {
|
||||||
|
schedule_task(
|
||||||
|
deadline_reached(now_ms, g_burst_deadline_ms)
|
||||||
|
? 0
|
||||||
|
: g_burst_deadline_ms - now_ms);
|
||||||
|
} else if (phase_needs_work()) {
|
||||||
|
schedule_task(kRetryIntervalMs);
|
||||||
|
} else {
|
||||||
|
cancel_task();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if SWITCH2_WAKE_CONFIGURED
|
||||||
|
bool configured_packet_valid() {
|
||||||
|
static const uint8_t prefix[] = {
|
||||||
|
0x02, 0x01, 0x06, 0x1b, 0xff, 0x53, 0x05,
|
||||||
|
0x01, 0x00, 0x03, 0x7e, 0x05,
|
||||||
|
};
|
||||||
|
static const uint8_t suffix[] = {
|
||||||
|
0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
};
|
||||||
|
uint8_t address_any = 0;
|
||||||
|
uint8_t target_any = 0;
|
||||||
|
for (uint8_t value : kWakeAddress) {
|
||||||
|
address_any |= value;
|
||||||
|
}
|
||||||
|
for (size_t index = 17; index < 23; ++index) {
|
||||||
|
target_any |= kWakeAdvertisement[index];
|
||||||
|
}
|
||||||
|
return address_any != 0 && target_any != 0 &&
|
||||||
|
memcmp(kWakeAdvertisement, prefix, sizeof(prefix)) == 0 &&
|
||||||
|
kWakeAdvertisement[14] == 0x00 &&
|
||||||
|
kWakeAdvertisement[15] == 0x01 &&
|
||||||
|
kWakeAdvertisement[16] == 0x81 &&
|
||||||
|
memcmp(&kWakeAdvertisement[23], suffix, sizeof(suffix)) == 0;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
void recover_from_failure() {
|
||||||
|
++g_failures;
|
||||||
|
g_pending_opcode = 0;
|
||||||
|
if (g_advertising) {
|
||||||
|
g_phase = Phase::kDisableAdvertising;
|
||||||
|
} else if (g_address_changed) {
|
||||||
|
g_phase = Phase::kRestoreAddress;
|
||||||
|
} else {
|
||||||
|
g_phase = Phase::kIdle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void begin_command(uint16_t opcode, uint32_t now_ms) {
|
||||||
|
g_pending_opcode = opcode;
|
||||||
|
g_command_deadline_ms = now_ms + kCommandTimeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
void submit_phase_command(uint32_t now_ms) {
|
||||||
|
if (g_pending_opcode != 0 || !hci_can_send_command_packet_now()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t result = ERROR_CODE_SUCCESS;
|
||||||
|
switch (g_phase) {
|
||||||
|
case Phase::kSetWakeAddress:
|
||||||
|
begin_command(kWritePublicAddress.opcode, now_ms);
|
||||||
|
#if SWITCH2_WAKE_CONFIGURED
|
||||||
|
result = hci_send_cmd(&kWritePublicAddress, kWakeAddress);
|
||||||
|
#endif
|
||||||
|
break;
|
||||||
|
case Phase::kSetParameters:
|
||||||
|
begin_command(hci_le_set_advertising_parameters.opcode, now_ms);
|
||||||
|
result = hci_send_cmd(
|
||||||
|
&hci_le_set_advertising_parameters,
|
||||||
|
kAdvertisingIntervalUnits, kAdvertisingIntervalUnits,
|
||||||
|
kAdvertisingTypeNonConnectable,
|
||||||
|
BD_ADDR_TYPE_LE_PUBLIC, BD_ADDR_TYPE_LE_PUBLIC,
|
||||||
|
kUnusedPeerAddress, 7, 0);
|
||||||
|
break;
|
||||||
|
case Phase::kSetData:
|
||||||
|
begin_command(hci_le_set_advertising_data.opcode, now_ms);
|
||||||
|
#if SWITCH2_WAKE_CONFIGURED
|
||||||
|
result = hci_send_cmd(
|
||||||
|
&hci_le_set_advertising_data,
|
||||||
|
static_cast<uint8_t>(sizeof(kWakeAdvertisement)),
|
||||||
|
kWakeAdvertisement);
|
||||||
|
#endif
|
||||||
|
break;
|
||||||
|
case Phase::kEnableAdvertising:
|
||||||
|
case Phase::kDisableAdvertising:
|
||||||
|
begin_command(hci_le_set_advertise_enable.opcode, now_ms);
|
||||||
|
result = hci_send_cmd(
|
||||||
|
&hci_le_set_advertise_enable,
|
||||||
|
g_phase == Phase::kEnableAdvertising ? 1 : 0);
|
||||||
|
break;
|
||||||
|
case Phase::kRestoreAddress:
|
||||||
|
begin_command(kWritePublicAddress.opcode, now_ms);
|
||||||
|
result = hci_send_cmd(&kWritePublicAddress, g_original_address);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result != ERROR_CODE_SUCCESS) {
|
||||||
|
recover_from_failure();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void handle_command_complete(uint8_t* packet, uint16_t size) {
|
||||||
|
if (size < 6 || g_pending_opcode == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uint16_t opcode =
|
||||||
|
hci_event_command_complete_get_command_opcode(packet);
|
||||||
|
if (opcode != g_pending_opcode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uint8_t status =
|
||||||
|
hci_event_command_complete_get_return_parameters(packet)[0];
|
||||||
|
g_pending_opcode = 0;
|
||||||
|
if (status != ERROR_CODE_SUCCESS) {
|
||||||
|
recover_from_failure();
|
||||||
|
schedule_for_phase(btstack_run_loop_get_time_ms());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (g_phase) {
|
||||||
|
case Phase::kSetWakeAddress:
|
||||||
|
g_address_changed = true;
|
||||||
|
g_phase = Phase::kSetParameters;
|
||||||
|
break;
|
||||||
|
case Phase::kSetParameters:
|
||||||
|
g_phase = Phase::kSetData;
|
||||||
|
break;
|
||||||
|
case Phase::kSetData:
|
||||||
|
g_phase = Phase::kEnableAdvertising;
|
||||||
|
break;
|
||||||
|
case Phase::kEnableAdvertising:
|
||||||
|
g_advertising = true;
|
||||||
|
g_burst_deadline_ms =
|
||||||
|
btstack_run_loop_get_time_ms() + kBurstDurationMs;
|
||||||
|
g_phase = Phase::kAdvertising;
|
||||||
|
break;
|
||||||
|
case Phase::kDisableAdvertising:
|
||||||
|
g_advertising = false;
|
||||||
|
g_phase = Phase::kRestoreAddress;
|
||||||
|
break;
|
||||||
|
case Phase::kRestoreAddress:
|
||||||
|
g_address_changed = false;
|
||||||
|
++g_completed_bursts;
|
||||||
|
g_phase = Phase::kIdle;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
recover_from_failure();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
schedule_for_phase(btstack_run_loop_get_time_ms());
|
||||||
|
}
|
||||||
|
|
||||||
|
void handle_hci_event(uint8_t packet_type, uint16_t,
|
||||||
|
uint8_t* packet, uint16_t size) {
|
||||||
|
if (packet_type == HCI_EVENT_PACKET && size >= 2 &&
|
||||||
|
hci_event_packet_get_type(packet) == HCI_EVENT_COMMAND_COMPLETE) {
|
||||||
|
handle_command_complete(packet, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void task(btstack_timer_source_t*) {
|
||||||
|
g_timer_armed = false;
|
||||||
|
const uint32_t now_ms = btstack_run_loop_get_time_ms();
|
||||||
|
if (g_pending_opcode != 0 &&
|
||||||
|
deadline_reached(now_ms, g_command_deadline_ms)) {
|
||||||
|
recover_from_failure();
|
||||||
|
}
|
||||||
|
if (g_phase == Phase::kAdvertising &&
|
||||||
|
deadline_reached(now_ms, g_burst_deadline_ms)) {
|
||||||
|
g_phase = Phase::kDisableAdvertising;
|
||||||
|
}
|
||||||
|
submit_phase_command(now_ms);
|
||||||
|
schedule_for_phase(now_ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void switch2_wake_initialize() {
|
||||||
|
if (g_initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_initialized = true;
|
||||||
|
#if SWITCH2_WAKE_CONFIGURED
|
||||||
|
if (!configured_packet_valid()) {
|
||||||
|
g_phase = Phase::kFailed;
|
||||||
|
++g_failures;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_configured = true;
|
||||||
|
gap_local_bd_addr(g_original_address);
|
||||||
|
g_event_registration.callback = handle_hci_event;
|
||||||
|
hci_add_event_handler(&g_event_registration);
|
||||||
|
btstack_run_loop_set_timer_handler(&g_timer, task);
|
||||||
|
g_phase = Phase::kIdle;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool switch2_wake_request() {
|
||||||
|
if (g_phase != Phase::kIdle) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
++g_accepted_requests;
|
||||||
|
g_phase = Phase::kSetWakeAddress;
|
||||||
|
schedule_task(0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void switch2_wake_diagnostics(Switch2WakeDiagnostics* out) {
|
||||||
|
if (out == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*out = {
|
||||||
|
g_configured,
|
||||||
|
g_phase != Phase::kDisabled && g_phase != Phase::kIdle &&
|
||||||
|
g_phase != Phase::kFailed,
|
||||||
|
g_accepted_requests,
|
||||||
|
g_completed_bursts,
|
||||||
|
g_failures,
|
||||||
|
};
|
||||||
|
}
|
||||||
20
src/firmware/input/switch2_wake.h
Normal file
20
src/firmware/input/switch2_wake.h
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
struct Switch2WakeDiagnostics {
|
||||||
|
bool configured;
|
||||||
|
bool busy;
|
||||||
|
uint32_t accepted_requests;
|
||||||
|
uint32_t completed_bursts;
|
||||||
|
uint32_t failures;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Installs BTstack callbacks and remembers the Pico's normal public identity.
|
||||||
|
void switch2_wake_initialize();
|
||||||
|
|
||||||
|
|
||||||
|
// Starts one wake burst when configured and idle. Calls while busy coalesce.
|
||||||
|
bool switch2_wake_request();
|
||||||
|
|
||||||
|
void switch2_wake_diagnostics(Switch2WakeDiagnostics* out);
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
#include <uni.h>
|
#include <uni.h>
|
||||||
#include "platform/pico/controller_color_config.h"
|
#include "platform/pico/controller_color_config.h"
|
||||||
|
#include "input/switch2_wake.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
|
@ -44,6 +45,8 @@ bool expect_configuration_timer_prearmed = false;
|
||||||
uint32_t expected_configuration_timer_add_count = 0;
|
uint32_t expected_configuration_timer_add_count = 0;
|
||||||
int cyw43_init_calls = 0;
|
int cyw43_init_calls = 0;
|
||||||
int uni_init_calls = 0;
|
int uni_init_calls = 0;
|
||||||
|
int switch2_wake_initializations = 0;
|
||||||
|
int switch2_wake_requests = 0;
|
||||||
int device_disconnect_calls = 0;
|
int device_disconnect_calls = 0;
|
||||||
uni_hid_device_t* last_disconnected_device = nullptr;
|
uni_hid_device_t* last_disconnected_device = nullptr;
|
||||||
uni_hid_device_t* lookup_devices[8]{};
|
uni_hid_device_t* lookup_devices[8]{};
|
||||||
|
|
@ -442,6 +445,19 @@ uint32_t btstack_run_loop_get_time_ms() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void switch2_wake_initialize() {
|
||||||
|
++switch2_wake_initializations;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool switch2_wake_request() {
|
||||||
|
++switch2_wake_requests;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void switch2_wake_diagnostics(Switch2WakeDiagnostics*) {
|
||||||
|
}
|
||||||
|
|
||||||
#include "core/controller_identity.cpp"
|
#include "core/controller_identity.cpp"
|
||||||
#include "input/bluepad32_input_backend.cpp"
|
#include "input/bluepad32_input_backend.cpp"
|
||||||
|
|
||||||
|
|
@ -548,7 +564,8 @@ void start_backend() {
|
||||||
kClassicLinkSupervisionTimeout &&
|
kClassicLinkSupervisionTimeout &&
|
||||||
!bondable && accepted_stk_methods == 0 &&
|
!bondable && accepted_stk_methods == 0 &&
|
||||||
!ssp_auto_accept && pairing_event_handler != nullptr &&
|
!ssp_auto_accept && pairing_event_handler != nullptr &&
|
||||||
identity_event_handler != nullptr,
|
identity_event_handler != nullptr &&
|
||||||
|
switch2_wake_initializations == 1,
|
||||||
"initialization must register Classic and BLE identity policy");
|
"initialization must register Classic and BLE identity policy");
|
||||||
}
|
}
|
||||||
void start_pairing_backend() {
|
void start_pairing_backend() {
|
||||||
|
|
@ -2373,6 +2390,51 @@ void test_flash_core_start_contract() {
|
||||||
"backend start must remain idempotent");
|
"backend start must remain idempotent");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
void test_system_button_wake_trigger() {
|
||||||
|
start_pairing_backend();
|
||||||
|
uni_hid_device_t controller = device(0);
|
||||||
|
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS,
|
||||||
|
"wake trigger controller did not become ready");
|
||||||
|
|
||||||
|
uni_controller_t input{};
|
||||||
|
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||||
|
platform_on_controller_data(&controller, &input);
|
||||||
|
require(switch2_wake_requests == 0,
|
||||||
|
"neutral input requested a wake burst");
|
||||||
|
|
||||||
|
input.gamepad.misc_buttons = MISC_BUTTON_SYSTEM;
|
||||||
|
platform_on_controller_data(&controller, &input);
|
||||||
|
require(switch2_wake_requests == 0,
|
||||||
|
"plain system button requested a wake burst");
|
||||||
|
|
||||||
|
input.gamepad.buttons = BUTTON_SHOULDER_L | BUTTON_SHOULDER_R;
|
||||||
|
platform_on_controller_data(&controller, &input);
|
||||||
|
platform_on_controller_data(&controller, &input);
|
||||||
|
require(switch2_wake_requests == 1,
|
||||||
|
"held L+R+System chord did not produce exactly one wake request");
|
||||||
|
|
||||||
|
input.gamepad.buttons = 0;
|
||||||
|
input.gamepad.misc_buttons = 0;
|
||||||
|
platform_on_controller_data(&controller, &input);
|
||||||
|
input.gamepad.buttons = BUTTON_SHOULDER_L | BUTTON_SHOULDER_R;
|
||||||
|
platform_on_controller_data(&controller, &input);
|
||||||
|
require(switch2_wake_requests == 1,
|
||||||
|
"L+R without System requested wake");
|
||||||
|
input.gamepad.misc_buttons = MISC_BUTTON_SYSTEM;
|
||||||
|
platform_on_controller_data(&controller, &input);
|
||||||
|
require(switch2_wake_requests == 2,
|
||||||
|
"second L+R+System chord edge did not request wake");
|
||||||
|
|
||||||
|
input.gamepad.buttons = 0;
|
||||||
|
input.gamepad.misc_buttons = MISC_BUTTON_CAPTURE;
|
||||||
|
platform_on_controller_data(&controller, &input);
|
||||||
|
require(switch2_wake_requests == 2,
|
||||||
|
"non-system misc button requested wake");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void test_flash_core_init_fatal() {
|
void test_flash_core_init_fatal() {
|
||||||
bluepad32_input_backend_init();
|
bluepad32_input_backend_init();
|
||||||
flash_core_init_result = false;
|
flash_core_init_result = false;
|
||||||
|
|
@ -2424,6 +2486,8 @@ int main(int argc, char** argv) {
|
||||||
test_configuration_timer_rearms_before_storage_work();
|
test_configuration_timer_rearms_before_storage_work();
|
||||||
} else if (scenario == "flash-core-start") {
|
} else if (scenario == "flash-core-start") {
|
||||||
test_flash_core_start_contract();
|
test_flash_core_start_contract();
|
||||||
|
} else if (scenario == "system-wake") {
|
||||||
|
test_system_button_wake_trigger();
|
||||||
} else if (scenario == "flash-core-failure") {
|
} else if (scenario == "flash-core-failure") {
|
||||||
test_flash_core_init_fatal();
|
test_flash_core_init_fatal();
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
57
tests/switch2_wake_native_stubs/btstack.h
Normal file
57
tests/switch2_wake_native_stubs/btstack.h
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
using bd_addr_t = uint8_t[6];
|
||||||
|
using btstack_packet_handler_t = void (*)(uint8_t, uint16_t, uint8_t*, uint16_t);
|
||||||
|
struct btstack_packet_callback_registration_t {
|
||||||
|
void* item;
|
||||||
|
btstack_packet_handler_t callback;
|
||||||
|
};
|
||||||
|
struct btstack_timer_source_t {
|
||||||
|
void (*handler)(btstack_timer_source_t*);
|
||||||
|
uint32_t timeout_ms;
|
||||||
|
uint32_t add_count;
|
||||||
|
};
|
||||||
|
struct hci_cmd_t {
|
||||||
|
uint16_t opcode;
|
||||||
|
const char* format;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum {
|
||||||
|
ERROR_CODE_SUCCESS = 0,
|
||||||
|
HCI_EVENT_PACKET = 4,
|
||||||
|
HCI_EVENT_COMMAND_COMPLETE = 0x0e,
|
||||||
|
BD_ADDR_TYPE_LE_PUBLIC = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
extern const hci_cmd_t hci_read_bd_addr;
|
||||||
|
extern const hci_cmd_t hci_le_set_advertising_parameters;
|
||||||
|
extern const hci_cmd_t hci_le_set_advertising_data;
|
||||||
|
extern const hci_cmd_t hci_le_set_advertise_enable;
|
||||||
|
|
||||||
|
bool hci_can_send_command_packet_now();
|
||||||
|
uint8_t hci_send_cmd(const hci_cmd_t* command, ...);
|
||||||
|
void hci_add_event_handler(btstack_packet_callback_registration_t* registration);
|
||||||
|
void gap_local_bd_addr(bd_addr_t output);
|
||||||
|
uint32_t btstack_run_loop_get_time_ms();
|
||||||
|
void btstack_run_loop_set_timer_handler(
|
||||||
|
btstack_timer_source_t* timer,
|
||||||
|
void (*handler)(btstack_timer_source_t*));
|
||||||
|
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms);
|
||||||
|
void btstack_run_loop_add_timer(btstack_timer_source_t* timer);
|
||||||
|
int btstack_run_loop_remove_timer(btstack_timer_source_t* timer);
|
||||||
|
|
||||||
|
inline uint8_t hci_event_packet_get_type(const uint8_t* event) {
|
||||||
|
return event[0];
|
||||||
|
}
|
||||||
|
inline uint16_t hci_event_command_complete_get_command_opcode(
|
||||||
|
const uint8_t* event) {
|
||||||
|
return static_cast<uint16_t>(event[3]) |
|
||||||
|
static_cast<uint16_t>(event[4] << 8);
|
||||||
|
}
|
||||||
|
inline const uint8_t* hci_event_command_complete_get_return_parameters(
|
||||||
|
const uint8_t* event) {
|
||||||
|
return &event[5];
|
||||||
|
}
|
||||||
249
tests/switch2_wake_test.cpp
Normal file
249
tests/switch2_wake_test.cpp
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
#include <cstdarg>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
#include <btstack.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct SubmittedCommand {
|
||||||
|
uint16_t opcode;
|
||||||
|
uint8_t address[6];
|
||||||
|
uint16_t interval_min;
|
||||||
|
uint16_t interval_max;
|
||||||
|
uint8_t advertising_type;
|
||||||
|
uint8_t own_address_type;
|
||||||
|
uint8_t channel_map;
|
||||||
|
uint8_t filter_policy;
|
||||||
|
uint8_t data[31];
|
||||||
|
uint8_t data_length;
|
||||||
|
uint8_t enabled;
|
||||||
|
};
|
||||||
|
|
||||||
|
uint32_t now_ms;
|
||||||
|
bd_addr_t original_address = {0xA8, 0x59, 0x5F, 0x9E, 0x37, 0x65};
|
||||||
|
btstack_packet_handler_t event_handler;
|
||||||
|
btstack_timer_source_t* installed_timer;
|
||||||
|
SubmittedCommand submitted[16]{};
|
||||||
|
size_t submitted_count;
|
||||||
|
bool command_available = true;
|
||||||
|
|
||||||
|
void require(bool condition, const char* message) {
|
||||||
|
if (!condition) {
|
||||||
|
std::cerr << message << '\n';
|
||||||
|
std::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
const hci_cmd_t hci_read_bd_addr = {0x1009, "read-address"};
|
||||||
|
const hci_cmd_t hci_le_set_advertising_parameters = {0x2006, "params"};
|
||||||
|
const hci_cmd_t hci_le_set_advertising_data = {0x2008, "data"};
|
||||||
|
const hci_cmd_t hci_le_set_advertise_enable = {0x200a, "enable"};
|
||||||
|
|
||||||
|
bool hci_can_send_command_packet_now() {
|
||||||
|
return command_available;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t hci_send_cmd(const hci_cmd_t* command, ...) {
|
||||||
|
require(command_available, "submitted without an HCI command credit");
|
||||||
|
require(submitted_count < 16, "command capture overflow");
|
||||||
|
command_available = false;
|
||||||
|
SubmittedCommand& output = submitted[submitted_count++];
|
||||||
|
output.opcode = command->opcode;
|
||||||
|
va_list arguments;
|
||||||
|
va_start(arguments, command);
|
||||||
|
if (command->opcode == 0xfc01) {
|
||||||
|
const uint8_t* address = va_arg(arguments, const uint8_t*);
|
||||||
|
memcpy(output.address, address, sizeof(output.address));
|
||||||
|
} else if (command->opcode == 0x2006) {
|
||||||
|
output.interval_min = static_cast<uint16_t>(va_arg(arguments, int));
|
||||||
|
output.interval_max = static_cast<uint16_t>(va_arg(arguments, int));
|
||||||
|
output.advertising_type = static_cast<uint8_t>(va_arg(arguments, int));
|
||||||
|
output.own_address_type = static_cast<uint8_t>(va_arg(arguments, int));
|
||||||
|
(void)va_arg(arguments, int);
|
||||||
|
(void)va_arg(arguments, const uint8_t*);
|
||||||
|
output.channel_map = static_cast<uint8_t>(va_arg(arguments, int));
|
||||||
|
output.filter_policy = static_cast<uint8_t>(va_arg(arguments, int));
|
||||||
|
} else if (command->opcode == 0x2008) {
|
||||||
|
output.data_length = static_cast<uint8_t>(va_arg(arguments, int));
|
||||||
|
const uint8_t* data = va_arg(arguments, const uint8_t*);
|
||||||
|
memcpy(output.data, data, output.data_length);
|
||||||
|
} else if (command->opcode == 0x200a) {
|
||||||
|
output.enabled = static_cast<uint8_t>(va_arg(arguments, int));
|
||||||
|
}
|
||||||
|
va_end(arguments);
|
||||||
|
return ERROR_CODE_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
void hci_add_event_handler(
|
||||||
|
btstack_packet_callback_registration_t* registration) {
|
||||||
|
event_handler = registration->callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
void gap_local_bd_addr(bd_addr_t output) {
|
||||||
|
memcpy(output, original_address, sizeof(original_address));
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t btstack_run_loop_get_time_ms() {
|
||||||
|
return now_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
void btstack_run_loop_set_timer_handler(
|
||||||
|
btstack_timer_source_t* timer,
|
||||||
|
void (*handler)(btstack_timer_source_t*)) {
|
||||||
|
timer->handler = handler;
|
||||||
|
installed_timer = timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
void btstack_run_loop_set_timer(
|
||||||
|
btstack_timer_source_t* timer, uint32_t timeout_ms) {
|
||||||
|
timer->timeout_ms = timeout_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
void btstack_run_loop_add_timer(btstack_timer_source_t* timer) {
|
||||||
|
++timer->add_count;
|
||||||
|
}
|
||||||
|
int btstack_run_loop_remove_timer(btstack_timer_source_t*) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#define SWITCH2_WAKE_CONFIGURED 1
|
||||||
|
#define SWITCH2_WAKE_SOURCE_ADDRESS_BYTES \
|
||||||
|
{0x98, 0xE2, 0x55, 0x07, 0xDF, 0x00}
|
||||||
|
#define SWITCH2_WAKE_ADVERTISEMENT_DATA_BYTES \
|
||||||
|
{0x02, 0x01, 0x06, 0x1B, 0xFF, 0x53, 0x05, 0x01, 0x00, 0x03, 0x7E, \
|
||||||
|
0x05, 0x66, 0x20, 0x00, 0x01, 0x81, 0xBD, 0xD6, 0xF7, 0xEB, 0xF1, \
|
||||||
|
0x48, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||||
|
#include "input/switch2_wake.cpp"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void complete(uint16_t opcode, uint8_t status = 0,
|
||||||
|
const uint8_t* address = nullptr) {
|
||||||
|
uint8_t event[12] = {
|
||||||
|
HCI_EVENT_COMMAND_COMPLETE,
|
||||||
|
static_cast<uint8_t>(address == nullptr ? 4 : 10),
|
||||||
|
1,
|
||||||
|
static_cast<uint8_t>(opcode), static_cast<uint8_t>(opcode >> 8),
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
if (address != nullptr) {
|
||||||
|
for (size_t index = 0; index < 6; ++index) {
|
||||||
|
event[6 + index] = address[5 - index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
command_available = true;
|
||||||
|
event_handler(
|
||||||
|
HCI_EVENT_PACKET, 0, event,
|
||||||
|
static_cast<uint16_t>(address == nullptr ? 6 : 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
void run_task() {
|
||||||
|
require(installed_timer != nullptr && installed_timer->handler != nullptr,
|
||||||
|
"wake timer was not installed");
|
||||||
|
installed_timer->handler(installed_timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
void require_opcode(size_t index, uint16_t opcode) {
|
||||||
|
require(index < submitted_count && submitted[index].opcode == opcode,
|
||||||
|
"unexpected HCI command sequence");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_temporary_identity_wake_and_restore() {
|
||||||
|
switch2_wake_initialize();
|
||||||
|
Switch2WakeDiagnostics diagnostics{};
|
||||||
|
switch2_wake_diagnostics(&diagnostics);
|
||||||
|
require(diagnostics.configured && !diagnostics.busy && !g_timer_armed,
|
||||||
|
"configured wake module did not initialize dormant");
|
||||||
|
require(switch2_wake_request() && !switch2_wake_request(),
|
||||||
|
"wake requests were not bounded while busy");
|
||||||
|
|
||||||
|
run_task();
|
||||||
|
require_opcode(0, 0xfc01);
|
||||||
|
const uint8_t wake_address[] = {0x98, 0xE2, 0x55, 0x07, 0xDF, 0x00};
|
||||||
|
require(memcmp(submitted[0].address, wake_address, 6) == 0,
|
||||||
|
"wake address did not reach the radio command");
|
||||||
|
complete(0xfc01);
|
||||||
|
|
||||||
|
run_task();
|
||||||
|
require_opcode(1, 0x2006);
|
||||||
|
require(submitted[1].interval_min == 0x20 &&
|
||||||
|
submitted[1].interval_max == 0x20 &&
|
||||||
|
submitted[1].advertising_type == 3 &&
|
||||||
|
submitted[1].own_address_type == 0 &&
|
||||||
|
submitted[1].channel_map == 7 &&
|
||||||
|
submitted[1].filter_policy == 0,
|
||||||
|
"known-working advertising parameters changed");
|
||||||
|
complete(0x2006);
|
||||||
|
|
||||||
|
run_task();
|
||||||
|
require_opcode(2, 0x2008);
|
||||||
|
require(submitted[2].data_length == 31 &&
|
||||||
|
submitted[2].data[16] == 0x81,
|
||||||
|
"captured wake payload was not submitted intact");
|
||||||
|
complete(0x2008);
|
||||||
|
|
||||||
|
run_task();
|
||||||
|
require_opcode(3, 0x200a);
|
||||||
|
require(submitted[3].enabled == 1,
|
||||||
|
"wake advertising was not enabled");
|
||||||
|
complete(0x200a);
|
||||||
|
require(g_timer_armed && installed_timer->timeout_ms == 2000,
|
||||||
|
"wake burst did not schedule one exact stop deadline");
|
||||||
|
now_ms = 1999;
|
||||||
|
run_task();
|
||||||
|
require(submitted_count == 4,
|
||||||
|
"wake burst stopped before two seconds");
|
||||||
|
now_ms = 2000;
|
||||||
|
run_task();
|
||||||
|
require_opcode(4, 0x200a);
|
||||||
|
require(submitted[4].enabled == 0,
|
||||||
|
"wake advertising was not disabled");
|
||||||
|
complete(0x200a);
|
||||||
|
|
||||||
|
run_task();
|
||||||
|
require_opcode(5, 0xfc01);
|
||||||
|
require(memcmp(submitted[5].address, original_address, 6) == 0,
|
||||||
|
"Pico gameplay identity was not restored");
|
||||||
|
complete(0xfc01);
|
||||||
|
|
||||||
|
switch2_wake_diagnostics(&diagnostics);
|
||||||
|
require(!diagnostics.busy && diagnostics.accepted_requests == 1 &&
|
||||||
|
diagnostics.completed_bursts == 1 &&
|
||||||
|
diagnostics.failures == 0 && !g_timer_armed,
|
||||||
|
"completed wake did not restore a dormant gameplay state");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_failed_setup_restores_gameplay_identity() {
|
||||||
|
require(switch2_wake_request(),
|
||||||
|
"idle module rejected a second wake request");
|
||||||
|
run_task();
|
||||||
|
require_opcode(6, 0xfc01);
|
||||||
|
complete(0xfc01);
|
||||||
|
run_task();
|
||||||
|
require_opcode(7, 0x2006);
|
||||||
|
complete(0x2006, 0x12);
|
||||||
|
run_task();
|
||||||
|
require_opcode(8, 0xfc01);
|
||||||
|
require(memcmp(submitted[8].address, original_address, 6) == 0,
|
||||||
|
"wake setup failure did not restore the gameplay identity");
|
||||||
|
complete(0xfc01);
|
||||||
|
|
||||||
|
Switch2WakeDiagnostics diagnostics{};
|
||||||
|
switch2_wake_diagnostics(&diagnostics);
|
||||||
|
require(!diagnostics.busy && diagnostics.failures == 1 &&
|
||||||
|
!g_timer_armed,
|
||||||
|
"wake failure did not recover to dormant gameplay");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
test_temporary_identity_wake_and_restore();
|
||||||
|
test_failed_setup_restores_gameplay_identity();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
@ -51,6 +51,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
||||||
"clear-pairings",
|
"clear-pairings",
|
||||||
"configuration-timer",
|
"configuration-timer",
|
||||||
"flash-core-start",
|
"flash-core-start",
|
||||||
|
"system-wake",
|
||||||
"flash-core-failure",
|
"flash-core-failure",
|
||||||
):
|
):
|
||||||
subprocess.run([str(executable), scenario], check=True, cwd=root)
|
subprocess.run([str(executable), scenario], check=True, cwd=root)
|
||||||
|
|
|
||||||
70
tests/test_configure_switch2_wake.py
Normal file
70
tests/test_configure_switch2_wake.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SPEC = importlib.util.spec_from_file_location(
|
||||||
|
"configure_switch2_wake",
|
||||||
|
ROOT / "tools" / "configure_switch2_wake.py",
|
||||||
|
)
|
||||||
|
configure = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(configure)
|
||||||
|
|
||||||
|
RAW_HEX = "0201061BFF53050100037E056620000181BDD6F7EBF1480F00000000000000"
|
||||||
|
RECORD = {
|
||||||
|
"version": 1,
|
||||||
|
"advertiser": "98:E2:55:07:DF:00",
|
||||||
|
"address_type": 0,
|
||||||
|
"event_type": 0,
|
||||||
|
"rssi": -67,
|
||||||
|
"pid": "2066",
|
||||||
|
"console": "48:F1:EB:F7:D6:BD",
|
||||||
|
"raw_hex": RAW_HEX,
|
||||||
|
"esphome_payload_hex": RAW_HEX[14:],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_capture_generates_build_configuration(tmp_path: Path) -> None:
|
||||||
|
capture_log = tmp_path / "capture.log"
|
||||||
|
capture_log.write_text(
|
||||||
|
"scanner startup\nSWITCH2_WAKE_CAPTURE " + json.dumps(RECORD) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
output = tmp_path / "switch2_wake_config.h"
|
||||||
|
|
||||||
|
assert configure.main(
|
||||||
|
["--input", str(capture_log), "--output", str(output)]
|
||||||
|
) == 0
|
||||||
|
generated = output.read_text(encoding="utf-8")
|
||||||
|
assert "#define SWITCH2_WAKE_CONFIGURED 1" in generated
|
||||||
|
assert "0x98, 0xE2, 0x55, 0x07, 0xDF, 0x00" in generated
|
||||||
|
assert "0x02, 0x01, 0x06, 0x1B, 0xFF" in generated
|
||||||
|
assert "Target Switch 2: 48:F1:EB:F7:D6:BD" in generated
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value", "message"),
|
||||||
|
[
|
||||||
|
("address_type", 1, "public advertiser"),
|
||||||
|
("event_type", 3, "ADV_IND"),
|
||||||
|
("console", "48:F1:EB:F7:D6:BE", "console address"),
|
||||||
|
("pid", "2067", "PID"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_inconsistent_capture_is_rejected(field, value, message) -> None:
|
||||||
|
record = dict(RECORD)
|
||||||
|
record[field] = value
|
||||||
|
with pytest.raises(configure.CaptureError, match=message):
|
||||||
|
configure.validate_capture(record)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_wake_packet_is_rejected() -> None:
|
||||||
|
record = dict(RECORD)
|
||||||
|
raw = bytearray.fromhex(RAW_HEX)
|
||||||
|
raw[16] = 0
|
||||||
|
record["raw_hex"] = raw.hex()
|
||||||
|
with pytest.raises(configure.CaptureError, match="wake flag"):
|
||||||
|
configure.validate_capture(record)
|
||||||
28
tests/test_switch2_wake_native.py
Normal file
28
tests/test_switch2_wake_native.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_switch2_wake_native(tmp_path: Path) -> None:
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
compiler = shutil.which("c++") or shutil.which("g++")
|
||||||
|
assert compiler is not None, "a host C++ compiler is required"
|
||||||
|
executable = tmp_path / "switch2_wake_test"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
compiler,
|
||||||
|
"-std=c++17",
|
||||||
|
"-Wall",
|
||||||
|
"-Wextra",
|
||||||
|
"-Werror",
|
||||||
|
"-pedantic",
|
||||||
|
f"-I{root / 'tests' / 'switch2_wake_native_stubs'}",
|
||||||
|
f"-I{root / 'src' / 'firmware'}",
|
||||||
|
str(root / "tests" / "switch2_wake_test.cpp"),
|
||||||
|
"-o",
|
||||||
|
str(executable),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
cwd=root,
|
||||||
|
)
|
||||||
|
subprocess.run([str(executable)], check=True, cwd=root)
|
||||||
213
tools/configure_switch2_wake.py
Executable file
213
tools/configure_switch2_wake.py
Executable file
|
|
@ -0,0 +1,213 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture a Switch 2 wake record and generate the AIO firmware header."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DEFAULT_OUTPUT = (
|
||||||
|
ROOT / "src" / "firmware" / "platform" / "pico" /
|
||||||
|
"switch2_wake_config.h"
|
||||||
|
)
|
||||||
|
CAPTURE_PREFIX = "SWITCH2_WAKE_CAPTURE "
|
||||||
|
ADDRESS_PATTERN = re.compile(r"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
|
||||||
|
EXPECTED_PREFIX = bytes.fromhex("0201061BFF53050100037E05")
|
||||||
|
EXPECTED_SUFFIX = bytes.fromhex("0F00000000000000")
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_address(value, field):
|
||||||
|
if not isinstance(value, str) or ADDRESS_PATTERN.fullmatch(value) is None:
|
||||||
|
raise CaptureError(f"{field} must be a six-byte Bluetooth address.")
|
||||||
|
normalized = value.upper()
|
||||||
|
if normalized == "00:00:00:00:00:00":
|
||||||
|
raise CaptureError(f"{field} must not be all zeroes.")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def parse_capture_line(line):
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith(CAPTURE_PREFIX):
|
||||||
|
line = line[len(CAPTURE_PREFIX):]
|
||||||
|
if not line.startswith("{"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
record = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
if not isinstance(record, dict) or "raw_hex" not in record:
|
||||||
|
return None
|
||||||
|
return validate_capture(record)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_capture(record):
|
||||||
|
if record.get("version", 1) != 1:
|
||||||
|
raise CaptureError("unsupported capture version")
|
||||||
|
if record.get("address_type") != 0:
|
||||||
|
raise CaptureError("capture must use a public advertiser address")
|
||||||
|
if record.get("event_type") != 0:
|
||||||
|
raise CaptureError("capture must be an ADV_IND wake advertisement")
|
||||||
|
advertiser = normalize_address(record.get("advertiser"), "advertiser")
|
||||||
|
console = normalize_address(record.get("console"), "console")
|
||||||
|
raw_hex = record.get("raw_hex")
|
||||||
|
if not isinstance(raw_hex, str) or re.fullmatch(r"[0-9A-Fa-f]{62}", raw_hex) is None:
|
||||||
|
raise CaptureError("raw_hex must contain exactly 31 bytes")
|
||||||
|
raw = bytes.fromhex(raw_hex)
|
||||||
|
if raw[:12] != EXPECTED_PREFIX:
|
||||||
|
raise CaptureError("capture is not a Nintendo Switch 2 advertisement")
|
||||||
|
if raw[14:16] != b"\x00\x01":
|
||||||
|
raise CaptureError("capture has an unexpected reconnect marker")
|
||||||
|
if raw[16] != 0x81:
|
||||||
|
raise CaptureError("capture does not contain the Switch 2 wake flag")
|
||||||
|
if raw[23:] != EXPECTED_SUFFIX:
|
||||||
|
raise CaptureError("capture has an unexpected reserved suffix")
|
||||||
|
decoded_console = ":".join(f"{byte:02X}" for byte in reversed(raw[17:23]))
|
||||||
|
if decoded_console != console:
|
||||||
|
raise CaptureError("console address does not match raw wake data")
|
||||||
|
pid = int.from_bytes(raw[12:14], "little")
|
||||||
|
if record.get("pid", f"{pid:04X}").upper() != f"{pid:04X}":
|
||||||
|
raise CaptureError("PID does not match raw wake data")
|
||||||
|
payload = raw[7:].hex().upper()
|
||||||
|
supplied_payload = record.get("esphome_payload_hex", payload)
|
||||||
|
if supplied_payload.upper() != payload:
|
||||||
|
raise CaptureError("manufacturer payload does not match raw wake data")
|
||||||
|
return {
|
||||||
|
"advertiser": advertiser,
|
||||||
|
"console": console,
|
||||||
|
"pid": pid,
|
||||||
|
"raw": raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def byte_initializer(data):
|
||||||
|
return ", ".join(f"0x{byte:02X}" for byte in data)
|
||||||
|
|
||||||
|
|
||||||
|
def render_header(capture):
|
||||||
|
source = bytes.fromhex(capture["advertiser"].replace(":", ""))
|
||||||
|
return (
|
||||||
|
"#pragma once\n\n"
|
||||||
|
"// Generated by tools/configure_switch2_wake.py. Do not commit this file.\n"
|
||||||
|
f"// Captured source controller: {capture['advertiser']}\n"
|
||||||
|
f"// Target Switch 2: {capture['console']}; PID: 0x{capture['pid']:04X}\n"
|
||||||
|
"#define SWITCH2_WAKE_CONFIGURED 1\n"
|
||||||
|
"#define SWITCH2_WAKE_SOURCE_ADDRESS_BYTES \\\n"
|
||||||
|
f" {{{byte_initializer(source)}}}\n"
|
||||||
|
"#define SWITCH2_WAKE_ADVERTISEMENT_DATA_BYTES \\\n"
|
||||||
|
f" {{{byte_initializer(capture['raw'])}}}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def capture_from_stream(stream, timeout):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
line = stream.readline()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if isinstance(line, bytes):
|
||||||
|
line = line.decode("utf-8", errors="replace")
|
||||||
|
print(line.rstrip())
|
||||||
|
capture = parse_capture_line(line)
|
||||||
|
if capture is not None:
|
||||||
|
return capture
|
||||||
|
raise CaptureError("timed out waiting for a Switch 2 wake capture")
|
||||||
|
|
||||||
|
|
||||||
|
def pico_serial_ports():
|
||||||
|
try:
|
||||||
|
from serial.tools import list_ports
|
||||||
|
except ImportError as exc:
|
||||||
|
raise CaptureError(
|
||||||
|
"pyserial is required; install project dependencies first"
|
||||||
|
) from exc
|
||||||
|
return [port.device for port in list_ports.comports() if port.vid == 0x2E8A]
|
||||||
|
|
||||||
|
|
||||||
|
def capture_from_pico(port, timeout):
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError as exc:
|
||||||
|
raise CaptureError(
|
||||||
|
"pyserial is required; install project dependencies first"
|
||||||
|
) from exc
|
||||||
|
if port is None:
|
||||||
|
ports = pico_serial_ports()
|
||||||
|
if len(ports) != 1:
|
||||||
|
detail = ", ".join(ports) if ports else "none"
|
||||||
|
raise CaptureError(
|
||||||
|
f"expected one Pico USB serial port, found {detail}; use --port"
|
||||||
|
)
|
||||||
|
port = ports[0]
|
||||||
|
print(f"Waiting for automatic wake capture on {port}...")
|
||||||
|
with serial.Serial(port, 115200, timeout=0.25, write_timeout=1) as device:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
next_request = 0.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
now = time.monotonic()
|
||||||
|
if now >= next_request:
|
||||||
|
device.write(b"p\n")
|
||||||
|
next_request = now + 1.0
|
||||||
|
line = device.readline()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
text = line.decode("utf-8", errors="replace").strip()
|
||||||
|
print(text)
|
||||||
|
capture = parse_capture_line(text)
|
||||||
|
if capture is not None:
|
||||||
|
return capture
|
||||||
|
raise CaptureError("timed out waiting for a Switch 2 wake capture")
|
||||||
|
|
||||||
|
|
||||||
|
def write_header(path, capture):
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(render_header(capture), encoding="utf-8")
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Read the one-shot Pico wake capture and configure the AIO build."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("--port", help="Pico USB serial port; auto-detected by default")
|
||||||
|
parser.add_argument(
|
||||||
|
"--input",
|
||||||
|
type=Path,
|
||||||
|
help="read a saved capture log instead of a live Pico",
|
||||||
|
)
|
||||||
|
parser.add_argument("--timeout", type=float, default=90.0)
|
||||||
|
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
args = parse_args(argv)
|
||||||
|
try:
|
||||||
|
if args.timeout <= 0:
|
||||||
|
raise CaptureError("timeout must be positive")
|
||||||
|
if args.input is None:
|
||||||
|
capture = capture_from_pico(args.port, args.timeout)
|
||||||
|
else:
|
||||||
|
with args.input.open("r", encoding="utf-8") as stream:
|
||||||
|
capture = capture_from_stream(stream, args.timeout)
|
||||||
|
write_header(args.output, capture)
|
||||||
|
except (CaptureError, OSError) as exc:
|
||||||
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"Configured Switch 2 wake: {args.output}")
|
||||||
|
print("Build and flash it with: python3 build.py --aio")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
19
tools/switch2_wake_capture/CMakeLists.txt
Normal file
19
tools/switch2_wake_capture/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
cmake_minimum_required(VERSION 3.13)
|
||||||
|
set(PICO_BOARD pico2_w CACHE STRING "Target board")
|
||||||
|
include(${CMAKE_CURRENT_LIST_DIR}/../../pico_sdk_import.cmake)
|
||||||
|
project(switch2_wake_capture C CXX ASM)
|
||||||
|
set(CMAKE_C_STANDARD 11)
|
||||||
|
pico_sdk_init()
|
||||||
|
add_executable(switch2-wake-capture main.c)
|
||||||
|
target_include_directories(switch2-wake-capture PRIVATE ${CMAKE_CURRENT_LIST_DIR})
|
||||||
|
target_link_libraries(switch2-wake-capture PRIVATE
|
||||||
|
pico_stdlib
|
||||||
|
pico_cyw43_arch_none
|
||||||
|
pico_btstack_cyw43
|
||||||
|
pico_btstack_ble
|
||||||
|
)
|
||||||
|
pico_enable_stdio_usb(switch2-wake-capture 1)
|
||||||
|
pico_enable_stdio_uart(switch2-wake-capture 0)
|
||||||
|
pico_set_program_name(switch2-wake-capture "Switch 2 wake capture")
|
||||||
|
pico_set_program_version(switch2-wake-capture "1.0")
|
||||||
|
pico_add_extra_outputs(switch2-wake-capture)
|
||||||
30
tools/switch2_wake_capture/btstack_config.h
Normal file
30
tools/switch2_wake_capture/btstack_config.h
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
#ifndef SWITCH2_WAKE_CAPTURE_BTSTACK_CONFIG_H
|
||||||
|
#define SWITCH2_WAKE_CAPTURE_BTSTACK_CONFIG_H
|
||||||
|
|
||||||
|
#define ENABLE_LE_CENTRAL
|
||||||
|
#define ENABLE_LE_PERIPHERAL
|
||||||
|
#define ENABLE_LOG_ERROR
|
||||||
|
#define ENABLE_PRINTF_HEXDUMP
|
||||||
|
#define HAVE_EMBEDDED_TIME_MS
|
||||||
|
#define HAVE_ASSERT
|
||||||
|
#define HCI_OUTGOING_PRE_BUFFER_SIZE 4
|
||||||
|
#define HCI_ACL_PAYLOAD_SIZE (1691 + 4)
|
||||||
|
#define HCI_ACL_CHUNK_SIZE_ALIGNMENT 4
|
||||||
|
#define MAX_NR_HCI_CONNECTIONS 1
|
||||||
|
#define MAX_NR_GATT_CLIENTS 1
|
||||||
|
#define MAX_NR_L2CAP_CHANNELS 2
|
||||||
|
#define MAX_NR_L2CAP_SERVICES 2
|
||||||
|
#define MAX_NR_SM_LOOKUP_ENTRIES 1
|
||||||
|
#define MAX_NR_WHITELIST_ENTRIES 1
|
||||||
|
#define MAX_NR_LE_DEVICE_DB_ENTRIES 16
|
||||||
|
#define NVM_NUM_DEVICE_DB_ENTRIES 16
|
||||||
|
#define MAX_ATT_DB_SIZE 128
|
||||||
|
#define MAX_NR_CONTROLLER_ACL_BUFFERS 3
|
||||||
|
#define ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL
|
||||||
|
#define HCI_HOST_ACL_PACKET_LEN 1024
|
||||||
|
#define HCI_HOST_ACL_PACKET_NUM 3
|
||||||
|
#define HCI_HOST_SCO_PACKET_LEN 120
|
||||||
|
#define HCI_HOST_SCO_PACKET_NUM 3
|
||||||
|
#define HCI_RESET_RESEND_TIMEOUT_MS 1000
|
||||||
|
|
||||||
|
#endif
|
||||||
152
tools/switch2_wake_capture/main.c
Normal file
152
tools/switch2_wake_capture/main.c
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "pico/cyw43_arch.h"
|
||||||
|
#include "pico/stdio_usb.h"
|
||||||
|
#include "pico/stdlib.h"
|
||||||
|
#include "btstack.h"
|
||||||
|
#include "nintendo_wake.h"
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
bd_addr_t advertiser;
|
||||||
|
uint8_t address_type;
|
||||||
|
uint8_t event_type;
|
||||||
|
int8_t rssi;
|
||||||
|
uint8_t data[31];
|
||||||
|
} wake_capture_t;
|
||||||
|
|
||||||
|
static wake_capture_t capture;
|
||||||
|
static bool captured;
|
||||||
|
static bool scan_started;
|
||||||
|
static bool usb_was_connected;
|
||||||
|
static uint32_t last_print_ms;
|
||||||
|
static btstack_packet_callback_registration_t registration;
|
||||||
|
static btstack_timer_source_t output_timer;
|
||||||
|
|
||||||
|
static void to_hex(const uint8_t* bytes, size_t size, char* output) {
|
||||||
|
static const char digits[] = "0123456789ABCDEF";
|
||||||
|
for (size_t index = 0; index < size; ++index) {
|
||||||
|
output[index * 2] = digits[bytes[index] >> 4];
|
||||||
|
output[index * 2 + 1] = digits[bytes[index] & 0x0f];
|
||||||
|
}
|
||||||
|
output[size * 2] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void print_capture(void) {
|
||||||
|
const uint8_t* manufacturer = &capture.data[5];
|
||||||
|
char raw_hex[63];
|
||||||
|
char payload_hex[49];
|
||||||
|
to_hex(capture.data, sizeof(capture.data), raw_hex);
|
||||||
|
// ESPHome-style payload excludes the two-byte Nintendo company ID.
|
||||||
|
to_hex(&manufacturer[2], 24, payload_hex);
|
||||||
|
printf("SWITCH2_WAKE_CAPTURE {\"version\":1,\"advertiser\":"
|
||||||
|
"\"%02X:%02X:%02X:%02X:%02X:%02X\",\"address_type\":%u,"
|
||||||
|
"\"event_type\":%u,\"rssi\":%d,\"pid\":\"%04X\","
|
||||||
|
"\"console\":\"%02X:%02X:%02X:%02X:%02X:%02X\","
|
||||||
|
"\"raw_hex\":\"%s\",\"esphome_payload_hex\":\"%s\"}\n",
|
||||||
|
capture.advertiser[0], capture.advertiser[1],
|
||||||
|
capture.advertiser[2], capture.advertiser[3],
|
||||||
|
capture.advertiser[4], capture.advertiser[5],
|
||||||
|
capture.address_type, capture.event_type, (int)capture.rssi,
|
||||||
|
(unsigned)(manufacturer[7] | (manufacturer[8] << 8)),
|
||||||
|
manufacturer[17], manufacturer[16], manufacturer[15],
|
||||||
|
manufacturer[14], manufacturer[13], manufacturer[12],
|
||||||
|
raw_hex, payload_hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void output_task(btstack_timer_source_t* timer) {
|
||||||
|
const uint32_t now_ms = btstack_run_loop_get_time_ms();
|
||||||
|
const bool usb_connected = stdio_usb_connected();
|
||||||
|
if (usb_connected && !usb_was_connected) {
|
||||||
|
puts("Switch 2 wake capture ready. Put the console to sleep, then wake it with a paired Joy-Con 2 HOME button.");
|
||||||
|
puts("Scanning stops automatically after one valid public ADV_IND wake packet.");
|
||||||
|
if (captured) {
|
||||||
|
print_capture();
|
||||||
|
last_print_ms = now_ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (usb_connected) {
|
||||||
|
int key;
|
||||||
|
while ((key = getchar_timeout_us(0)) != PICO_ERROR_TIMEOUT) {
|
||||||
|
if ((key == 'p' || key == 'P') && captured) {
|
||||||
|
print_capture();
|
||||||
|
last_print_ms = now_ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Repeat for a generator attached after capture; scanning remains stopped.
|
||||||
|
if (captured && now_ms - last_print_ms >= 1000) {
|
||||||
|
print_capture();
|
||||||
|
last_print_ms = now_ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usb_was_connected = usb_connected;
|
||||||
|
static bool blink;
|
||||||
|
blink = !blink;
|
||||||
|
cyw43_arch_gpio_put(
|
||||||
|
CYW43_WL_GPIO_LED_PIN, captured || (scan_started && blink));
|
||||||
|
btstack_run_loop_set_timer(timer, 250);
|
||||||
|
btstack_run_loop_add_timer(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_packet(uint8_t packet_type, uint16_t,
|
||||||
|
uint8_t* packet, uint16_t size) {
|
||||||
|
if (packet_type != HCI_EVENT_PACKET || size < 2 || captured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uint8_t event_type = hci_event_packet_get_type(packet);
|
||||||
|
if (event_type == BTSTACK_EVENT_STATE && size >= 3 &&
|
||||||
|
btstack_event_state_get_state(packet) == HCI_STATE_WORKING) {
|
||||||
|
gap_set_scan_params(0, 96, 96, 0); // Passive, continuous, accept all.
|
||||||
|
gap_set_scan_duplicate_filter(false);
|
||||||
|
gap_start_scan();
|
||||||
|
scan_started = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event_type != GAP_EVENT_ADVERTISING_REPORT || size < 12) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uint8_t length =
|
||||||
|
gap_event_advertising_report_get_data_length(packet);
|
||||||
|
if (length != sizeof(capture.data) || size < 12u + length ||
|
||||||
|
gap_event_advertising_report_get_address_type(packet) !=
|
||||||
|
BD_ADDR_TYPE_LE_PUBLIC ||
|
||||||
|
gap_event_advertising_report_get_advertising_event_type(packet) != 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uint8_t* data =
|
||||||
|
gap_event_advertising_report_get_data(packet);
|
||||||
|
if (switch2_wake_manufacturer(data, length) == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gap_event_advertising_report_get_address(packet, capture.advertiser);
|
||||||
|
capture.address_type = BD_ADDR_TYPE_LE_PUBLIC;
|
||||||
|
capture.event_type = 0;
|
||||||
|
capture.rssi =
|
||||||
|
(int8_t)gap_event_advertising_report_get_rssi(packet);
|
||||||
|
memcpy(capture.data, data, sizeof(capture.data));
|
||||||
|
captured = true;
|
||||||
|
scan_started = false;
|
||||||
|
gap_stop_scan();
|
||||||
|
if (stdio_usb_connected()) {
|
||||||
|
print_capture();
|
||||||
|
last_print_ms = btstack_run_loop_get_time_ms();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
stdio_init_all();
|
||||||
|
if (cyw43_arch_init() != 0) {
|
||||||
|
while (true) {
|
||||||
|
puts("ERROR: CYW43 initialization failed");
|
||||||
|
sleep_ms(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registration.callback = handle_packet;
|
||||||
|
hci_add_event_handler(®istration);
|
||||||
|
btstack_run_loop_set_timer_handler(&output_timer, output_task);
|
||||||
|
btstack_run_loop_set_timer(&output_timer, 250);
|
||||||
|
btstack_run_loop_add_timer(&output_timer);
|
||||||
|
hci_power_control(HCI_POWER_ON);
|
||||||
|
btstack_run_loop_execute();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
34
tools/switch2_wake_capture/nintendo_wake.h
Normal file
34
tools/switch2_wake_capture/nintendo_wake.h
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#ifndef SWITCH2_WAKE_CAPTURE_NINTENDO_WAKE_H
|
||||||
|
#define SWITCH2_WAKE_CAPTURE_NINTENDO_WAKE_H
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static inline const uint8_t* switch2_wake_manufacturer(
|
||||||
|
const uint8_t* data, size_t size) {
|
||||||
|
static const uint8_t prefix[] = {
|
||||||
|
0x53, 0x05, 0x01, 0x00, 0x03, 0x7e, 0x05,
|
||||||
|
};
|
||||||
|
static const uint8_t suffix[] = {
|
||||||
|
0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
};
|
||||||
|
if (size != 31 || memcmp(data, "\x02\x01\x06\x1b\xff", 5) != 0) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
const uint8_t* manufacturer = &data[5];
|
||||||
|
if (memcmp(manufacturer, prefix, sizeof(prefix)) != 0 ||
|
||||||
|
manufacturer[9] != 0x00 || manufacturer[10] != 0x01 ||
|
||||||
|
manufacturer[11] != 0x81 ||
|
||||||
|
memcmp(&manufacturer[18], suffix, sizeof(suffix)) != 0) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
uint8_t target_any = 0;
|
||||||
|
for (size_t index = 12; index < 18; ++index) {
|
||||||
|
target_any |= manufacturer[index];
|
||||||
|
}
|
||||||
|
return target_any == 0 ? NULL : manufacturer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
Loading…
Add table
Add a link
Reference in a new issue