mirror of
https://github.com/bybrooklyn/openbitdo.git
synced 2026-03-19 04:12:56 -04:00
Bootstrap OpenBitdo clean-room SDK and reliability milestone
This commit is contained in:
55
.github/workflows/ci.yml
vendored
Normal file
55
.github/workflows/ci.yml
vendored
Normal file
@@ -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
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Rust
|
||||||
|
sdk/target/
|
||||||
|
|
||||||
|
# Reports
|
||||||
|
harness/reports/*.json
|
||||||
|
|
||||||
|
# OS/editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
8
README.md
Normal file
8
README.md
Normal file
@@ -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)
|
||||||
1
harness/golden/mock_trace_get_mode.json
Normal file
1
harness/golden/mock_trace_get_mode.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"command":"GetMode","request_hex":"810405...","response_hex":"0205000000000200000000000000000000000000000000000000000000000000"}
|
||||||
1
harness/golden/mock_trace_get_pid.json
Normal file
1
harness/golden/mock_trace_get_pid.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"command":"GetPid","request_hex":"8105c1...","response_hex":"0205c10000000000000000000000000000000000000000005960000000000000"}
|
||||||
BIN
harness/golden/profile_fixture.bin
Normal file
BIN
harness/golden/profile_fixture.bin
Normal file
Binary file not shown.
26
harness/lab/device_lab.yaml
Normal file
26
harness/lab/device_lab.yaml
Normal file
@@ -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
|
||||||
16
process/branch_policy.md
Normal file
16
process/branch_policy.md
Normal file
@@ -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.
|
||||||
23
process/cleanroom_rules.md
Normal file
23
process/cleanroom_rules.md
Normal file
@@ -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.
|
||||||
522
sdk/Cargo.lock
generated
Normal file
522
sdk/Cargo.lock
generated
Normal file
@@ -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"
|
||||||
19
sdk/Cargo.toml
Normal file
19
sdk/Cargo.toml
Normal file
@@ -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"
|
||||||
30
sdk/README.md
Normal file
30
sdk/README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
76
sdk/crates/bitdo_proto/Cargo.toml
Normal file
76
sdk/crates/bitdo_proto/Cargo.toml
Normal file
@@ -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"
|
||||||
121
sdk/crates/bitdo_proto/build.rs
Normal file
121
sdk/crates/bitdo_proto/build.rs
Normal file
@@ -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::<bool>()
|
||||||
|
.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::<u8>().expect("invalid u8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_to_bytes(hex: &str) -> Vec<u8> {
|
||||||
|
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
|
||||||
|
}
|
||||||
60
sdk/crates/bitdo_proto/src/command.rs
Normal file
60
sdk/crates/bitdo_proto/src/command.rs
Normal file
@@ -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,
|
||||||
|
}
|
||||||
65
sdk/crates/bitdo_proto/src/error.rs
Normal file
65
sdk/crates/bitdo_proto/src/error.rs
Normal file
@@ -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<T> = std::result::Result<T, BitdoError>;
|
||||||
56
sdk/crates/bitdo_proto/src/frame.rs
Normal file
56
sdk/crates/bitdo_proto/src/frame.rs
Normal file
@@ -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<Self, Self::Error> {
|
||||||
|
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<u8>);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CommandFrame {
|
||||||
|
pub id: CommandId,
|
||||||
|
pub payload: Vec<u8>,
|
||||||
|
pub report_id: u8,
|
||||||
|
pub expected_response: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandFrame {
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
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<u8>,
|
||||||
|
pub status: ResponseStatus,
|
||||||
|
pub parsed_fields: BTreeMap<String, u32>,
|
||||||
|
}
|
||||||
125
sdk/crates/bitdo_proto/src/hid_transport.rs
Normal file
125
sdk/crates/bitdo_proto/src/hid_transport.rs
Normal file
@@ -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<String>,
|
||||||
|
pub manufacturer: Option<String>,
|
||||||
|
pub serial: Option<String>,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enumerate_hid_devices() -> Result<Vec<EnumeratedDevice>> {
|
||||||
|
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<HidApi>,
|
||||||
|
device: Option<HidDevice>,
|
||||||
|
target: Option<VidPid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<usize> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<usize> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
sdk/crates/bitdo_proto/src/lib.rs
Normal file
30
sdk/crates/bitdo_proto/src/lib.rs
Normal file
@@ -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,
|
||||||
|
};
|
||||||
63
sdk/crates/bitdo_proto/src/profile.rs
Normal file
63
sdk/crates/bitdo_proto/src/profile.rs
Normal file
@@ -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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileBlob {
|
||||||
|
pub fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
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<Self> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
82
sdk/crates/bitdo_proto/src/registry.rs
Normal file
82
sdk/crates/bitdo_proto/src/registry.rs
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
715
sdk/crates/bitdo_proto/src/session.rs
Normal file
715
sdk/crates/bitdo_proto/src/session.rs
Normal file
@@ -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<BitdoErrorCode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DiagCommandStatus {
|
||||||
|
pub command: CommandId,
|
||||||
|
pub ok: bool,
|
||||||
|
pub error_code: Option<BitdoErrorCode>,
|
||||||
|
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<DiagCommandStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<T: Transport> {
|
||||||
|
transport: T,
|
||||||
|
target: VidPid,
|
||||||
|
profile: DeviceProfile,
|
||||||
|
config: SessionConfig,
|
||||||
|
trace: Vec<CommandExecutionReport>,
|
||||||
|
last_execution: Option<CommandExecutionReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Transport> DeviceSession<T> {
|
||||||
|
pub fn new(mut transport: T, target: VidPid, config: SessionConfig) -> Result<Self> {
|
||||||
|
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<IdentifyResult> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
|
||||||
|
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<ModeState> {
|
||||||
|
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<ModeState> {
|
||||||
|
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<ProfileBlob> {
|
||||||
|
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<FirmwareTransferReport> {
|
||||||
|
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<ResponseFrame> {
|
||||||
|
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<ResponseFrame> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<String, u32> {
|
||||||
|
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
|
||||||
|
}
|
||||||
126
sdk/crates/bitdo_proto/src/transport.rs
Normal file
126
sdk/crates/bitdo_proto/src/transport.rs
Normal file
@@ -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<usize>;
|
||||||
|
fn read(&mut self, len: usize, timeout_ms: u64) -> Result<Vec<u8>>;
|
||||||
|
fn write_feature(&mut self, data: &[u8]) -> Result<usize>;
|
||||||
|
fn read_feature(&mut self, len: usize) -> Result<Vec<u8>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Transport + ?Sized> Transport for Box<T> {
|
||||||
|
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<usize> {
|
||||||
|
(**self).write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&mut self, len: usize, timeout_ms: u64) -> Result<Vec<u8>> {
|
||||||
|
(**self).read(len, timeout_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_feature(&mut self, data: &[u8]) -> Result<usize> {
|
||||||
|
(**self).write_feature(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_feature(&mut self, len: usize) -> Result<Vec<u8>> {
|
||||||
|
(**self).read_feature(len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum MockReadEvent {
|
||||||
|
Data(Vec<u8>),
|
||||||
|
Timeout,
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct MockTransport {
|
||||||
|
opened: Option<VidPid>,
|
||||||
|
reads: VecDeque<MockReadEvent>,
|
||||||
|
feature_reads: VecDeque<MockReadEvent>,
|
||||||
|
writes: Vec<Vec<u8>>,
|
||||||
|
feature_writes: Vec<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockTransport {
|
||||||
|
pub fn push_read_data(&mut self, data: Vec<u8>) {
|
||||||
|
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<String>) {
|
||||||
|
self.reads.push_back(MockReadEvent::Error(message.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_feature_read_data(&mut self, data: Vec<u8>) {
|
||||||
|
self.feature_reads.push_back(MockReadEvent::Data(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn writes(&self) -> &[Vec<u8>] {
|
||||||
|
&self.writes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn feature_writes(&self) -> &[Vec<u8>] {
|
||||||
|
&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<usize> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<usize> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
sdk/crates/bitdo_proto/src/types.rs
Normal file
116
sdk/crates/bitdo_proto/src/types.rs
Normal file
@@ -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<Self, Self::Err> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
20
sdk/crates/bitdoctl/Cargo.toml
Normal file
20
sdk/crates/bitdoctl/Cargo.toml
Normal file
@@ -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"
|
||||||
518
sdk/crates/bitdoctl/src/main.rs
Normal file
518
sdk/crates/bitdoctl/src/main.rs
Normal file
@@ -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<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
pid: Option<String>,
|
||||||
|
#[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<dyn Transport> = 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<VidPid> {
|
||||||
|
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<u16> {
|
||||||
|
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::<u16>()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mock_transport_for(command: &Commands, target: VidPid) -> Result<MockTransport> {
|
||||||
|
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<u8> {
|
||||||
|
vec![0x02, 0x01, 0x00, 0x00]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_mode_response(mode: u8) -> Vec<u8> {
|
||||||
|
let mut out = vec![0u8; 64];
|
||||||
|
out[0] = 0x02;
|
||||||
|
out[1] = 0x05;
|
||||||
|
out[5] = mode;
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_rr_response() -> Vec<u8> {
|
||||||
|
let mut out = vec![0u8; 64];
|
||||||
|
out[0] = 0x02;
|
||||||
|
out[1] = 0x04;
|
||||||
|
out[5] = 0x01;
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_version_response() -> Vec<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
sdk/scripts/cleanroom_guard.sh
Executable file
15
sdk/scripts/cleanroom_guard.sh
Executable file
@@ -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"
|
||||||
45
sdk/scripts/run_hardware_smoke.sh
Executable file
45
sdk/scripts/run_hardware_smoke.sh
Executable file
@@ -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"
|
||||||
41
sdk/tests/boot_safety.rs
Normal file
41
sdk/tests/boot_safety.rs
Normal file
@@ -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");
|
||||||
|
}
|
||||||
23
sdk/tests/capability_gating.rs
Normal file
23
sdk/tests/capability_gating.rs
Normal file
@@ -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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
17
sdk/tests/cleanroom_guard.rs
Normal file
17
sdk/tests/cleanroom_guard.rs
Normal file
@@ -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());
|
||||||
|
}
|
||||||
66
sdk/tests/cli_snapshot.rs
Normal file
66
sdk/tests/cli_snapshot.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
45
sdk/tests/diag_probe.rs
Normal file
45
sdk/tests/diag_probe.rs
Normal file
@@ -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));
|
||||||
|
}
|
||||||
10
sdk/tests/error_codes.rs
Normal file
10
sdk/tests/error_codes.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
30
sdk/tests/firmware_chunk.rs
Normal file
30
sdk/tests/firmware_chunk.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
23
sdk/tests/frame_roundtrip.rs
Normal file
23
sdk/tests/frame_roundtrip.rs
Normal file
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
sdk/tests/hardware_smoke.rs
Normal file
96
sdk/tests/hardware_smoke.rs
Normal file
@@ -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<u16> {
|
||||||
|
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::<u16>().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
|
||||||
|
);
|
||||||
|
}
|
||||||
34
sdk/tests/mode_switch_readback.rs
Normal file
34
sdk/tests/mode_switch_readback.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
29
sdk/tests/parser_rejection.rs
Normal file
29
sdk/tests/parser_rejection.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
16
sdk/tests/pid_matrix_coverage.rs
Normal file
16
sdk/tests/pid_matrix_coverage.rs
Normal file
@@ -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());
|
||||||
|
}
|
||||||
17
sdk/tests/profile_serialization.rs
Normal file
17
sdk/tests/profile_serialization.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
86
sdk/tests/retry_timeout.rs
Normal file
86
sdk/tests/retry_timeout.rs
Normal file
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
18
spec/command_matrix.csv
Normal file
18
spec/command_matrix.csv
Normal file
@@ -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"
|
||||||
|
60
spec/pid_matrix.csv
Normal file
60
spec/pid_matrix.csv
Normal file
@@ -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
|
||||||
|
42
spec/protocol_spec.md
Normal file
42
spec/protocol_spec.md
Normal file
@@ -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.
|
||||||
52
spec/requirements.yaml
Normal file
52
spec/requirements.yaml
Normal file
@@ -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.
|
||||||
Reference in New Issue
Block a user