From d5afadf560d764bf18f1dda7653c342fd9235e55 Mon Sep 17 00:00:00 2001 From: bybrooklyn Date: Fri, 27 Feb 2026 20:43:34 -0500 Subject: [PATCH] Bootstrap OpenBitdo clean-room SDK and reliability milestone --- .github/workflows/ci.yml | 55 ++ .gitignore | 13 + README.md | 8 + harness/golden/mock_trace_get_mode.json | 1 + harness/golden/mock_trace_get_pid.json | 1 + harness/golden/profile_fixture.bin | Bin 0 -> 27 bytes harness/lab/device_lab.yaml | 26 + process/branch_policy.md | 16 + process/cleanroom_rules.md | 23 + sdk/Cargo.lock | 522 ++++++++++++++ sdk/Cargo.toml | 19 + sdk/README.md | 30 + sdk/crates/bitdo_proto/Cargo.toml | 76 +++ sdk/crates/bitdo_proto/build.rs | 121 ++++ sdk/crates/bitdo_proto/src/command.rs | 60 ++ sdk/crates/bitdo_proto/src/error.rs | 65 ++ sdk/crates/bitdo_proto/src/frame.rs | 56 ++ sdk/crates/bitdo_proto/src/hid_transport.rs | 125 ++++ sdk/crates/bitdo_proto/src/lib.rs | 30 + sdk/crates/bitdo_proto/src/profile.rs | 63 ++ sdk/crates/bitdo_proto/src/registry.rs | 82 +++ sdk/crates/bitdo_proto/src/session.rs | 715 ++++++++++++++++++++ sdk/crates/bitdo_proto/src/transport.rs | 126 ++++ sdk/crates/bitdo_proto/src/types.rs | 116 ++++ sdk/crates/bitdoctl/Cargo.toml | 20 + sdk/crates/bitdoctl/src/main.rs | 518 ++++++++++++++ sdk/scripts/cleanroom_guard.sh | 15 + sdk/scripts/run_hardware_smoke.sh | 45 ++ sdk/tests/boot_safety.rs | 41 ++ sdk/tests/capability_gating.rs | 23 + sdk/tests/cleanroom_guard.rs | 17 + sdk/tests/cli_snapshot.rs | 66 ++ sdk/tests/diag_probe.rs | 45 ++ sdk/tests/error_codes.rs | 10 + sdk/tests/firmware_chunk.rs | 30 + sdk/tests/frame_roundtrip.rs | 23 + sdk/tests/hardware_smoke.rs | 96 +++ sdk/tests/mode_switch_readback.rs | 34 + sdk/tests/parser_rejection.rs | 29 + sdk/tests/pid_matrix_coverage.rs | 16 + sdk/tests/profile_serialization.rs | 17 + sdk/tests/retry_timeout.rs | 86 +++ spec/command_matrix.csv | 18 + spec/pid_matrix.csv | 60 ++ spec/protocol_spec.md | 42 ++ spec/requirements.yaml | 52 ++ 46 files changed, 3652 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 harness/golden/mock_trace_get_mode.json create mode 100644 harness/golden/mock_trace_get_pid.json create mode 100644 harness/golden/profile_fixture.bin create mode 100644 harness/lab/device_lab.yaml create mode 100644 process/branch_policy.md create mode 100644 process/cleanroom_rules.md create mode 100644 sdk/Cargo.lock create mode 100644 sdk/Cargo.toml create mode 100644 sdk/README.md create mode 100644 sdk/crates/bitdo_proto/Cargo.toml create mode 100644 sdk/crates/bitdo_proto/build.rs create mode 100644 sdk/crates/bitdo_proto/src/command.rs create mode 100644 sdk/crates/bitdo_proto/src/error.rs create mode 100644 sdk/crates/bitdo_proto/src/frame.rs create mode 100644 sdk/crates/bitdo_proto/src/hid_transport.rs create mode 100644 sdk/crates/bitdo_proto/src/lib.rs create mode 100644 sdk/crates/bitdo_proto/src/profile.rs create mode 100644 sdk/crates/bitdo_proto/src/registry.rs create mode 100644 sdk/crates/bitdo_proto/src/session.rs create mode 100644 sdk/crates/bitdo_proto/src/transport.rs create mode 100644 sdk/crates/bitdo_proto/src/types.rs create mode 100644 sdk/crates/bitdoctl/Cargo.toml create mode 100644 sdk/crates/bitdoctl/src/main.rs create mode 100755 sdk/scripts/cleanroom_guard.sh create mode 100755 sdk/scripts/run_hardware_smoke.sh create mode 100644 sdk/tests/boot_safety.rs create mode 100644 sdk/tests/capability_gating.rs create mode 100644 sdk/tests/cleanroom_guard.rs create mode 100644 sdk/tests/cli_snapshot.rs create mode 100644 sdk/tests/diag_probe.rs create mode 100644 sdk/tests/error_codes.rs create mode 100644 sdk/tests/firmware_chunk.rs create mode 100644 sdk/tests/frame_roundtrip.rs create mode 100644 sdk/tests/hardware_smoke.rs create mode 100644 sdk/tests/mode_switch_readback.rs create mode 100644 sdk/tests/parser_rejection.rs create mode 100644 sdk/tests/pid_matrix_coverage.rs create mode 100644 sdk/tests/profile_serialization.rs create mode 100644 sdk/tests/retry_timeout.rs create mode 100644 spec/command_matrix.csv create mode 100644 spec/pid_matrix.csv create mode 100644 spec/protocol_spec.md create mode 100644 spec/requirements.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e7a7ca1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + paths: + - '**' + pull_request: + paths: + - '**' + workflow_dispatch: + +jobs: + guard: + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk + steps: + - uses: actions/checkout@v4 + - name: Clean-room guard + run: ./scripts/cleanroom_guard.sh + + test: + runs-on: ubuntu-latest + needs: guard + defaults: + run: + working-directory: sdk + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Run tests + run: cargo test --workspace --all-targets + + hardware-smoke: + runs-on: [self-hosted, linux, hid-lab] + if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + continue-on-error: true + needs: test + defaults: + run: + working-directory: sdk + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Hardware smoke tests + run: ./scripts/run_hardware_smoke.sh + - name: Upload hardware smoke report + if: always() + uses: actions/upload-artifact@v4 + with: + name: hardware-smoke-report + path: harness/reports/*.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1a4002 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Rust +sdk/target/ + +# Reports +harness/reports/*.json + +# OS/editor +.DS_Store +Thumbs.db +*.swp +*.swo +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..906e51c --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# OpenBitdo + +OpenBitdo is a clean-room implementation workspace for 8BitDo protocol tooling. + +- `spec/`: sanitized protocol, requirements, PID matrix, command matrix +- `process/`: clean-room workflow and branch policy documents +- `harness/`: golden fixtures, lab config, and hardware smoke reports +- `sdk/`: Rust workspace (`bitdo_proto` library + `bitdoctl` CLI) diff --git a/harness/golden/mock_trace_get_mode.json b/harness/golden/mock_trace_get_mode.json new file mode 100644 index 0000000..2911ad2 --- /dev/null +++ b/harness/golden/mock_trace_get_mode.json @@ -0,0 +1 @@ +{"command":"GetMode","request_hex":"810405...","response_hex":"0205000000000200000000000000000000000000000000000000000000000000"} diff --git a/harness/golden/mock_trace_get_pid.json b/harness/golden/mock_trace_get_pid.json new file mode 100644 index 0000000..5d22f80 --- /dev/null +++ b/harness/golden/mock_trace_get_pid.json @@ -0,0 +1 @@ +{"command":"GetPid","request_hex":"8105c1...","response_hex":"0205c10000000000000000000000000000000000000000005960000000000000"} diff --git a/harness/golden/profile_fixture.bin b/harness/golden/profile_fixture.bin new file mode 100644 index 0000000000000000000000000000000000000000..e2a91370fdeff17ef39223907a6960fc7b763d06 GIT binary patch literal 27 icmZ>A2{2?5U|?ioW?^Mx=iubx=Hcbz7nsGszyJUvrvf|x literal 0 HcmV?d00001 diff --git a/harness/lab/device_lab.yaml b/harness/lab/device_lab.yaml new file mode 100644 index 0000000..478b81a --- /dev/null +++ b/harness/lab/device_lab.yaml @@ -0,0 +1,26 @@ +version: 1 +devices: + - name: Pro3 + vid: 0x2dc8 + pid: 0x6009 + capability: + supports_mode: true + supports_profile_rw: true + supports_boot: true + supports_firmware: true + protocol_family: DInput + evidence: confirmed + - name: UltimateBT2 + vid: 0x2dc8 + pid: 0x600f + capability: + supports_mode: true + supports_profile_rw: true + supports_boot: true + supports_firmware: true + protocol_family: DInput + evidence: confirmed +policies: + require_mock_suite: true + hardware_jobs_non_blocking: true + promote_after_stable_weeks: 2 diff --git a/process/branch_policy.md b/process/branch_policy.md new file mode 100644 index 0000000..52b5f09 --- /dev/null +++ b/process/branch_policy.md @@ -0,0 +1,16 @@ +# Branch and Merge Policy + +Because this workspace currently has no active Git repository metadata, this policy is documented for use when repository control is re-enabled. + +## Branches +- `codex/dirtyroom-spec`: sanitize findings into `cleanroom/spec` and `cleanroom/process` +- `codex/cleanroom-sdk`: implement SDK and CLI from sanitized artifacts only + +## Merge Strategy +- Cherry-pick sanitized spec commits from dirtyroom branch into cleanroom branch. +- Never merge dirty-room evidence paths into cleanroom implementation branch. + +## Review Checklist +- Guard script passes. +- No forbidden path references in code/tests. +- Requirement IDs are traceable from implementation and tests. diff --git a/process/cleanroom_rules.md b/process/cleanroom_rules.md new file mode 100644 index 0000000..bc1de58 --- /dev/null +++ b/process/cleanroom_rules.md @@ -0,0 +1,23 @@ +# Clean-Room Rules + +## Allowed Inputs During Clean Implementation +- `cleanroom/spec/**` +- `cleanroom/process/cleanroom_rules.md` +- `cleanroom/harness/golden/**` + +## Forbidden Inputs During Clean Implementation +- `decompiled/**` +- `decompiled_*/*` +- `bundle_extract/**` +- `extracted/**` +- `extracted_net/**` +- `session-ses_35e4.md` + +## Enforcement +- `cleanroom/sdk/scripts/cleanroom_guard.sh` checks for forbidden path and token references. +- CI runs the guard before test jobs. + +## Commit Hygiene +- No copied decompiled code snippets. +- No direct references to dirty-room files in SDK implementation/tests. +- Any new protocol fact must be added to sanitized spec artifacts first. diff --git a/sdk/Cargo.lock b/sdk/Cargo.lock new file mode 100644 index 0000000..bb990c9 --- /dev/null +++ b/sdk/Cargo.lock @@ -0,0 +1,522 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert_cmd" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitdo_proto" +version = "0.1.0" +dependencies = [ + "csv", + "hex", + "hidapi", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "bitdoctl" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_cmd", + "bitdo_proto", + "clap", + "hex", + "predicates", + "serde_json", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hidapi" +version = "2.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1b71e1f4791fb9e93b9d7ee03d70b501ab48f6151432fbcadeabc30fe15396e" +dependencies = [ + "cc", + "cfg-if", + "libc", + "pkg-config", + "windows-sys", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml new file mode 100644 index 0000000..5494ceb --- /dev/null +++ b/sdk/Cargo.toml @@ -0,0 +1,19 @@ +[workspace] +members = [ + "crates/bitdo_proto", + "crates/bitdoctl", +] +resolver = "2" + +[workspace.package] +edition = "2021" +version = "0.1.0" +license = "MIT" + +[workspace.dependencies] +anyhow = "1.0" +thiserror = "2.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +clap = { version = "4.5", features = ["derive"] } +hex = "0.4" diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..4765cab --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,30 @@ +# OpenBitdo SDK + +`bitdo_proto` and `bitdoctl` provide the clean-room protocol core and CLI. + +## Build +```bash +cargo build --workspace +``` + +## Test +```bash +cargo test --workspace --all-targets +``` + +## Guard +```bash +./scripts/cleanroom_guard.sh +``` + +## Hardware smoke report +```bash +./scripts/run_hardware_smoke.sh +``` + +## CLI examples +```bash +cargo run -p bitdoctl -- --mock list +cargo run -p bitdoctl -- --mock --json --pid 24585 identify +cargo run -p bitdoctl -- --mock --json --pid 24585 diag probe +``` diff --git a/sdk/crates/bitdo_proto/Cargo.toml b/sdk/crates/bitdo_proto/Cargo.toml new file mode 100644 index 0000000..7252023 --- /dev/null +++ b/sdk/crates/bitdo_proto/Cargo.toml @@ -0,0 +1,76 @@ +[package] +name = "bitdo_proto" +version = "0.1.0" +edition = "2021" +license = "MIT" +build = "build.rs" + +[features] +default = ["hidapi-backend"] +hidapi-backend = ["dep:hidapi"] + +[dependencies] +thiserror = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +hex = { workspace = true } +hidapi = { version = "2.6", optional = true } + +[build-dependencies] +csv = "1.3" + +[dev-dependencies] +serde_json = { workspace = true } +hex = { workspace = true } + +[[test]] +name = "frame_roundtrip" +path = "../../tests/frame_roundtrip.rs" + +[[test]] +name = "parser_rejection" +path = "../../tests/parser_rejection.rs" + +[[test]] +name = "retry_timeout" +path = "../../tests/retry_timeout.rs" + +[[test]] +name = "pid_matrix_coverage" +path = "../../tests/pid_matrix_coverage.rs" + +[[test]] +name = "capability_gating" +path = "../../tests/capability_gating.rs" + +[[test]] +name = "profile_serialization" +path = "../../tests/profile_serialization.rs" + +[[test]] +name = "mode_switch_readback" +path = "../../tests/mode_switch_readback.rs" + +[[test]] +name = "boot_safety" +path = "../../tests/boot_safety.rs" + +[[test]] +name = "firmware_chunk" +path = "../../tests/firmware_chunk.rs" + +[[test]] +name = "cleanroom_guard" +path = "../../tests/cleanroom_guard.rs" + +[[test]] +name = "hardware_smoke" +path = "../../tests/hardware_smoke.rs" + +[[test]] +name = "error_codes" +path = "../../tests/error_codes.rs" + +[[test]] +name = "diag_probe" +path = "../../tests/diag_probe.rs" diff --git a/sdk/crates/bitdo_proto/build.rs b/sdk/crates/bitdo_proto/build.rs new file mode 100644 index 0000000..dfe3fe6 --- /dev/null +++ b/sdk/crates/bitdo_proto/build.rs @@ -0,0 +1,121 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +fn main() { + let manifest_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_MANIFEST_DIR")); + let spec_dir = manifest_dir.join("../../../spec"); + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("missing OUT_DIR")); + + let pid_csv = spec_dir.join("pid_matrix.csv"); + let command_csv = spec_dir.join("command_matrix.csv"); + + println!("cargo:rerun-if-changed={}", pid_csv.display()); + println!("cargo:rerun-if-changed={}", command_csv.display()); + + generate_pid_registry(&pid_csv, &out_dir.join("generated_pid_registry.rs")); + generate_command_registry(&command_csv, &out_dir.join("generated_command_registry.rs")); +} + +fn generate_pid_registry(csv_path: &Path, out_path: &Path) { + let mut rdr = csv::Reader::from_path(csv_path).expect("failed to open pid_matrix.csv"); + let mut out = String::new(); + out.push_str("pub const PID_REGISTRY: &[crate::registry::PidRegistryRow] = &[\n"); + + for rec in rdr.records() { + let rec = rec.expect("invalid pid csv record"); + let name = rec.get(0).expect("pid_name"); + let pid: u16 = rec + .get(1) + .expect("pid_decimal") + .parse() + .expect("invalid pid decimal"); + let support_level = match rec.get(5).expect("support_level") { + "full" => "crate::types::SupportLevel::Full", + "detect-only" => "crate::types::SupportLevel::DetectOnly", + other => panic!("unknown support_level {other}"), + }; + let protocol_family = match rec.get(6).expect("protocol_family") { + "Standard64" => "crate::types::ProtocolFamily::Standard64", + "JpHandshake" => "crate::types::ProtocolFamily::JpHandshake", + "DInput" => "crate::types::ProtocolFamily::DInput", + "DS4Boot" => "crate::types::ProtocolFamily::DS4Boot", + "Unknown" => "crate::types::ProtocolFamily::Unknown", + other => panic!("unknown protocol_family {other}"), + }; + + out.push_str(&format!( + " crate::registry::PidRegistryRow {{ name: \"{name}\", pid: {pid}, support_level: {support_level}, protocol_family: {protocol_family} }},\n" + )); + } + + out.push_str("]\n;"); + fs::write(out_path, out).expect("failed writing generated_pid_registry.rs"); +} + +fn generate_command_registry(csv_path: &Path, out_path: &Path) { + let mut rdr = csv::Reader::from_path(csv_path).expect("failed to open command_matrix.csv"); + let mut out = String::new(); + out.push_str("pub const COMMAND_REGISTRY: &[crate::registry::CommandRegistryRow] = &[\n"); + + for rec in rdr.records() { + let rec = rec.expect("invalid command csv record"); + let id = rec.get(0).expect("command_id"); + let safety_class = match rec.get(1).expect("safety_class") { + "SafeRead" => "crate::types::SafetyClass::SafeRead", + "SafeWrite" => "crate::types::SafetyClass::SafeWrite", + "UnsafeBoot" => "crate::types::SafetyClass::UnsafeBoot", + "UnsafeFirmware" => "crate::types::SafetyClass::UnsafeFirmware", + other => panic!("unknown safety_class {other}"), + }; + let confidence = match rec.get(2).expect("confidence") { + "confirmed" => "crate::types::CommandConfidence::Confirmed", + "inferred" => "crate::types::CommandConfidence::Inferred", + other => panic!("unknown confidence {other}"), + }; + let experimental_default = rec + .get(3) + .expect("experimental_default") + .parse::() + .expect("invalid experimental_default"); + let report_id = parse_u8(rec.get(4).expect("report_id")); + let request_hex = rec.get(6).expect("request_hex"); + let request = hex_to_bytes(request_hex); + let expected_response = rec.get(7).expect("expected_response"); + + out.push_str(&format!( + " crate::registry::CommandRegistryRow {{ id: crate::command::CommandId::{id}, safety_class: {safety_class}, confidence: {confidence}, experimental_default: {experimental_default}, report_id: {report_id}, request: &{request:?}, expected_response: \"{expected_response}\" }},\n" + )); + } + + out.push_str("]\n;"); + fs::write(out_path, out).expect("failed writing generated_command_registry.rs"); +} + +fn parse_u8(value: &str) -> u8 { + if let Some(stripped) = value.strip_prefix("0x") { + u8::from_str_radix(stripped, 16).expect("invalid hex u8") + } else { + value.parse::().expect("invalid u8") + } +} + +fn hex_to_bytes(hex: &str) -> Vec { + let hex = hex.trim(); + if hex.len() % 2 != 0 { + panic!("hex length must be even: {hex}"); + } + let mut bytes = Vec::with_capacity(hex.len() / 2); + let raw = hex.as_bytes(); + for i in (0..raw.len()).step_by(2) { + let hi = (raw[i] as char) + .to_digit(16) + .unwrap_or_else(|| panic!("invalid hex: {hex}")); + let lo = (raw[i + 1] as char) + .to_digit(16) + .unwrap_or_else(|| panic!("invalid hex: {hex}")); + bytes.push(((hi << 4) | lo) as u8); + } + bytes +} diff --git a/sdk/crates/bitdo_proto/src/command.rs b/sdk/crates/bitdo_proto/src/command.rs new file mode 100644 index 0000000..4dbd812 --- /dev/null +++ b/sdk/crates/bitdo_proto/src/command.rs @@ -0,0 +1,60 @@ +use crate::types::{CommandConfidence, SafetyClass}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum CommandId { + GetPid, + GetReportRevision, + GetMode, + GetModeAlt, + GetControllerVersion, + GetSuperButton, + SetModeDInput, + Idle, + Version, + ReadProfile, + WriteProfile, + EnterBootloaderA, + EnterBootloaderB, + EnterBootloaderC, + ExitBootloader, + FirmwareChunk, + FirmwareCommit, +} + +impl CommandId { + pub const ALL: [CommandId; 17] = [ + CommandId::GetPid, + CommandId::GetReportRevision, + CommandId::GetMode, + CommandId::GetModeAlt, + CommandId::GetControllerVersion, + CommandId::GetSuperButton, + CommandId::SetModeDInput, + CommandId::Idle, + CommandId::Version, + CommandId::ReadProfile, + CommandId::WriteProfile, + CommandId::EnterBootloaderA, + CommandId::EnterBootloaderB, + CommandId::EnterBootloaderC, + CommandId::ExitBootloader, + CommandId::FirmwareChunk, + CommandId::FirmwareCommit, + ]; + + pub fn all() -> &'static [CommandId] { + &Self::ALL + } +} + +#[derive(Clone, Debug)] +pub struct CommandDefinition { + pub id: CommandId, + pub safety_class: SafetyClass, + pub confidence: CommandConfidence, + pub experimental_default: bool, + pub report_id: u8, + pub request: &'static [u8], + pub expected_response: &'static str, +} diff --git a/sdk/crates/bitdo_proto/src/error.rs b/sdk/crates/bitdo_proto/src/error.rs new file mode 100644 index 0000000..aff329f --- /dev/null +++ b/sdk/crates/bitdo_proto/src/error.rs @@ -0,0 +1,65 @@ +use crate::command::CommandId; +use crate::types::VidPid; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum BitdoErrorCode { + Transport, + Timeout, + InvalidResponse, + MalformedResponse, + UnsupportedForPid, + ExperimentalRequired, + UnsafeCommandDenied, + UnknownPid, + InvalidInput, + UnknownCommand, + DeviceNotOpen, +} + +#[derive(Debug, Error)] +pub enum BitdoError { + #[error("transport error: {0}")] + Transport(String), + #[error("timeout while waiting for device response")] + Timeout, + #[error("invalid response for {command:?}: {reason}")] + InvalidResponse { command: CommandId, reason: String }, + #[error("malformed response for {command:?}: len={len}")] + MalformedResponse { command: CommandId, len: usize }, + #[error("unsupported command {command:?} for PID {pid:#06x}")] + UnsupportedForPid { command: CommandId, pid: u16 }, + #[error("inferred command {command:?} requires --experimental")] + ExperimentalRequired { command: CommandId }, + #[error("unsafe command {command:?} requires --unsafe and --i-understand-brick-risk")] + UnsafeCommandDenied { command: CommandId }, + #[error("unknown PID {0:#06x}")] + UnknownPid(u16), + #[error("invalid input: {0}")] + InvalidInput(String), + #[error("command definition not found: {0:?}")] + UnknownCommand(CommandId), + #[error("device not open for {0}")] + DeviceNotOpen(VidPid), +} + +impl BitdoError { + pub fn code(&self) -> BitdoErrorCode { + match self { + BitdoError::Transport(_) => BitdoErrorCode::Transport, + BitdoError::Timeout => BitdoErrorCode::Timeout, + BitdoError::InvalidResponse { .. } => BitdoErrorCode::InvalidResponse, + BitdoError::MalformedResponse { .. } => BitdoErrorCode::MalformedResponse, + BitdoError::UnsupportedForPid { .. } => BitdoErrorCode::UnsupportedForPid, + BitdoError::ExperimentalRequired { .. } => BitdoErrorCode::ExperimentalRequired, + BitdoError::UnsafeCommandDenied { .. } => BitdoErrorCode::UnsafeCommandDenied, + BitdoError::UnknownPid(_) => BitdoErrorCode::UnknownPid, + BitdoError::InvalidInput(_) => BitdoErrorCode::InvalidInput, + BitdoError::UnknownCommand(_) => BitdoErrorCode::UnknownCommand, + BitdoError::DeviceNotOpen(_) => BitdoErrorCode::DeviceNotOpen, + } + } +} + +pub type Result = std::result::Result; diff --git a/sdk/crates/bitdo_proto/src/frame.rs b/sdk/crates/bitdo_proto/src/frame.rs new file mode 100644 index 0000000..4ff52dc --- /dev/null +++ b/sdk/crates/bitdo_proto/src/frame.rs @@ -0,0 +1,56 @@ +use crate::command::CommandId; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Report64(pub [u8; 64]); + +impl Report64 { + pub fn as_slice(&self) -> &[u8] { + &self.0 + } +} + +impl TryFrom<&[u8]> for Report64 { + type Error = String; + + fn try_from(value: &[u8]) -> Result { + if value.len() != 64 { + return Err(format!("expected 64 bytes, got {}", value.len())); + } + let mut arr = [0u8; 64]; + arr.copy_from_slice(value); + Ok(Self(arr)) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VariableReport(pub Vec); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommandFrame { + pub id: CommandId, + pub payload: Vec, + pub report_id: u8, + pub expected_response: &'static str, +} + +impl CommandFrame { + pub fn encode(&self) -> Vec { + self.payload.clone() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum ResponseStatus { + Ok, + Invalid, + Malformed, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ResponseFrame { + pub raw: Vec, + pub status: ResponseStatus, + pub parsed_fields: BTreeMap, +} diff --git a/sdk/crates/bitdo_proto/src/hid_transport.rs b/sdk/crates/bitdo_proto/src/hid_transport.rs new file mode 100644 index 0000000..8c679d2 --- /dev/null +++ b/sdk/crates/bitdo_proto/src/hid_transport.rs @@ -0,0 +1,125 @@ +#![cfg(feature = "hidapi-backend")] + +use crate::error::{BitdoError, Result}; +use crate::transport::Transport; +use crate::types::VidPid; +use hidapi::{HidApi, HidDevice}; + +#[derive(Clone, Debug)] +pub struct EnumeratedDevice { + pub vid_pid: VidPid, + pub product: Option, + pub manufacturer: Option, + pub serial: Option, + pub path: String, +} + +pub fn enumerate_hid_devices() -> Result> { + let api = HidApi::new().map_err(|e| BitdoError::Transport(e.to_string()))?; + let mut devices = Vec::new(); + for dev in api.device_list() { + devices.push(EnumeratedDevice { + vid_pid: VidPid::new(dev.vendor_id(), dev.product_id()), + product: dev.product_string().map(ToOwned::to_owned), + manufacturer: dev.manufacturer_string().map(ToOwned::to_owned), + serial: dev.serial_number().map(ToOwned::to_owned), + path: dev.path().to_string_lossy().to_string(), + }); + } + Ok(devices) +} + +pub struct HidTransport { + api: Option, + device: Option, + target: Option, +} + +impl HidTransport { + pub fn new() -> Self { + Self { + api: None, + device: None, + target: None, + } + } +} + +impl Default for HidTransport { + fn default() -> Self { + Self::new() + } +} + +impl Transport for HidTransport { + fn open(&mut self, vid_pid: VidPid) -> Result<()> { + let api = HidApi::new().map_err(|e| BitdoError::Transport(e.to_string()))?; + let device = api + .open(vid_pid.vid, vid_pid.pid) + .map_err(|e| BitdoError::Transport(format!("open failed for {}: {}", vid_pid, e)))?; + self.target = Some(vid_pid); + self.device = Some(device); + self.api = Some(api); + Ok(()) + } + + fn close(&mut self) -> Result<()> { + self.device = None; + self.api = None; + self.target = None; + Ok(()) + } + + fn write(&mut self, data: &[u8]) -> Result { + let device = self + .device + .as_ref() + .ok_or_else(|| BitdoError::Transport("HID transport not open".to_owned()))?; + device + .write(data) + .map_err(|e| BitdoError::Transport(e.to_string())) + } + + fn read(&mut self, len: usize, timeout_ms: u64) -> Result> { + let device = self + .device + .as_ref() + .ok_or_else(|| BitdoError::Transport("HID transport not open".to_owned()))?; + let mut buf = vec![0u8; len]; + let read = device + .read_timeout(&mut buf, timeout_ms as i32) + .map_err(|e| BitdoError::Transport(e.to_string()))?; + if read == 0 { + return Err(BitdoError::Timeout); + } + buf.truncate(read); + Ok(buf) + } + + fn write_feature(&mut self, data: &[u8]) -> Result { + let device = self + .device + .as_ref() + .ok_or_else(|| BitdoError::Transport("HID transport not open".to_owned()))?; + device + .send_feature_report(data) + .map_err(|e| BitdoError::Transport(e.to_string()))?; + Ok(data.len()) + } + + fn read_feature(&mut self, len: usize) -> Result> { + let device = self + .device + .as_ref() + .ok_or_else(|| BitdoError::Transport("HID transport not open".to_owned()))?; + let mut buf = vec![0u8; len]; + let read = device + .get_feature_report(&mut buf) + .map_err(|e| BitdoError::Transport(e.to_string()))?; + if read == 0 { + return Err(BitdoError::Timeout); + } + buf.truncate(read); + Ok(buf) + } +} diff --git a/sdk/crates/bitdo_proto/src/lib.rs b/sdk/crates/bitdo_proto/src/lib.rs new file mode 100644 index 0000000..d118736 --- /dev/null +++ b/sdk/crates/bitdo_proto/src/lib.rs @@ -0,0 +1,30 @@ +mod command; +mod error; +mod frame; +#[cfg(feature = "hidapi-backend")] +mod hid_transport; +mod profile; +mod registry; +mod session; +mod transport; +mod types; + +pub use command::{CommandDefinition, CommandId}; +pub use error::{BitdoError, BitdoErrorCode, Result}; +pub use frame::{CommandFrame, Report64, ResponseFrame, ResponseStatus, VariableReport}; +#[cfg(feature = "hidapi-backend")] +pub use hid_transport::{enumerate_hid_devices, EnumeratedDevice, HidTransport}; +pub use profile::ProfileBlob; +pub use registry::{ + command_registry, device_profile_for, find_command, find_pid, pid_registry, CommandRegistryRow, + PidRegistryRow, +}; +pub use session::{ + validate_response, CommandExecutionReport, DeviceSession, DiagCommandStatus, DiagProbeResult, + FirmwareTransferReport, IdentifyResult, ModeState, RetryPolicy, SessionConfig, TimeoutProfile, +}; +pub use transport::{MockTransport, Transport}; +pub use types::{ + CommandConfidence, DeviceProfile, PidCapability, ProtocolFamily, SafetyClass, SupportEvidence, + SupportLevel, VidPid, +}; diff --git a/sdk/crates/bitdo_proto/src/profile.rs b/sdk/crates/bitdo_proto/src/profile.rs new file mode 100644 index 0000000..f40cb59 --- /dev/null +++ b/sdk/crates/bitdo_proto/src/profile.rs @@ -0,0 +1,63 @@ +use crate::error::{BitdoError, Result}; +use serde::{Deserialize, Serialize}; + +const MAGIC: &[u8; 4] = b"BDP1"; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProfileBlob { + pub slot: u8, + pub payload: Vec, +} + +impl ProfileBlob { + pub fn to_bytes(&self) -> Vec { + let mut out = Vec::with_capacity(4 + 1 + 2 + self.payload.len() + 4); + out.extend_from_slice(MAGIC); + out.push(self.slot); + out.extend_from_slice(&(self.payload.len() as u16).to_le_bytes()); + out.extend_from_slice(&self.payload); + let checksum = checksum(&out[4..]); + out.extend_from_slice(&checksum.to_le_bytes()); + out + } + + pub fn from_bytes(data: &[u8]) -> Result { + if data.len() < 11 { + return Err(BitdoError::InvalidInput( + "profile blob too short".to_owned(), + )); + } + if &data[0..4] != MAGIC { + return Err(BitdoError::InvalidInput("invalid profile magic".to_owned())); + } + + let slot = data[4]; + let len = u16::from_le_bytes([data[5], data[6]]) as usize; + let payload_end = 7 + len; + if payload_end + 4 > data.len() { + return Err(BitdoError::InvalidInput( + "profile length exceeds blob size".to_owned(), + )); + } + + let payload = data[7..payload_end].to_vec(); + let expected = u32::from_le_bytes([ + data[payload_end], + data[payload_end + 1], + data[payload_end + 2], + data[payload_end + 3], + ]); + let actual = checksum(&data[4..payload_end]); + if expected != actual { + return Err(BitdoError::InvalidInput(format!( + "checksum mismatch expected={expected:#x} actual={actual:#x}" + ))); + } + + Ok(Self { slot, payload }) + } +} + +fn checksum(data: &[u8]) -> u32 { + data.iter().fold(0u32, |acc, b| acc.wrapping_add(*b as u32)) +} diff --git a/sdk/crates/bitdo_proto/src/registry.rs b/sdk/crates/bitdo_proto/src/registry.rs new file mode 100644 index 0000000..af9a0e7 --- /dev/null +++ b/sdk/crates/bitdo_proto/src/registry.rs @@ -0,0 +1,82 @@ +use crate::command::CommandId; +use crate::types::{ + CommandConfidence, DeviceProfile, PidCapability, ProtocolFamily, SafetyClass, SupportEvidence, + SupportLevel, VidPid, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PidRegistryRow { + pub name: &'static str, + pub pid: u16, + pub support_level: SupportLevel, + pub protocol_family: ProtocolFamily, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CommandRegistryRow { + pub id: CommandId, + pub safety_class: SafetyClass, + pub confidence: CommandConfidence, + pub experimental_default: bool, + pub report_id: u8, + pub request: &'static [u8], + pub expected_response: &'static str, +} + +include!(concat!(env!("OUT_DIR"), "/generated_pid_registry.rs")); +include!(concat!(env!("OUT_DIR"), "/generated_command_registry.rs")); + +pub fn pid_registry() -> &'static [PidRegistryRow] { + PID_REGISTRY +} + +pub fn command_registry() -> &'static [CommandRegistryRow] { + COMMAND_REGISTRY +} + +pub fn find_pid(pid: u16) -> Option<&'static PidRegistryRow> { + PID_REGISTRY.iter().find(|row| row.pid == pid) +} + +pub fn find_command(id: CommandId) -> Option<&'static CommandRegistryRow> { + COMMAND_REGISTRY.iter().find(|row| row.id == id) +} + +pub fn default_capability_for( + support_level: SupportLevel, + _protocol_family: ProtocolFamily, +) -> PidCapability { + match support_level { + SupportLevel::Full => PidCapability::full(), + SupportLevel::DetectOnly => PidCapability::identify_only(), + } +} + +pub fn default_evidence_for(support_level: SupportLevel) -> SupportEvidence { + match support_level { + SupportLevel::Full => SupportEvidence::Confirmed, + SupportLevel::DetectOnly => SupportEvidence::Inferred, + } +} + +pub fn device_profile_for(vid_pid: VidPid) -> DeviceProfile { + if let Some(row) = find_pid(vid_pid.pid) { + DeviceProfile { + vid_pid, + name: row.name.to_owned(), + support_level: row.support_level, + protocol_family: row.protocol_family, + capability: default_capability_for(row.support_level, row.protocol_family), + evidence: default_evidence_for(row.support_level), + } + } else { + DeviceProfile { + vid_pid, + name: "PID_UNKNOWN".to_owned(), + support_level: SupportLevel::DetectOnly, + protocol_family: ProtocolFamily::Unknown, + capability: PidCapability::identify_only(), + evidence: SupportEvidence::Untested, + } + } +} diff --git a/sdk/crates/bitdo_proto/src/session.rs b/sdk/crates/bitdo_proto/src/session.rs new file mode 100644 index 0000000..95765f8 --- /dev/null +++ b/sdk/crates/bitdo_proto/src/session.rs @@ -0,0 +1,715 @@ +use crate::command::CommandId; +use crate::error::{BitdoError, BitdoErrorCode, Result}; +use crate::frame::{CommandFrame, ResponseFrame, ResponseStatus}; +use crate::profile::ProfileBlob; +use crate::registry::{device_profile_for, find_command, find_pid, CommandRegistryRow}; +use crate::transport::Transport; +use crate::types::{ + CommandConfidence, DeviceProfile, PidCapability, ProtocolFamily, SafetyClass, SupportEvidence, + SupportLevel, VidPid, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::thread; +use std::time::Duration; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RetryPolicy { + pub max_attempts: u8, + pub backoff_ms: u64, +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self { + max_attempts: 3, + backoff_ms: 10, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TimeoutProfile { + pub probe_ms: u64, + pub io_ms: u64, + pub firmware_ms: u64, +} + +impl Default for TimeoutProfile { + fn default() -> Self { + Self { + probe_ms: 200, + io_ms: 400, + firmware_ms: 1_200, + } + } +} + +#[derive(Clone, Debug)] +pub struct SessionConfig { + pub retry_policy: RetryPolicy, + pub timeout_profile: TimeoutProfile, + pub allow_unsafe: bool, + pub brick_risk_ack: bool, + pub experimental: bool, + pub trace_enabled: bool, +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + retry_policy: RetryPolicy::default(), + timeout_profile: TimeoutProfile::default(), + allow_unsafe: false, + brick_risk_ack: false, + experimental: false, + trace_enabled: true, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CommandExecutionReport { + pub command: CommandId, + pub attempts: u8, + pub validator: String, + pub status: ResponseStatus, + pub bytes_written: usize, + pub bytes_read: usize, + pub error_code: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DiagCommandStatus { + pub command: CommandId, + pub ok: bool, + pub error_code: Option, + pub detail: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DiagProbeResult { + pub target: VidPid, + pub profile_name: String, + pub support_level: SupportLevel, + pub protocol_family: ProtocolFamily, + pub capability: PidCapability, + pub evidence: SupportEvidence, + pub transport_ready: bool, + pub command_checks: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct IdentifyResult { + pub target: VidPid, + pub profile_name: String, + pub support_level: SupportLevel, + pub protocol_family: ProtocolFamily, + pub capability: PidCapability, + pub evidence: SupportEvidence, + pub detected_pid: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ModeState { + pub mode: u8, + pub source: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FirmwareTransferReport { + pub bytes_total: usize, + pub chunk_size: usize, + pub chunks_sent: usize, + pub dry_run: bool, +} + +pub struct DeviceSession { + transport: T, + target: VidPid, + profile: DeviceProfile, + config: SessionConfig, + trace: Vec, + last_execution: Option, +} + +impl DeviceSession { + pub fn new(mut transport: T, target: VidPid, config: SessionConfig) -> Result { + transport.open(target)?; + let profile = device_profile_for(target); + Ok(Self { + transport, + target, + profile, + config, + trace: Vec::new(), + last_execution: None, + }) + } + + pub fn profile(&self) -> &DeviceProfile { + &self.profile + } + + pub fn trace(&self) -> &[CommandExecutionReport] { + &self.trace + } + + pub fn last_execution_report(&self) -> Option<&CommandExecutionReport> { + self.last_execution.as_ref() + } + + pub fn close(&mut self) -> Result<()> { + self.transport.close() + } + + pub fn into_transport(self) -> T { + self.transport + } + + pub fn identify(&mut self) -> Result { + let detected_pid = match self.send_command(CommandId::GetPid, None) { + Ok(resp) => resp + .parsed_fields + .get("detected_pid") + .copied() + .map(|v| v as u16), + Err(_) => None, + }; + + let profile_row = detected_pid.and_then(find_pid); + let mut profile = self.profile.clone(); + if let Some(row) = profile_row { + profile = device_profile_for(VidPid::new(self.target.vid, row.pid)); + } + + Ok(IdentifyResult { + target: self.target, + profile_name: profile.name, + support_level: profile.support_level, + protocol_family: profile.protocol_family, + capability: profile.capability, + evidence: profile.evidence, + detected_pid, + }) + } + + pub fn diag_probe(&mut self) -> DiagProbeResult { + let checks = [ + CommandId::GetPid, + CommandId::GetReportRevision, + CommandId::GetMode, + CommandId::GetControllerVersion, + ] + .iter() + .map(|cmd| match self.send_command(*cmd, None) { + Ok(_) => DiagCommandStatus { + command: *cmd, + ok: true, + error_code: None, + detail: "ok".to_owned(), + }, + Err(err) => DiagCommandStatus { + command: *cmd, + ok: false, + error_code: Some(err.code()), + detail: err.to_string(), + }, + }) + .collect::>(); + + DiagProbeResult { + target: self.target, + profile_name: self.profile.name.clone(), + support_level: self.profile.support_level, + protocol_family: self.profile.protocol_family, + capability: self.profile.capability, + evidence: self.profile.evidence, + transport_ready: true, + command_checks: checks, + } + } + + pub fn get_mode(&mut self) -> Result { + let resp = self.send_command(CommandId::GetMode, None)?; + if let Some(mode) = resp.parsed_fields.get("mode").copied() { + return Ok(ModeState { + mode: mode as u8, + source: "GetMode".to_owned(), + }); + } + + let resp = self.send_command(CommandId::GetModeAlt, None)?; + let mode = resp.parsed_fields.get("mode").copied().unwrap_or_default() as u8; + Ok(ModeState { + mode, + source: "GetModeAlt".to_owned(), + }) + } + + pub fn set_mode(&mut self, mode: u8) -> Result { + let row = self.ensure_command_allowed(CommandId::SetModeDInput)?; + let mut payload = row.request.to_vec(); + if payload.len() < 5 { + return Err(BitdoError::InvalidInput( + "SetModeDInput payload shorter than expected".to_owned(), + )); + } + payload[4] = mode; + self.send_row(row, Some(&payload))?; + self.get_mode() + } + + pub fn read_profile(&mut self, slot: u8) -> Result { + let row = self.ensure_command_allowed(CommandId::ReadProfile)?; + let mut payload = row.request.to_vec(); + if payload.len() > 3 { + payload[3] = slot; + } + let resp = self.send_row(row, Some(&payload))?; + Ok(ProfileBlob { + slot, + payload: resp.raw, + }) + } + + pub fn write_profile(&mut self, slot: u8, profile: &ProfileBlob) -> Result<()> { + let row = self.ensure_command_allowed(CommandId::WriteProfile)?; + let mut payload = row.request.to_vec(); + if payload.len() > 3 { + payload[3] = slot; + } + + let serialized = profile.to_bytes(); + let copy_len = (payload.len().saturating_sub(8)).min(serialized.len()); + if copy_len > 0 { + payload[8..8 + copy_len].copy_from_slice(&serialized[..copy_len]); + } + + self.send_row(row, Some(&payload))?; + Ok(()) + } + + pub fn enter_bootloader(&mut self) -> Result<()> { + self.send_command(CommandId::EnterBootloaderA, None)?; + self.send_command(CommandId::EnterBootloaderB, None)?; + self.send_command(CommandId::EnterBootloaderC, None)?; + Ok(()) + } + + pub fn firmware_transfer( + &mut self, + image: &[u8], + chunk_size: usize, + dry_run: bool, + ) -> Result { + if chunk_size == 0 { + return Err(BitdoError::InvalidInput( + "chunk size must be greater than zero".to_owned(), + )); + } + + let chunk_count = image.len().div_ceil(chunk_size); + if dry_run { + return Ok(FirmwareTransferReport { + bytes_total: image.len(), + chunk_size, + chunks_sent: chunk_count, + dry_run, + }); + } + + let row = self.ensure_command_allowed(CommandId::FirmwareChunk)?; + for chunk in image.chunks(chunk_size) { + let mut payload = row.request.to_vec(); + let offset = 4; + let copy_len = chunk.len().min(payload.len().saturating_sub(offset)); + if copy_len > 0 { + payload[offset..offset + copy_len].copy_from_slice(&chunk[..copy_len]); + } + self.send_row(row, Some(&payload))?; + } + + self.send_command(CommandId::FirmwareCommit, None)?; + Ok(FirmwareTransferReport { + bytes_total: image.len(), + chunk_size, + chunks_sent: chunk_count, + dry_run, + }) + } + + pub fn exit_bootloader(&mut self) -> Result<()> { + self.send_command(CommandId::ExitBootloader, None)?; + Ok(()) + } + + pub fn send_command( + &mut self, + command: CommandId, + override_payload: Option<&[u8]>, + ) -> Result { + let row = self.ensure_command_allowed(command)?; + self.send_row(row, override_payload) + } + + fn send_row( + &mut self, + row: &CommandRegistryRow, + override_payload: Option<&[u8]>, + ) -> Result { + let payload = override_payload.unwrap_or(row.request).to_vec(); + let frame = CommandFrame { + id: row.id, + payload, + report_id: row.report_id, + expected_response: row.expected_response, + }; + let encoded = frame.encode(); + let bytes_written = self.transport.write(&encoded)?; + + if row.expected_response == "none" { + let report = CommandExecutionReport { + command: row.id, + attempts: 1, + validator: self.validator_name(row), + status: ResponseStatus::Ok, + bytes_written, + bytes_read: 0, + error_code: None, + }; + self.record_execution(report); + return Ok(ResponseFrame { + raw: Vec::new(), + status: ResponseStatus::Ok, + parsed_fields: BTreeMap::new(), + }); + } + + let timeout_ms = self.timeout_for_command(row); + let expected_min_len = minimum_response_len(row.id); + let attempts_total = self.config.retry_policy.max_attempts.max(1); + + let mut last_status = ResponseStatus::Malformed; + let mut last_len = 0usize; + + for attempt in 1..=attempts_total { + match self.read_response_reassembled(timeout_ms, expected_min_len) { + Ok(raw) => { + let status = validate_response(row.id, &raw); + if status == ResponseStatus::Ok { + let report = CommandExecutionReport { + command: row.id, + attempts: attempt, + validator: self.validator_name(row), + status: ResponseStatus::Ok, + bytes_written, + bytes_read: raw.len(), + error_code: None, + }; + self.record_execution(report); + return Ok(ResponseFrame { + parsed_fields: parse_fields(row.id, &raw), + raw, + status, + }); + } + last_status = status; + last_len = raw.len(); + } + Err(BitdoError::Timeout) => { + last_status = ResponseStatus::Malformed; + last_len = 0; + } + Err(err) => { + let report = CommandExecutionReport { + command: row.id, + attempts: attempt, + validator: self.validator_name(row), + status: ResponseStatus::Malformed, + bytes_written, + bytes_read: 0, + error_code: Some(err.code()), + }; + self.record_execution(report); + return Err(err); + } + } + + if attempt < attempts_total && self.config.retry_policy.backoff_ms > 0 { + thread::sleep(Duration::from_millis(self.config.retry_policy.backoff_ms)); + } + } + + match last_status { + ResponseStatus::Invalid => { + let err = BitdoError::InvalidResponse { + command: row.id, + reason: "response signature mismatch".to_owned(), + }; + let report = CommandExecutionReport { + command: row.id, + attempts: attempts_total, + validator: self.validator_name(row), + status: ResponseStatus::Invalid, + bytes_written, + bytes_read: last_len, + error_code: Some(err.code()), + }; + self.record_execution(report); + Err(err) + } + _ => { + let err = BitdoError::MalformedResponse { + command: row.id, + len: last_len, + }; + let report = CommandExecutionReport { + command: row.id, + attempts: attempts_total, + validator: self.validator_name(row), + status: ResponseStatus::Malformed, + bytes_written, + bytes_read: last_len, + error_code: Some(err.code()), + }; + self.record_execution(report); + Err(err) + } + } + } + + fn read_response_reassembled( + &mut self, + timeout_ms: u64, + expected_min_len: usize, + ) -> Result> { + let mut raw = Vec::new(); + + // Some devices can split replies across multiple reads; reassemble bounded chunks. + for _ in 0..3 { + let chunk = self.transport.read(64, timeout_ms)?; + if chunk.is_empty() { + continue; + } + raw.extend_from_slice(&chunk); + if raw.len() >= expected_min_len { + break; + } + } + + if raw.is_empty() { + return Err(BitdoError::Timeout); + } + Ok(raw) + } + + fn record_execution(&mut self, report: CommandExecutionReport) { + self.last_execution = Some(report.clone()); + if self.config.trace_enabled { + self.trace.push(report); + } + } + + fn timeout_for_command(&self, row: &CommandRegistryRow) -> u64 { + match row.safety_class { + SafetyClass::UnsafeFirmware => self.config.timeout_profile.firmware_ms, + SafetyClass::SafeRead => self.config.timeout_profile.probe_ms, + SafetyClass::SafeWrite | SafetyClass::UnsafeBoot => self.config.timeout_profile.io_ms, + } + } + + fn validator_name(&self, row: &CommandRegistryRow) -> String { + format!( + "pid={:#06x};signature={}", + self.target.pid, row.expected_response + ) + } + + fn ensure_command_allowed(&self, command: CommandId) -> Result<&'static CommandRegistryRow> { + let row = find_command(command).ok_or(BitdoError::UnknownCommand(command))?; + + if row.confidence == CommandConfidence::Inferred && !self.config.experimental { + return Err(BitdoError::ExperimentalRequired { command }); + } + + if !is_command_allowed_by_family(self.profile.protocol_family, command) + || !is_command_allowed_by_capability(self.profile.capability, command) + { + return Err(BitdoError::UnsupportedForPid { + command, + pid: self.target.pid, + }); + } + + if row.safety_class.is_unsafe() { + if self.profile.support_level != SupportLevel::Full { + return Err(BitdoError::UnsupportedForPid { + command, + pid: self.target.pid, + }); + } + if !(self.config.allow_unsafe && self.config.brick_risk_ack) { + return Err(BitdoError::UnsafeCommandDenied { command }); + } + } + + if row.safety_class == SafetyClass::SafeWrite + && self.profile.support_level == SupportLevel::DetectOnly + { + return Err(BitdoError::UnsupportedForPid { + command, + pid: self.target.pid, + }); + } + + Ok(row) + } +} + +fn is_command_allowed_by_capability(cap: PidCapability, command: CommandId) -> bool { + match command { + CommandId::GetPid + | CommandId::GetReportRevision + | CommandId::GetControllerVersion + | CommandId::Version + | CommandId::Idle + | CommandId::GetSuperButton => true, + CommandId::GetMode | CommandId::GetModeAlt | CommandId::SetModeDInput => cap.supports_mode, + CommandId::ReadProfile | CommandId::WriteProfile => cap.supports_profile_rw, + CommandId::EnterBootloaderA + | CommandId::EnterBootloaderB + | CommandId::EnterBootloaderC + | CommandId::ExitBootloader => cap.supports_boot, + CommandId::FirmwareChunk | CommandId::FirmwareCommit => cap.supports_firmware, + } +} + +fn is_command_allowed_by_family(family: ProtocolFamily, command: CommandId) -> bool { + match family { + ProtocolFamily::Unknown => matches!( + command, + CommandId::GetPid + | CommandId::GetReportRevision + | CommandId::GetControllerVersion + | CommandId::Version + | CommandId::Idle + ), + ProtocolFamily::JpHandshake => !matches!( + command, + CommandId::SetModeDInput + | CommandId::ReadProfile + | CommandId::WriteProfile + | CommandId::FirmwareChunk + | CommandId::FirmwareCommit + ), + ProtocolFamily::DS4Boot => matches!( + command, + CommandId::EnterBootloaderA + | CommandId::EnterBootloaderB + | CommandId::EnterBootloaderC + | CommandId::ExitBootloader + | CommandId::FirmwareChunk + | CommandId::FirmwareCommit + | CommandId::GetPid + ), + ProtocolFamily::Standard64 | ProtocolFamily::DInput => true, + } +} + +pub fn validate_response(command: CommandId, response: &[u8]) -> ResponseStatus { + if response.len() < 2 { + return ResponseStatus::Malformed; + } + + match command { + CommandId::GetPid => { + if response.len() < 24 { + return ResponseStatus::Malformed; + } + if response[0] == 0x02 && response[1] == 0x05 && response[4] == 0xC1 { + ResponseStatus::Ok + } else { + ResponseStatus::Invalid + } + } + CommandId::GetReportRevision => { + if response.len() < 6 { + return ResponseStatus::Malformed; + } + if response[0] == 0x02 && response[1] == 0x04 && response[5] == 0x01 { + ResponseStatus::Ok + } else { + ResponseStatus::Invalid + } + } + CommandId::GetMode | CommandId::GetModeAlt => { + if response.len() < 6 { + return ResponseStatus::Malformed; + } + if response[0] == 0x02 && response[1] == 0x05 { + ResponseStatus::Ok + } else { + ResponseStatus::Invalid + } + } + CommandId::GetControllerVersion | CommandId::Version => { + if response.len() < 5 { + return ResponseStatus::Malformed; + } + if response[0] == 0x02 && response[1] == 0x22 { + ResponseStatus::Ok + } else { + ResponseStatus::Invalid + } + } + CommandId::Idle => { + if response[0] == 0x02 { + ResponseStatus::Ok + } else { + ResponseStatus::Invalid + } + } + CommandId::EnterBootloaderA + | CommandId::EnterBootloaderB + | CommandId::EnterBootloaderC + | CommandId::ExitBootloader => ResponseStatus::Ok, + _ => { + if response[0] == 0x02 { + ResponseStatus::Ok + } else { + ResponseStatus::Invalid + } + } + } +} + +fn minimum_response_len(command: CommandId) -> usize { + match command { + CommandId::GetPid => 24, + CommandId::GetReportRevision => 6, + CommandId::GetMode | CommandId::GetModeAlt => 6, + CommandId::GetControllerVersion | CommandId::Version => 5, + _ => 2, + } +} + +fn parse_fields(command: CommandId, response: &[u8]) -> BTreeMap { + let mut parsed = BTreeMap::new(); + match command { + CommandId::GetPid if response.len() >= 24 => { + let pid = u16::from_le_bytes([response[22], response[23]]); + parsed.insert("detected_pid".to_owned(), pid as u32); + } + CommandId::GetMode | CommandId::GetModeAlt if response.len() >= 6 => { + parsed.insert("mode".to_owned(), response[5] as u32); + } + CommandId::GetControllerVersion | CommandId::Version if response.len() >= 5 => { + let fw = u16::from_le_bytes([response[2], response[3]]) as u32; + parsed.insert("version_x100".to_owned(), fw); + parsed.insert("beta".to_owned(), response[4] as u32); + } + _ => {} + } + parsed +} diff --git a/sdk/crates/bitdo_proto/src/transport.rs b/sdk/crates/bitdo_proto/src/transport.rs new file mode 100644 index 0000000..fe377ed --- /dev/null +++ b/sdk/crates/bitdo_proto/src/transport.rs @@ -0,0 +1,126 @@ +use crate::error::{BitdoError, Result}; +use crate::types::VidPid; +use std::collections::VecDeque; + +pub trait Transport { + fn open(&mut self, vid_pid: VidPid) -> Result<()>; + fn close(&mut self) -> Result<()>; + fn write(&mut self, data: &[u8]) -> Result; + fn read(&mut self, len: usize, timeout_ms: u64) -> Result>; + fn write_feature(&mut self, data: &[u8]) -> Result; + fn read_feature(&mut self, len: usize) -> Result>; +} + +impl Transport for Box { + fn open(&mut self, vid_pid: VidPid) -> Result<()> { + (**self).open(vid_pid) + } + + fn close(&mut self) -> Result<()> { + (**self).close() + } + + fn write(&mut self, data: &[u8]) -> Result { + (**self).write(data) + } + + fn read(&mut self, len: usize, timeout_ms: u64) -> Result> { + (**self).read(len, timeout_ms) + } + + fn write_feature(&mut self, data: &[u8]) -> Result { + (**self).write_feature(data) + } + + fn read_feature(&mut self, len: usize) -> Result> { + (**self).read_feature(len) + } +} + +#[derive(Clone, Debug)] +pub enum MockReadEvent { + Data(Vec), + Timeout, + Error(String), +} + +#[derive(Clone, Debug, Default)] +pub struct MockTransport { + opened: Option, + reads: VecDeque, + feature_reads: VecDeque, + writes: Vec>, + feature_writes: Vec>, +} + +impl MockTransport { + pub fn push_read_data(&mut self, data: Vec) { + self.reads.push_back(MockReadEvent::Data(data)); + } + + pub fn push_read_timeout(&mut self) { + self.reads.push_back(MockReadEvent::Timeout); + } + + pub fn push_read_error(&mut self, message: impl Into) { + self.reads.push_back(MockReadEvent::Error(message.into())); + } + + pub fn push_feature_read_data(&mut self, data: Vec) { + self.feature_reads.push_back(MockReadEvent::Data(data)); + } + + pub fn writes(&self) -> &[Vec] { + &self.writes + } + + pub fn feature_writes(&self) -> &[Vec] { + &self.feature_writes + } +} + +impl Transport for MockTransport { + fn open(&mut self, vid_pid: VidPid) -> Result<()> { + self.opened = Some(vid_pid); + Ok(()) + } + + fn close(&mut self) -> Result<()> { + self.opened = None; + Ok(()) + } + + fn write(&mut self, data: &[u8]) -> Result { + if self.opened.is_none() { + return Err(BitdoError::Transport("mock transport not open".to_owned())); + } + self.writes.push(data.to_vec()); + Ok(data.len()) + } + + fn read(&mut self, _len: usize, _timeout_ms: u64) -> Result> { + match self.reads.pop_front() { + Some(MockReadEvent::Data(d)) => Ok(d), + Some(MockReadEvent::Timeout) => Err(BitdoError::Timeout), + Some(MockReadEvent::Error(msg)) => Err(BitdoError::Transport(msg)), + None => Err(BitdoError::Timeout), + } + } + + fn write_feature(&mut self, data: &[u8]) -> Result { + if self.opened.is_none() { + return Err(BitdoError::Transport("mock transport not open".to_owned())); + } + self.feature_writes.push(data.to_vec()); + Ok(data.len()) + } + + fn read_feature(&mut self, _len: usize) -> Result> { + match self.feature_reads.pop_front() { + Some(MockReadEvent::Data(d)) => Ok(d), + Some(MockReadEvent::Timeout) => Err(BitdoError::Timeout), + Some(MockReadEvent::Error(msg)) => Err(BitdoError::Transport(msg)), + None => Err(BitdoError::Timeout), + } + } +} diff --git a/sdk/crates/bitdo_proto/src/types.rs b/sdk/crates/bitdo_proto/src/types.rs new file mode 100644 index 0000000..503388e --- /dev/null +++ b/sdk/crates/bitdo_proto/src/types.rs @@ -0,0 +1,116 @@ +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct VidPid { + pub vid: u16, + pub pid: u16, +} + +impl VidPid { + pub const fn new(vid: u16, pid: u16) -> Self { + Self { vid, pid } + } +} + +impl Display for VidPid { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{:04x}:{:04x}", self.vid, self.pid) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum ProtocolFamily { + Standard64, + JpHandshake, + DInput, + DS4Boot, + Unknown, +} + +impl FromStr for ProtocolFamily { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "Standard64" => Ok(Self::Standard64), + "JpHandshake" => Ok(Self::JpHandshake), + "DInput" => Ok(Self::DInput), + "DS4Boot" => Ok(Self::DS4Boot), + "Unknown" => Ok(Self::Unknown), + _ => Err(format!("unsupported protocol family: {s}")), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum SupportLevel { + Full, + DetectOnly, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum SafetyClass { + SafeRead, + SafeWrite, + UnsafeBoot, + UnsafeFirmware, +} + +impl SafetyClass { + pub fn is_unsafe(self) -> bool { + matches!(self, Self::UnsafeBoot | Self::UnsafeFirmware) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum CommandConfidence { + Confirmed, + Inferred, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum SupportEvidence { + Confirmed, + Inferred, + Untested, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PidCapability { + pub supports_mode: bool, + pub supports_profile_rw: bool, + pub supports_boot: bool, + pub supports_firmware: bool, +} + +impl PidCapability { + pub const fn full() -> Self { + Self { + supports_mode: true, + supports_profile_rw: true, + supports_boot: true, + supports_firmware: true, + } + } + + pub const fn identify_only() -> Self { + Self { + supports_mode: false, + supports_profile_rw: false, + supports_boot: false, + supports_firmware: false, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct DeviceProfile { + pub vid_pid: VidPid, + pub name: String, + pub support_level: SupportLevel, + pub protocol_family: ProtocolFamily, + pub capability: PidCapability, + pub evidence: SupportEvidence, +} diff --git a/sdk/crates/bitdoctl/Cargo.toml b/sdk/crates/bitdoctl/Cargo.toml new file mode 100644 index 0000000..c86c5a5 --- /dev/null +++ b/sdk/crates/bitdoctl/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "bitdoctl" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true } +serde_json = { workspace = true } +hex = { workspace = true } +bitdo_proto = { path = "../bitdo_proto" } + +[dev-dependencies] +assert_cmd = "2.0" +predicates = "3.1" + +[[test]] +name = "cli_snapshot" +path = "../../tests/cli_snapshot.rs" diff --git a/sdk/crates/bitdoctl/src/main.rs b/sdk/crates/bitdoctl/src/main.rs new file mode 100644 index 0000000..27539d7 --- /dev/null +++ b/sdk/crates/bitdoctl/src/main.rs @@ -0,0 +1,518 @@ +use anyhow::{anyhow, Result}; +use bitdo_proto::{ + command_registry, device_profile_for, enumerate_hid_devices, BitdoErrorCode, CommandId, + DeviceSession, FirmwareTransferReport, HidTransport, MockTransport, ProfileBlob, RetryPolicy, + SessionConfig, TimeoutProfile, Transport, VidPid, +}; +use clap::{Parser, Subcommand}; +use serde_json::json; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +#[command(name = "bitdoctl")] +#[command(about = "OpenBitdo clean-room protocol CLI")] +struct Cli { + #[arg(long)] + vid: Option, + #[arg(long)] + pid: Option, + #[arg(long)] + json: bool, + #[arg(long = "unsafe")] + allow_unsafe: bool, + #[arg(long = "i-understand-brick-risk")] + brick_risk_ack: bool, + #[arg(long)] + experimental: bool, + #[arg(long)] + mock: bool, + #[arg(long, default_value_t = 3)] + max_attempts: u8, + #[arg(long, default_value_t = 10)] + backoff_ms: u64, + #[arg(long, default_value_t = 200)] + probe_timeout_ms: u64, + #[arg(long, default_value_t = 400)] + io_timeout_ms: u64, + #[arg(long, default_value_t = 1200)] + firmware_timeout_ms: u64, + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand)] +enum Commands { + List, + Identify, + Diag { + #[command(subcommand)] + command: DiagCommand, + }, + Profile { + #[command(subcommand)] + command: ProfileCommand, + }, + Mode { + #[command(subcommand)] + command: ModeCommand, + }, + Boot { + #[command(subcommand)] + command: BootCommand, + }, + Fw { + #[command(subcommand)] + command: FwCommand, + }, +} + +#[derive(Debug, Subcommand)] +enum DiagCommand { + Probe, +} + +#[derive(Debug, Subcommand)] +enum ProfileCommand { + Dump { + #[arg(long)] + slot: u8, + }, + Apply { + #[arg(long)] + slot: u8, + #[arg(long)] + file: PathBuf, + }, +} + +#[derive(Debug, Subcommand)] +enum ModeCommand { + Get, + Set { + #[arg(long)] + mode: u8, + }, +} + +#[derive(Debug, Subcommand)] +enum BootCommand { + Enter, + Exit, +} + +#[derive(Debug, Subcommand)] +enum FwCommand { + Write { + #[arg(long)] + file: PathBuf, + #[arg(long, default_value_t = 56)] + chunk_size: usize, + #[arg(long)] + dry_run: bool, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + if let Err(err) = run(cli) { + eprintln!("error: {err}"); + return Err(err); + } + Ok(()) +} + +fn run(cli: Cli) -> Result<()> { + match &cli.command { + Commands::List => handle_list(&cli), + Commands::Identify + | Commands::Diag { .. } + | Commands::Profile { .. } + | Commands::Mode { .. } + | Commands::Boot { .. } + | Commands::Fw { .. } => { + let target = resolve_target(&cli)?; + let transport: Box = if cli.mock { + Box::new(mock_transport_for(&cli.command, target)?) + } else { + Box::new(HidTransport::new()) + }; + + let config = SessionConfig { + retry_policy: RetryPolicy { + max_attempts: cli.max_attempts, + backoff_ms: cli.backoff_ms, + }, + timeout_profile: TimeoutProfile { + probe_ms: cli.probe_timeout_ms, + io_ms: cli.io_timeout_ms, + firmware_ms: cli.firmware_timeout_ms, + }, + allow_unsafe: cli.allow_unsafe, + brick_risk_ack: cli.brick_risk_ack, + experimental: cli.experimental, + trace_enabled: true, + }; + let mut session = DeviceSession::new(transport, target, config)?; + + match &cli.command { + Commands::Identify => { + let info = session.identify()?; + if cli.json { + println!("{}", serde_json::to_string_pretty(&info)?); + } else { + println!( + "target={} profile={} support={:?} family={:?} evidence={:?} capability={:?} detected_pid={}", + info.target, + info.profile_name, + info.support_level, + info.protocol_family, + info.evidence, + info.capability, + info.detected_pid + .map(|v| format!("{v:#06x}")) + .unwrap_or_else(|| "none".to_owned()) + ); + } + } + Commands::Diag { command } => match command { + DiagCommand::Probe => { + let diag = session.diag_probe(); + if cli.json { + println!("{}", serde_json::to_string_pretty(&diag)?); + } else { + println!( + "diag target={} profile={} family={:?}", + diag.target, diag.profile_name, diag.protocol_family + ); + for check in diag.command_checks { + println!( + " {:?}: ok={} code={}", + check.command, + check.ok, + check + .error_code + .map(|c| format!("{c:?}")) + .unwrap_or_else(|| "none".to_owned()) + ); + } + } + } + }, + Commands::Mode { command } => match command { + ModeCommand::Get => { + let mode = session.get_mode()?; + print_mode(mode.mode, &mode.source, cli.json); + } + ModeCommand::Set { mode } => { + let mode_state = session.set_mode(*mode)?; + print_mode(mode_state.mode, &mode_state.source, cli.json); + } + }, + Commands::Profile { command } => match command { + ProfileCommand::Dump { slot } => { + let profile = session.read_profile(*slot)?; + if cli.json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "slot": profile.slot, + "payload_hex": hex::encode(&profile.payload), + }))? + ); + } else { + println!( + "slot={} payload_hex={}", + profile.slot, + hex::encode(&profile.payload) + ); + } + } + ProfileCommand::Apply { slot, file } => { + let bytes = fs::read(file)?; + let parsed = ProfileBlob::from_bytes(&bytes)?; + let blob = ProfileBlob { + slot: *slot, + payload: parsed.payload, + }; + session.write_profile(*slot, &blob)?; + if cli.json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "applied": true, + "slot": slot, + }))? + ); + } else { + println!("applied profile to slot={slot}"); + } + } + }, + Commands::Boot { command } => { + match command { + BootCommand::Enter => session.enter_bootloader()?, + BootCommand::Exit => session.exit_bootloader()?, + } + if cli.json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "ok": true, + "command": format!("{:?}", command), + }))? + ); + } else { + println!("{:?} completed", command); + } + } + Commands::Fw { command } => match command { + FwCommand::Write { + file, + chunk_size, + dry_run, + } => { + let image = fs::read(file)?; + let report = session.firmware_transfer(&image, *chunk_size, *dry_run)?; + print_fw_report(report, cli.json)?; + } + }, + Commands::List => unreachable!(), + } + + session.close()?; + Ok(()) + } + } +} + +fn handle_list(cli: &Cli) -> Result<()> { + if cli.mock { + let profile = device_profile_for(VidPid::new(0x2dc8, 0x6009)); + if cli.json { + println!( + "{}", + serde_json::to_string_pretty(&vec![json!({ + "vid": "0x2dc8", + "pid": "0x6009", + "product": "Mock 8BitDo Device", + "support_level": format!("{:?}", profile.support_level), + "protocol_family": format!("{:?}", profile.protocol_family), + "capability": profile.capability, + "evidence": format!("{:?}", profile.evidence), + })])? + ); + } else { + println!("2dc8:6009 Mock 8BitDo Device"); + } + return Ok(()); + } + + let devices = enumerate_hid_devices()?; + let filtered: Vec<_> = devices + .into_iter() + .filter(|d| d.vid_pid.vid == 0x2dc8) + .collect(); + + if cli.json { + let out: Vec<_> = filtered + .iter() + .map(|d| { + let profile = device_profile_for(d.vid_pid); + json!({ + "vid": format!("{:#06x}", d.vid_pid.vid), + "pid": format!("{:#06x}", d.vid_pid.pid), + "product": d.product, + "manufacturer": d.manufacturer, + "serial": d.serial, + "path": d.path, + "support_level": format!("{:?}", profile.support_level), + "protocol_family": format!("{:?}", profile.protocol_family), + "capability": profile.capability, + "evidence": format!("{:?}", profile.evidence), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&out)?); + } else { + for d in &filtered { + println!( + "{} {}", + d.vid_pid, + d.product.as_deref().unwrap_or("(unknown product)") + ); + } + } + Ok(()) +} + +fn resolve_target(cli: &Cli) -> Result { + let vid = cli + .vid + .as_deref() + .map(parse_u16) + .transpose()? + .unwrap_or(0x2dc8); + let pid_str = cli + .pid + .as_deref() + .ok_or_else(|| anyhow!("--pid is required for this command"))?; + let pid = parse_u16(pid_str)?; + Ok(VidPid::new(vid, pid)) +} + +fn parse_u16(input: &str) -> Result { + if let Some(hex) = input + .strip_prefix("0x") + .or_else(|| input.strip_prefix("0X")) + { + return Ok(u16::from_str_radix(hex, 16)?); + } + Ok(input.parse::()?) +} + +fn mock_transport_for(command: &Commands, target: VidPid) -> Result { + let mut t = MockTransport::default(); + match command { + Commands::Identify => { + t.push_read_data(build_pid_response(target.pid)); + } + Commands::Diag { command } => match command { + DiagCommand::Probe => { + t.push_read_data(build_pid_response(target.pid)); + t.push_read_data(build_rr_response()); + t.push_read_data(build_mode_response(2)); + t.push_read_data(build_version_response()); + } + }, + Commands::Mode { command } => match command { + ModeCommand::Get => t.push_read_data(build_mode_response(2)), + ModeCommand::Set { mode } => { + t.push_read_data(build_ack_response()); + t.push_read_data(build_mode_response(*mode)); + } + }, + Commands::Profile { command } => match command { + ProfileCommand::Dump { slot } => { + let mut raw = vec![0x02, 0x06, 0x00, *slot]; + raw.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]); + t.push_read_data(raw); + } + ProfileCommand::Apply { .. } => { + t.push_read_data(build_ack_response()); + } + }, + Commands::Boot { .. } => {} + Commands::Fw { command } => { + let chunks = match command { + FwCommand::Write { + file, + chunk_size, + dry_run, + } => { + if *dry_run { + 0 + } else { + let sz = fs::metadata(file).map(|m| m.len() as usize).unwrap_or(0); + sz.div_ceil(*chunk_size) + 1 + } + } + }; + for _ in 0..chunks { + t.push_read_data(build_ack_response()); + } + } + Commands::List => {} + } + + if matches!(command, Commands::Profile { .. } | Commands::Fw { .. }) + && !command_registry() + .iter() + .any(|c| c.id == CommandId::ReadProfile) + { + return Err(anyhow!("command registry is empty")); + } + + Ok(t) +} + +fn build_ack_response() -> Vec { + vec![0x02, 0x01, 0x00, 0x00] +} + +fn build_mode_response(mode: u8) -> Vec { + let mut out = vec![0u8; 64]; + out[0] = 0x02; + out[1] = 0x05; + out[5] = mode; + out +} + +fn build_rr_response() -> Vec { + let mut out = vec![0u8; 64]; + out[0] = 0x02; + out[1] = 0x04; + out[5] = 0x01; + out +} + +fn build_version_response() -> Vec { + let mut out = vec![0u8; 64]; + out[0] = 0x02; + out[1] = 0x22; + out[2] = 0x2A; + out[3] = 0x00; + out[4] = 0x01; + out +} + +fn build_pid_response(pid: u16) -> Vec { + let mut out = vec![0u8; 64]; + out[0] = 0x02; + out[1] = 0x05; + out[4] = 0xC1; + let [lo, hi] = pid.to_le_bytes(); + out[22] = lo; + out[23] = hi; + out +} + +fn print_mode(mode: u8, source: &str, as_json: bool) { + if as_json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "mode": mode, + "source": source, + })) + .expect("json serialization") + ); + } else { + println!("mode={} source={}", mode, source); + } +} + +fn print_fw_report(report: FirmwareTransferReport, as_json: bool) -> Result<()> { + if as_json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!( + "bytes_total={} chunk_size={} chunks_sent={} dry_run={}", + report.bytes_total, report.chunk_size, report.chunks_sent, report.dry_run + ); + } + Ok(()) +} + +#[allow(dead_code)] +fn print_error_code(code: BitdoErrorCode, as_json: bool) { + if as_json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ "error_code": format!("{:?}", code) })) + .expect("json serialization") + ); + } else { + println!("error_code={:?}", code); + } +} diff --git a/sdk/scripts/cleanroom_guard.sh b/sdk/scripts/cleanroom_guard.sh new file mode 100755 index 0000000..d01f629 --- /dev/null +++ b/sdk/scripts/cleanroom_guard.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +forbidden_pattern='decompiled(_dll|_autoupdate)?/|bundle_extract/|extracted(_net)?/|session-ses_35e4|8BitDo_Ultimate_Software_V2\.decompiled\.cs' +scan_paths=(crates tests .github) + +if rg -n --hidden -g '!target/**' "$forbidden_pattern" "${scan_paths[@]}"; then + echo "cleanroom guard failed: forbidden dirty-room reference detected" + exit 1 +fi + +echo "cleanroom guard passed" diff --git a/sdk/scripts/run_hardware_smoke.sh b/sdk/scripts/run_hardware_smoke.sh new file mode 100755 index 0000000..1a8eed6 --- /dev/null +++ b/sdk/scripts/run_hardware_smoke.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +REPORT_DIR="${ROOT}/../harness/reports" +mkdir -p "$REPORT_DIR" +TS="$(date +%Y%m%d-%H%M%S)" +REPORT_PATH="${1:-$REPORT_DIR/hardware_smoke_${TS}.json}" + +LIST_JSON="$(cargo run -q -p bitdoctl -- --json list 2>/dev/null || echo '[]')" + +TEST_OUTPUT_FILE="$(mktemp)" +set +e +BITDO_HARDWARE=1 cargo test --workspace --test hardware_smoke -- --ignored >"$TEST_OUTPUT_FILE" 2>&1 +TEST_STATUS=$? +set -e + +python3 - <<'PY' "$REPORT_PATH" "$TEST_STATUS" "$TEST_OUTPUT_FILE" "$LIST_JSON" +import json, sys, pathlib, datetime +report_path = pathlib.Path(sys.argv[1]) +test_status = int(sys.argv[2]) +output_file = pathlib.Path(sys.argv[3]) +list_json_raw = sys.argv[4] + +try: + devices = json.loads(list_json_raw) +except Exception: + devices = [] + +report = { + "timestamp_utc": datetime.datetime.utcnow().isoformat() + "Z", + "test_status": test_status, + "tests_passed": test_status == 0, + "devices": devices, + "raw_test_output": output_file.read_text(errors="replace"), +} + +report_path.write_text(json.dumps(report, indent=2)) +print(report_path) +PY + +rm -f "$TEST_OUTPUT_FILE" +echo "hardware smoke report written: $REPORT_PATH" diff --git a/sdk/tests/boot_safety.rs b/sdk/tests/boot_safety.rs new file mode 100644 index 0000000..dff54b8 --- /dev/null +++ b/sdk/tests/boot_safety.rs @@ -0,0 +1,41 @@ +use bitdo_proto::{BitdoError, DeviceSession, MockTransport, SessionConfig, VidPid}; + +#[test] +fn unsafe_boot_requires_dual_ack() { + let transport = MockTransport::default(); + let mut session = DeviceSession::new( + transport, + VidPid::new(0x2dc8, 24585), + SessionConfig { + allow_unsafe: true, + brick_risk_ack: false, + experimental: true, + ..SessionConfig::default() + }, + ) + .expect("session init"); + + let err = session.enter_bootloader().expect_err("expected denial"); + match err { + BitdoError::UnsafeCommandDenied { .. } => {} + other => panic!("unexpected error: {other:?}"), + } +} + +#[test] +fn unsafe_boot_succeeds_with_dual_ack() { + let transport = MockTransport::default(); + let mut session = DeviceSession::new( + transport, + VidPid::new(0x2dc8, 24585), + SessionConfig { + allow_unsafe: true, + brick_risk_ack: true, + experimental: true, + ..SessionConfig::default() + }, + ) + .expect("session init"); + + session.enter_bootloader().expect("boot sequence"); +} diff --git a/sdk/tests/capability_gating.rs b/sdk/tests/capability_gating.rs new file mode 100644 index 0000000..1928e11 --- /dev/null +++ b/sdk/tests/capability_gating.rs @@ -0,0 +1,23 @@ +use bitdo_proto::{BitdoError, DeviceSession, MockTransport, SessionConfig, VidPid}; + +#[test] +fn detect_only_pid_blocks_unsafe_operations() { + let transport = MockTransport::default(); + let config = SessionConfig { + allow_unsafe: true, + brick_risk_ack: true, + experimental: true, + ..SessionConfig::default() + }; + + let mut session = + DeviceSession::new(transport, VidPid::new(0x2dc8, 8448), config).expect("session init"); + + let err = session + .enter_bootloader() + .expect_err("must reject unsafe op"); + match err { + BitdoError::UnsupportedForPid { .. } => {} + other => panic!("unexpected error: {other:?}"), + } +} diff --git a/sdk/tests/cleanroom_guard.rs b/sdk/tests/cleanroom_guard.rs new file mode 100644 index 0000000..3fd822f --- /dev/null +++ b/sdk/tests/cleanroom_guard.rs @@ -0,0 +1,17 @@ +use std::path::PathBuf; +use std::process::Command; + +#[test] +fn guard_script_passes() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let sdk_root = manifest.join("../.."); + let script = sdk_root.join("scripts/cleanroom_guard.sh"); + + let status = Command::new("bash") + .arg(script) + .current_dir(&sdk_root) + .status() + .expect("run cleanroom_guard.sh"); + + assert!(status.success()); +} diff --git a/sdk/tests/cli_snapshot.rs b/sdk/tests/cli_snapshot.rs new file mode 100644 index 0000000..98dd765 --- /dev/null +++ b/sdk/tests/cli_snapshot.rs @@ -0,0 +1,66 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use predicates::prelude::*; +use std::fs; + +#[test] +fn list_mock_text_snapshot() { + let mut cmd = cargo_bin_cmd!("bitdoctl"); + cmd.args(["--mock", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("2dc8:6009")); +} + +#[test] +fn identify_mock_json_snapshot() { + let mut cmd = cargo_bin_cmd!("bitdoctl"); + cmd.args(["--mock", "--json", "--pid", "24585", "identify"]) + .assert() + .success() + .stdout(predicate::str::contains("\"capability\"")); +} + +#[test] +fn mode_get_mock_json_snapshot() { + let mut cmd = cargo_bin_cmd!("bitdoctl"); + cmd.args(["--mock", "--json", "--pid", "24585", "mode", "get"]) + .assert() + .success() + .stdout(predicate::str::contains("\"mode\": 2")); +} + +#[test] +fn diag_probe_mock_json_snapshot() { + let mut cmd = cargo_bin_cmd!("bitdoctl"); + cmd.args(["--mock", "--json", "--pid", "24585", "diag", "probe"]) + .assert() + .success() + .stdout(predicate::str::contains("\"command_checks\"")); +} + +#[test] +fn firmware_dry_run_snapshot() { + let tmp = std::env::temp_dir().join("bitdoctl-fw-test.bin"); + fs::write(&tmp, vec![0xAA; 128]).expect("write temp fw"); + + let mut cmd = cargo_bin_cmd!("bitdoctl"); + cmd.args([ + "--mock", + "--json", + "--pid", + "24585", + "--unsafe", + "--i-understand-brick-risk", + "--experimental", + "fw", + "write", + "--file", + tmp.to_str().expect("path"), + "--dry-run", + ]) + .assert() + .success() + .stdout(predicate::str::contains("\"dry_run\": true")); + + let _ = fs::remove_file(tmp); +} diff --git a/sdk/tests/diag_probe.rs b/sdk/tests/diag_probe.rs new file mode 100644 index 0000000..845a227 --- /dev/null +++ b/sdk/tests/diag_probe.rs @@ -0,0 +1,45 @@ +use bitdo_proto::{DeviceSession, MockTransport, SessionConfig, VidPid}; + +#[test] +fn diag_probe_returns_command_checks() { + let mut transport = MockTransport::default(); + + let mut pid = vec![0u8; 64]; + pid[0] = 0x02; + pid[1] = 0x05; + pid[4] = 0xC1; + pid[22] = 0x09; + pid[23] = 0x60; + transport.push_read_data(pid); + + let mut rr = vec![0u8; 64]; + rr[0] = 0x02; + rr[1] = 0x04; + rr[5] = 0x01; + transport.push_read_data(rr); + + let mut mode = vec![0u8; 64]; + mode[0] = 0x02; + mode[1] = 0x05; + mode[5] = 2; + transport.push_read_data(mode); + + let mut ver = vec![0u8; 64]; + ver[0] = 0x02; + ver[1] = 0x22; + ver[2] = 0x2A; + ver[3] = 0x00; + ver[4] = 1; + transport.push_read_data(ver); + + let mut session = DeviceSession::new( + transport, + VidPid::new(0x2dc8, 24585), + SessionConfig::default(), + ) + .expect("session init"); + + let diag = session.diag_probe(); + assert_eq!(diag.command_checks.len(), 4); + assert!(diag.command_checks.iter().all(|c| c.ok)); +} diff --git a/sdk/tests/error_codes.rs b/sdk/tests/error_codes.rs new file mode 100644 index 0000000..4005d6a --- /dev/null +++ b/sdk/tests/error_codes.rs @@ -0,0 +1,10 @@ +use bitdo_proto::{BitdoError, BitdoErrorCode}; + +#[test] +fn bitdo_error_maps_to_stable_codes() { + let err = BitdoError::InvalidInput("bad".to_owned()); + assert_eq!(err.code(), BitdoErrorCode::InvalidInput); + + let err = BitdoError::Timeout; + assert_eq!(err.code(), BitdoErrorCode::Timeout); +} diff --git a/sdk/tests/firmware_chunk.rs b/sdk/tests/firmware_chunk.rs new file mode 100644 index 0000000..8580d18 --- /dev/null +++ b/sdk/tests/firmware_chunk.rs @@ -0,0 +1,30 @@ +use bitdo_proto::{DeviceSession, MockTransport, SessionConfig, VidPid}; + +#[test] +fn firmware_transfer_chunks_and_commit() { + let mut transport = MockTransport::default(); + for _ in 0..4 { + transport.push_read_data(vec![0x02, 0x10, 0x00, 0x00]); + } + + let mut session = DeviceSession::new( + transport, + VidPid::new(0x2dc8, 24585), + SessionConfig { + allow_unsafe: true, + brick_risk_ack: true, + experimental: true, + ..SessionConfig::default() + }, + ) + .expect("session init"); + + let image = vec![0xAB; 120]; + let report = session + .firmware_transfer(&image, 50, false) + .expect("fw transfer"); + assert_eq!(report.chunks_sent, 3); + + let transport = session.into_transport(); + assert_eq!(transport.writes().len(), 4); +} diff --git a/sdk/tests/frame_roundtrip.rs b/sdk/tests/frame_roundtrip.rs new file mode 100644 index 0000000..28445cb --- /dev/null +++ b/sdk/tests/frame_roundtrip.rs @@ -0,0 +1,23 @@ +use bitdo_proto::{command_registry, CommandFrame, CommandId, Report64}; + +#[test] +fn frame_encode_decode_roundtrip_for_all_commands() { + assert_eq!(command_registry().len(), CommandId::all().len()); + + for row in command_registry() { + let frame = CommandFrame { + id: row.id, + payload: row.request.to_vec(), + report_id: row.report_id, + expected_response: row.expected_response, + }; + + let encoded = frame.encode(); + if encoded.len() == 64 { + let parsed = Report64::try_from(encoded.as_slice()).expect("64-byte frame parses"); + assert_eq!(parsed.as_slice(), encoded.as_slice()); + } else { + assert!(!encoded.is_empty()); + } + } +} diff --git a/sdk/tests/hardware_smoke.rs b/sdk/tests/hardware_smoke.rs new file mode 100644 index 0000000..db287b9 --- /dev/null +++ b/sdk/tests/hardware_smoke.rs @@ -0,0 +1,96 @@ +use bitdo_proto::{device_profile_for, enumerate_hid_devices, ProtocolFamily, VidPid}; + +fn hardware_enabled() -> bool { + std::env::var("BITDO_HARDWARE").ok().as_deref() == Some("1") +} + +fn expected_pid(env_key: &str) -> Option { + std::env::var(env_key).ok().and_then(|v| { + let trimmed = v.trim(); + if let Some(hex) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + u16::from_str_radix(hex, 16).ok() + } else { + trimmed.parse::().ok() + } + }) +} + +#[test] +#[ignore = "requires lab hardware and BITDO_HARDWARE=1"] +fn hardware_smoke_detect_devices() { + if !hardware_enabled() { + eprintln!("BITDO_HARDWARE!=1, skipping"); + return; + } + + let devices = enumerate_hid_devices().expect("enumeration"); + let eight_bitdo: Vec<_> = devices + .into_iter() + .filter(|d| d.vid_pid.vid == 0x2dc8) + .collect(); + + assert!(!eight_bitdo.is_empty(), "no 8BitDo devices detected"); +} + +#[test] +#[ignore = "optional family check; set BITDO_EXPECT_DINPUT_PID"] +fn hardware_smoke_dinput_family() { + if !hardware_enabled() { + return; + } + let Some(pid) = expected_pid("BITDO_EXPECT_DINPUT_PID") else { + eprintln!("BITDO_EXPECT_DINPUT_PID not set, skipping DInput family check"); + return; + }; + + let profile = device_profile_for(VidPid::new(0x2dc8, pid)); + assert_eq!( + profile.protocol_family, + ProtocolFamily::DInput, + "expected DInput family for pid={pid:#06x}, got {:?}", + profile.protocol_family + ); +} + +#[test] +#[ignore = "optional family check; set BITDO_EXPECT_STANDARD64_PID"] +fn hardware_smoke_standard64_family() { + if !hardware_enabled() { + return; + } + let Some(pid) = expected_pid("BITDO_EXPECT_STANDARD64_PID") else { + eprintln!("BITDO_EXPECT_STANDARD64_PID not set, skipping Standard64 family check"); + return; + }; + + let profile = device_profile_for(VidPid::new(0x2dc8, pid)); + assert_eq!( + profile.protocol_family, + ProtocolFamily::Standard64, + "expected Standard64 family for pid={pid:#06x}, got {:?}", + profile.protocol_family + ); +} + +#[test] +#[ignore = "optional family check; set BITDO_EXPECT_JPHANDSHAKE_PID"] +fn hardware_smoke_jphandshake_family() { + if !hardware_enabled() { + return; + } + let Some(pid) = expected_pid("BITDO_EXPECT_JPHANDSHAKE_PID") else { + eprintln!("BITDO_EXPECT_JPHANDSHAKE_PID not set, skipping JpHandshake family check"); + return; + }; + + let profile = device_profile_for(VidPid::new(0x2dc8, pid)); + assert_eq!( + profile.protocol_family, + ProtocolFamily::JpHandshake, + "expected JpHandshake family for pid={pid:#06x}, got {:?}", + profile.protocol_family + ); +} diff --git a/sdk/tests/mode_switch_readback.rs b/sdk/tests/mode_switch_readback.rs new file mode 100644 index 0000000..64326cc --- /dev/null +++ b/sdk/tests/mode_switch_readback.rs @@ -0,0 +1,34 @@ +use bitdo_proto::{ + DeviceSession, MockTransport, RetryPolicy, SessionConfig, TimeoutProfile, VidPid, +}; + +#[test] +fn set_mode_reads_back_latest_mode() { + let mut transport = MockTransport::default(); + transport.push_read_data(vec![0x02, 0x01, 0x00, 0x00]); + + let mut mode = vec![0u8; 64]; + mode[0] = 0x02; + mode[1] = 0x05; + mode[5] = 3; + transport.push_read_data(mode); + + let config = SessionConfig { + retry_policy: RetryPolicy { + max_attempts: 2, + backoff_ms: 0, + }, + timeout_profile: TimeoutProfile { + probe_ms: 10, + io_ms: 10, + firmware_ms: 10, + }, + ..SessionConfig::default() + }; + + let mut session = + DeviceSession::new(transport, VidPid::new(0x2dc8, 24585), config).expect("session init"); + + let mode_state = session.set_mode(3).expect("set mode"); + assert_eq!(mode_state.mode, 3); +} diff --git a/sdk/tests/parser_rejection.rs b/sdk/tests/parser_rejection.rs new file mode 100644 index 0000000..621d4cb --- /dev/null +++ b/sdk/tests/parser_rejection.rs @@ -0,0 +1,29 @@ +use bitdo_proto::{validate_response, CommandId, ResponseStatus}; + +#[test] +fn malformed_response_is_rejected() { + let status = validate_response(CommandId::GetPid, &[0x02]); + assert_eq!(status, ResponseStatus::Malformed); +} + +#[test] +fn invalid_signature_is_rejected() { + let mut bad = vec![0u8; 64]; + bad[0] = 0x00; + bad[1] = 0x05; + bad[4] = 0xC1; + let status = validate_response(CommandId::GetPid, &bad); + assert_eq!(status, ResponseStatus::Invalid); +} + +#[test] +fn valid_signature_is_accepted() { + let mut good = vec![0u8; 64]; + good[0] = 0x02; + good[1] = 0x05; + good[4] = 0xC1; + good[22] = 0x09; + good[23] = 0x60; + let status = validate_response(CommandId::GetPid, &good); + assert_eq!(status, ResponseStatus::Ok); +} diff --git a/sdk/tests/pid_matrix_coverage.rs b/sdk/tests/pid_matrix_coverage.rs new file mode 100644 index 0000000..34ffe7d --- /dev/null +++ b/sdk/tests/pid_matrix_coverage.rs @@ -0,0 +1,16 @@ +use bitdo_proto::pid_registry; +use std::fs; +use std::path::PathBuf; + +#[test] +fn pid_registry_matches_spec_rows() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let csv_path = manifest.join("../../../spec/pid_matrix.csv"); + let content = fs::read_to_string(csv_path).expect("read pid_matrix.csv"); + let rows = content + .lines() + .skip(1) + .filter(|l| !l.trim().is_empty()) + .count(); + assert_eq!(rows, pid_registry().len()); +} diff --git a/sdk/tests/profile_serialization.rs b/sdk/tests/profile_serialization.rs new file mode 100644 index 0000000..abc56e8 --- /dev/null +++ b/sdk/tests/profile_serialization.rs @@ -0,0 +1,17 @@ +use bitdo_proto::ProfileBlob; +use std::fs; +use std::path::PathBuf; + +#[test] +fn golden_profile_fixture_roundtrips() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let path = manifest.join("../../../harness/golden/profile_fixture.bin"); + let fixture = fs::read(path).expect("read fixture"); + + let blob = ProfileBlob::from_bytes(&fixture).expect("parse fixture"); + assert_eq!(blob.slot, 2); + assert_eq!(blob.payload.len(), 16); + + let serialized = blob.to_bytes(); + assert_eq!(serialized, fixture); +} diff --git a/sdk/tests/retry_timeout.rs b/sdk/tests/retry_timeout.rs new file mode 100644 index 0000000..e104b8f --- /dev/null +++ b/sdk/tests/retry_timeout.rs @@ -0,0 +1,86 @@ +use bitdo_proto::{ + DeviceSession, MockTransport, RetryPolicy, SessionConfig, TimeoutProfile, VidPid, +}; + +#[test] +fn retries_after_timeout_then_succeeds() { + let mut transport = MockTransport::default(); + transport.push_read_timeout(); + let mut good = vec![0u8; 64]; + good[0] = 0x02; + good[1] = 0x05; + good[4] = 0xC1; + good[22] = 0x09; + good[23] = 0x60; + transport.push_read_data(good); + + let config = SessionConfig { + retry_policy: RetryPolicy { + max_attempts: 3, + backoff_ms: 0, + }, + timeout_profile: TimeoutProfile { + probe_ms: 1, + io_ms: 1, + firmware_ms: 1, + }, + allow_unsafe: false, + brick_risk_ack: false, + experimental: false, + trace_enabled: true, + }; + let mut session = + DeviceSession::new(transport, VidPid::new(0x2dc8, 24585), config).expect("session init"); + + let response = session + .send_command(bitdo_proto::CommandId::GetPid, None) + .expect("response"); + assert_eq!( + response.parsed_fields.get("detected_pid").copied(), + Some(24585) + ); +} + +#[test] +fn retries_after_malformed_then_succeeds() { + let mut transport = MockTransport::default(); + let mut malformed = vec![0u8; 64]; + malformed[0] = 0x00; + malformed[1] = 0x05; + malformed[4] = 0xC1; + transport.push_read_data(malformed); + + let mut good = vec![0u8; 64]; + good[0] = 0x02; + good[1] = 0x05; + good[4] = 0xC1; + good[22] = 0x09; + good[23] = 0x60; + transport.push_read_data(good); + + let config = SessionConfig { + retry_policy: RetryPolicy { + max_attempts: 3, + backoff_ms: 0, + }, + timeout_profile: TimeoutProfile { + probe_ms: 1, + io_ms: 1, + firmware_ms: 1, + }, + allow_unsafe: false, + brick_risk_ack: false, + experimental: false, + trace_enabled: true, + }; + let mut session = + DeviceSession::new(transport, VidPid::new(0x2dc8, 24585), config).expect("session init"); + + let response = session + .send_command(bitdo_proto::CommandId::GetPid, None) + .expect("response"); + assert_eq!( + response.parsed_fields.get("detected_pid").copied(), + Some(24585) + ); +} diff --git a/spec/command_matrix.csv b/spec/command_matrix.csv new file mode 100644 index 0000000..bc79462 --- /dev/null +++ b/spec/command_matrix.csv @@ -0,0 +1,18 @@ +command_id,safety_class,confidence,experimental_default,report_id,request_len,request_hex,expected_response,notes +GetPid,SafeRead,confirmed,false,0x81,64,8105c100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,byte0=0x02;byte1=0x05;byte4=0xC1,"Primary PID detection request" +GetReportRevision,SafeRead,confirmed,false,0x81,64,81040001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,byte0=0x02;byte1=0x04;byte5=0x01,"RR read preflight" +GetMode,SafeRead,confirmed,false,0x81,64,81040501000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,byte0=0x02;byte1=0x05,"Mode read" +GetModeAlt,SafeRead,confirmed,false,0x81,64,81050800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,byte0=0x02;byte1=0x05,"Alternate mode read" +GetControllerVersion,SafeRead,confirmed,false,0x81,64,81042101000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,byte0=0x02;byte1=0x22,"Controller version" +GetSuperButton,SafeRead,inferred,true,0x81,64,81052100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,byte0=0x02;byte1=0x05,"Super button capability" +SetModeDInput,SafeWrite,confirmed,false,0x81,64,81050051020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,byte0=0x02,"Mode write to DInput" +Idle,SafeRead,confirmed,false,0x81,64,81040001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,byte0=0x02,"Idle check" +Version,SafeRead,confirmed,false,0x81,64,81042101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,byte1=0x22,"Version check" +ReadProfile,SafeRead,inferred,true,0x81,64,81060001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,byte0=0x02,"Profile slot read (sanitized)" +WriteProfile,SafeWrite,inferred,true,0x81,64,81070001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,byte0=0x02,"Profile slot write (sanitized)" +EnterBootloaderA,UnsafeBoot,confirmed,false,0x81,6,050050010000,none,"Boot stage A" +EnterBootloaderB,UnsafeBoot,confirmed,false,0x81,6,005100000000,none,"Boot stage B" +EnterBootloaderC,UnsafeBoot,confirmed,false,0x81,5,0050000000,none,"Boot stage C" +ExitBootloader,UnsafeBoot,inferred,true,0x81,6,050051010000,none,"Boot exit (sanitized inferred)" +FirmwareChunk,UnsafeFirmware,inferred,true,0x81,64,81100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,byte0=0x02,"Firmware chunk transfer" +FirmwareCommit,UnsafeFirmware,inferred,true,0x81,64,81110000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,byte0=0x02,"Firmware commit" diff --git a/spec/pid_matrix.csv b/spec/pid_matrix.csv new file mode 100644 index 0000000..27c0530 --- /dev/null +++ b/spec/pid_matrix.csv @@ -0,0 +1,60 @@ +pid_name,pid_decimal,pid_hex,vid_decimal,vid_hex,support_level,protocol_family,notes +PID_None,0,0x0,11720,0x2dc8,detect-only,Unknown,Sentinel value +PID_IDLE,12553,0x3109,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_SN30Plus,24578,0x6002,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_USB_Ultimate,12544,0x3100,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_USB_Ultimate2,12549,0x3105,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_USB_UltimateClasses,12548,0x3104,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_Xcloud,8448,0x2100,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_Xcloud2,8449,0x2101,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_ArcadeStick,36890,0x901a,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_Pro2,24579,0x6003,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_Pro2_CY,24582,0x6006,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_Pro2_OLD,24579,0x6003,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_Pro2_Wired,12304,0x3010,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_Ultimate_PC,12305,0x3011,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_Ultimate2_4,12306,0x3012,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_Ultimate2_4RR,12307,0x3013,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_UltimateBT,24583,0x6007,11720,0x2dc8,full,Standard64,Baseline from sanitized dirty-room analysis +PID_UltimateBTRR,12550,0x3106,11720,0x2dc8,full,Standard64,Baseline from sanitized dirty-room analysis +PID_JP,20992,0x5200,11720,0x2dc8,detect-only,JpHandshake,Baseline from sanitized dirty-room analysis +PID_JPUSB,20993,0x5201,11720,0x2dc8,detect-only,JpHandshake,Baseline from sanitized dirty-room analysis +PID_NUMPAD,20995,0x5203,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_NUMPADRR,20996,0x5204,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_QINGCHUN2,12554,0x310a,11720,0x2dc8,full,DInput,Baseline from sanitized dirty-room analysis +PID_QINGCHUN2RR,12316,0x301c,11720,0x2dc8,full,DInput,Baseline from sanitized dirty-room analysis +PID_Xinput,12555,0x310b,11720,0x2dc8,detect-only,DInput,Baseline from sanitized dirty-room analysis +PID_Pro3,24585,0x6009,11720,0x2dc8,full,DInput,Baseline from sanitized dirty-room analysis +PID_Pro3USB,24586,0x600a,11720,0x2dc8,full,DInput,Baseline from sanitized dirty-room analysis +PID_Pro3DOCK,24589,0x600d,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_108JP,21001,0x5209,11720,0x2dc8,detect-only,JpHandshake,Baseline from sanitized dirty-room analysis +PID_108JPUSB,21002,0x520a,11720,0x2dc8,detect-only,JpHandshake,Baseline from sanitized dirty-room analysis +PID_XBOXJP,8232,0x2028,11720,0x2dc8,detect-only,JpHandshake,Baseline from sanitized dirty-room analysis +PID_XBOXJPUSB,8238,0x202e,11720,0x2dc8,detect-only,JpHandshake,Baseline from sanitized dirty-room analysis +PID_NGCDIY,22352,0x5750,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_NGCRR,36906,0x902a,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_Ultimate2,24594,0x6012,11720,0x2dc8,full,DInput,Baseline from sanitized dirty-room analysis +PID_Ultimate2RR,24595,0x6013,11720,0x2dc8,full,DInput,Baseline from sanitized dirty-room analysis +PID_UltimateBT2,24591,0x600f,11720,0x2dc8,full,DInput,Baseline from sanitized dirty-room analysis +PID_UltimateBT2RR,24593,0x6011,11720,0x2dc8,full,DInput,Baseline from sanitized dirty-room analysis +PID_Mouse,20997,0x5205,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_MouseRR,20998,0x5206,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_SaturnRR,36907,0x902b,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_UltimateBT2C,12314,0x301a,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_Lashen,12318,0x301e,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_HitBox,24587,0x600b,11720,0x2dc8,full,DInput,Baseline from sanitized dirty-room analysis +PID_HitBoxRR,24588,0x600c,11720,0x2dc8,full,DInput,Baseline from sanitized dirty-room analysis +PID_N64BT,12313,0x3019,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_N64,12292,0x3004,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_N64RR,36904,0x9028,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_XBOXUK,12326,0x3026,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_XBOXUKUSB,12327,0x3027,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_LashenX,8203,0x200b,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_68JP,8250,0x203a,11720,0x2dc8,detect-only,JpHandshake,Baseline from sanitized dirty-room analysis +PID_68JPUSB,8265,0x2049,11720,0x2dc8,detect-only,JpHandshake,Baseline from sanitized dirty-room analysis +PID_N64JoySticks,12321,0x3021,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_DoubleSuper,8254,0x203e,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_Cube2RR,8278,0x2056,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_Cube2,8249,0x2039,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis +PID_ASLGJP,8282,0x205a,11720,0x2dc8,detect-only,JpHandshake,Baseline from sanitized dirty-room analysis +PID_ASLGMouse,20997,0x5205,11720,0x2dc8,detect-only,Standard64,Baseline from sanitized dirty-room analysis diff --git a/spec/protocol_spec.md b/spec/protocol_spec.md new file mode 100644 index 0000000..e03384c --- /dev/null +++ b/spec/protocol_spec.md @@ -0,0 +1,42 @@ +# 8BitDo Clean-Room Protocol Specification (Sanitized) + +## Scope +This document defines a sanitized command and transport contract for a clean-room Rust implementation. +It is intentionally independent from reverse-engineered source code details and uses stable requirement IDs. + +## Wire Model +- Transport: HID-like reports +- Primary report width: 64 bytes (`Standard64`, `DInput`, `JpHandshake` families) +- Variable-length reports: allowed for boot/firmware phases +- Byte order: little-endian for multi-byte numeric fields + +## Protocol Families +- `Standard64`: standard 64-byte command and response flow +- `JpHandshake`: alternate handshake and version probing workflow +- `DInput`: command family used for mode and runtime profile operations +- `DS4Boot`: reserved boot mode for DS4-style update path +- `Unknown`: fallback for unknown devices + +## Safety Classes +- `SafeRead`: read-only operations +- `SafeWrite`: runtime settings/profile writes +- `UnsafeBoot`: bootloader transitions with brick risk +- `UnsafeFirmware`: firmware transfer/commit operations with brick risk + +## Response Validation Contract +- Responses are validated per command against byte-pattern expectations from `command_matrix.csv` +- Validation outcomes: `Ok`, `Invalid`, `Malformed` +- Retry policy applies on `Malformed` or timeout responses + +## Device Support Levels +- `full`: command execution permitted for safe and unsafe operations (with user gates) +- `detect-only`: identification allowed; unsupported operations return `UnsupportedForPid` + +## Required Runtime Gating +Unsafe commands execute only when both conditions are true: +1. `--unsafe` +2. `--i-understand-brick-risk` + +## Clean-Room Requirements Linkage +Implementation and tests must trace to IDs in `requirements.yaml`. +All public APIs and behavior are governed by `REQ-PROT-*`, `REQ-PID-*`, `REQ-SAFE-*`, and `REQ-TEST-*` IDs. diff --git a/spec/requirements.yaml b/spec/requirements.yaml new file mode 100644 index 0000000..af6d605 --- /dev/null +++ b/spec/requirements.yaml @@ -0,0 +1,52 @@ +metadata: + version: 1 + owner: cleanroom-sdk + status: draft +requirements: + - id: REQ-PROT-001 + title: CommandFrame model + description: SDK shall expose CommandFrame with command id, payload, report id, and expected response metadata. + acceptance: Unit tests validate frame creation for all CommandId values. + - id: REQ-PROT-002 + title: Response validation + description: SDK shall validate responses using command-specific byte signatures. + acceptance: Parser rejection tests fail malformed responses and accept matching responses. + - id: REQ-PROT-003 + title: Deterministic retries + description: SDK shall retry reads on timeout/malformed responses using configured retry count. + acceptance: Retry tests cover delayed and partial responses. + - id: REQ-PROT-004 + title: Report width support + description: SDK shall support both 64-byte reports and variable-length boot/firmware frames. + acceptance: Encode/decode tests cover Report64 and variable report wrappers. + + - id: REQ-PID-001 + title: PID registry completeness + description: SDK shall include all PIDs present in sanitized pid_matrix.csv. + acceptance: pid registry coverage test count equals pid_matrix.csv row count. + - id: REQ-PID-002 + title: Support-level gating + description: detect-only devices shall reject unsupported operations with UnsupportedForPid. + acceptance: Capability gating tests verify rejection for unsafe operations on detect-only PIDs. + + - id: REQ-SAFE-001 + title: Unsafe command dual confirmation + description: Unsafe commands shall require both unsafe and brick-risk acknowledgement flags. + acceptance: Boot safety tests verify command denial without both flags. + - id: REQ-SAFE-002 + title: Experimental command policy + description: Inferred commands shall require experimental mode. + acceptance: Inferred-command tests verify denial without experimental flag. + + - id: REQ-TEST-001 + title: Golden profile fixture + description: SDK shall parse and serialize profile blobs compatible with golden binary fixture. + acceptance: Profile serialization test round-trips fixture payload. + - id: REQ-TEST-002 + title: CLI structured output + description: CLI shall provide JSON output for automation. + acceptance: CLI tests assert stable JSON keys and command responses. + - id: REQ-TEST-003 + title: Clean-room guard + description: CI shall fail if cleanroom/sdk references forbidden dirty-room locations or tokens. + acceptance: cleanroom guard script is executed in CI and by integration test.