release prep: rc.1 baseline and gating updates

This commit is contained in:
2026-03-02 15:54:55 -05:00
parent 97a42c8802
commit f43b2b24b6
168 changed files with 14708 additions and 982 deletions

2383
sdk/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,33 @@
[workspace]
members = [
"crates/bitdo_proto",
"crates/bitdoctl",
"crates/bitdo_app_core",
"crates/bitdo_tui",
"crates/openbitdo",
]
resolver = "2"
[workspace.package]
edition = "2021"
version = "0.1.0"
license = "MIT"
license = "BSD-3-Clause"
[workspace.dependencies]
anyhow = "1.0"
thiserror = "2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
base64 = "0.22"
ed25519-dalek = { version = "2.1", default-features = false, features = ["std"] }
clap = { version = "4.5", features = ["derive"] }
hex = "0.4"
tokio = { version = "1.48", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "net", "io-util"] }
uuid = { version = "1.18", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["clock", "serde"] }
sha2 = "0.10"
ratatui = "0.29"
crossterm = "0.29"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }

View File

@@ -1,6 +1,10 @@
# OpenBitdo SDK
`bitdo_proto` and `bitdoctl` provide the clean-room protocol core and CLI.
OpenBitdo SDK includes:
- `bitdo_proto`: protocol/transport/session library
- `bitdo_app_core`: shared firmware-first workflow and policy layer
- `bitdo_tui`: Ratatui/Crossterm terminal app
- `openbitdo`: beginner-first launcher (`openbitdo` starts guided TUI)
## Build
```bash
@@ -22,9 +26,97 @@ cargo test --workspace --all-targets
./scripts/run_hardware_smoke.sh
```
## CLI examples
## TUI app examples (`openbitdo`)
```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
cargo run -p openbitdo -- --mock
```
## Beginner-first behavior
- launch with no subcommands
- if no device is connected, OpenBitdo starts in a waiting screen with `Refresh`, `Help`, and `Quit`
- if one device is connected, it is auto-selected and ready for action
- choose `Recommended Update` or `Diagnose` from large clickable actions
- for JP108 devices (`0x5209`/`0x520a`), `Recommended Update` enters a dedicated-button wizard:
- edit `A/B/K1-K8`
- backup + apply
- guided button test
- for Ultimate2 devices (`0x6012`/`0x6013`), `Recommended Update` enters a core-profile wizard:
- choose slot (`Slot1/2/3`)
- set mode
- edit RC mapping slots (`A/B/K1-K8`) with known controller-button targets
- view/edit L2/R2 analog values when capability supports writes
- backup + apply
- guided button test
- firmware path defaults to verified recommended download; local file fallback is prompted if unavailable
- update transfer requires one plain-language `I Understand` confirmation
- detect-only PIDs stay read/diagnostic-only with a clear block reason
- mouse support:
- left click for primary actions
- right click on device rows for context menu actions
- scroll wheel to navigate device list/detail panes
- support reports are TOML only
- beginner mode: `failure_only` (default) or `always`
- advanced mode: `failure_only`, `always`, or `off` (with warning)
- advanced mode is toggled from About (`t` or click) and persisted to OS config TOML
- advanced report hotkeys after a failure report exists:
- `c` copy report path
- `o` open report file
- `f` open report folder
- open About from home (`a` key or click `About`) to view:
- app version
- git commit short and full hash
- build date (UTC)
- compile target triple
- runtime OS/arch
- firmware signing-key fingerprint (short with full-view toggle, plus next-key short)
## Packaging
```bash
./scripts/package-linux.sh v0.0.1-rc.1 x86_64
./scripts/package-linux.sh v0.0.1-rc.1 aarch64
./scripts/package-macos.sh v0.0.1-rc.1 arm64 aarch64-apple-darwin
```
Packaging outputs use:
- `openbitdo-<version>-<os>-<arch>.tar.gz`
- `openbitdo-<version>-<os>-<arch>` standalone binary
- `.sha256` checksum file for each artifact
- macOS arm64 additionally emits `.pkg` (unsigned/ad-hoc for RC)
## Release Workflow
- CI checks remain in `.github/workflows/ci.yml`.
- Tag-based release workflow is in `.github/workflows/release.yml`.
- Release tags must originate from `main`.
- `v0.0.1-rc.1` style tags publish GitHub pre-releases.
- Release notes are sourced from `/Users/brooklyn/data/8bitdo/cleanroom/CHANGELOG.md`.
- Package-manager publish runs only after release assets are published.
## Public RC Gate
- No open GitHub issues with label `release-blocker`.
- Scope-completeness gate:
- JP108 RC scope is dedicated mapping only (`A/B/K1-K8`).
- Ultimate2 RC scope is expanded mapping for required fields only.
- Clean-tree requirement from `/Users/brooklyn/data/8bitdo/cleanroom/RC_CHECKLIST.md` must be satisfied before tagging.
## Distribution Prep
- Homebrew formula scaffold: `/Users/brooklyn/data/8bitdo/cleanroom/packaging/homebrew/Formula/openbitdo.rb`
- Homebrew tap sync script (disabled by default): `/Users/brooklyn/data/8bitdo/cleanroom/packaging/homebrew/sync_tap.sh`
- Planned tap repository: `bybrooklyn/homebrew-openbitdo`
- AUR package sources:
- `/Users/brooklyn/data/8bitdo/cleanroom/packaging/aur/openbitdo`
- `/Users/brooklyn/data/8bitdo/cleanroom/packaging/aur/openbitdo-bin`
- Release metadata renderer:
- `/Users/brooklyn/data/8bitdo/cleanroom/packaging/scripts/render_release_metadata.sh`
- AUR publish workflow:
- `/Users/brooklyn/data/8bitdo/cleanroom/.github/workflows/aur-publish.yml`
- gated by `AUR_PUBLISH_ENABLED=1`
- Homebrew publish path:
- `release.yml` renders checksum-pinned formula and runs `sync_tap.sh`
- gated by `HOMEBREW_PUBLISH_ENABLED=1`
## Hardware CI gates
- required:
- `hardware-ultimate2`
- `hardware-108jp`
- gated:
- `hardware-jphandshake` (enabled only when `BITDO_ENABLE_JP_HARDWARE=1`)

View File

@@ -0,0 +1,24 @@
[package]
name = "bitdo_app_core"
version = "0.1.0"
edition = "2021"
license = "BSD-3-Clause"
[dependencies]
anyhow = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
toml = { workspace = true }
reqwest = { workspace = true }
base64 = { workspace = true }
ed25519-dalek = { workspace = true }
bitdo_proto = { path = "../bitdo_proto" }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "time"] }

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,7 @@
name = "bitdo_proto"
version = "0.1.0"
edition = "2021"
license = "MIT"
build = "build.rs"
license = "BSD-3-Clause"
[features]
default = ["hidapi-backend"]
@@ -16,9 +15,6 @@ 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 }
@@ -39,10 +35,18 @@ path = "../../tests/retry_timeout.rs"
name = "pid_matrix_coverage"
path = "../../tests/pid_matrix_coverage.rs"
[[test]]
name = "command_matrix_coverage"
path = "../../tests/command_matrix_coverage.rs"
[[test]]
name = "capability_gating"
path = "../../tests/capability_gating.rs"
[[test]]
name = "candidate_readonly_gating"
path = "../../tests/candidate_readonly_gating.rs"
[[test]]
name = "profile_serialization"
path = "../../tests/profile_serialization.rs"
@@ -74,3 +78,7 @@ path = "../../tests/error_codes.rs"
[[test]]
name = "diag_probe"
path = "../../tests/diag_probe.rs"
[[test]]
name = "runtime_policy"
path = "../../tests/runtime_policy.rs"

View File

@@ -1,121 +0,0 @@
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
}

View File

@@ -20,10 +20,30 @@ pub enum CommandId {
ExitBootloader,
FirmwareChunk,
FirmwareCommit,
Jp108ReadDedicatedMappings,
Jp108WriteDedicatedMapping,
Jp108ReadFeatureFlags,
Jp108WriteFeatureFlags,
Jp108ReadVoice,
Jp108WriteVoice,
U2GetCurrentSlot,
U2ReadConfigSlot,
U2WriteConfigSlot,
U2ReadButtonMap,
U2WriteButtonMap,
U2SetMode,
Jp108EnterBootloader,
Jp108FirmwareChunk,
Jp108FirmwareCommit,
Jp108ExitBootloader,
U2EnterBootloader,
U2FirmwareChunk,
U2FirmwareCommit,
U2ExitBootloader,
}
impl CommandId {
pub const ALL: [CommandId; 17] = [
pub const ALL: [CommandId; 37] = [
CommandId::GetPid,
CommandId::GetReportRevision,
CommandId::GetMode,
@@ -41,6 +61,26 @@ impl CommandId {
CommandId::ExitBootloader,
CommandId::FirmwareChunk,
CommandId::FirmwareCommit,
CommandId::Jp108ReadDedicatedMappings,
CommandId::Jp108WriteDedicatedMapping,
CommandId::Jp108ReadFeatureFlags,
CommandId::Jp108WriteFeatureFlags,
CommandId::Jp108ReadVoice,
CommandId::Jp108WriteVoice,
CommandId::U2GetCurrentSlot,
CommandId::U2ReadConfigSlot,
CommandId::U2WriteConfigSlot,
CommandId::U2ReadButtonMap,
CommandId::U2WriteButtonMap,
CommandId::U2SetMode,
CommandId::Jp108EnterBootloader,
CommandId::Jp108FirmwareChunk,
CommandId::Jp108FirmwareCommit,
CommandId::Jp108ExitBootloader,
CommandId::U2EnterBootloader,
CommandId::U2FirmwareChunk,
CommandId::U2FirmwareCommit,
CommandId::U2ExitBootloader,
];
pub fn all() -> &'static [CommandId] {

View File

@@ -0,0 +1,82 @@
// Hardcoded command declaration table.
//
// Policy model:
// - Confirmed commands can be enabled by default.
// - Inferred safe reads can run only behind experimental/advanced mode.
// - Inferred writes and unsafe paths stay blocked until confirmed.
pub const COMMAND_REGISTRY: &[crate::registry::CommandRegistryRow] = &[
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetPid, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05;byte4=0xC1", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetReportRevision, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x04;byte5=0x01", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetMode, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 5, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetModeAlt, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetControllerVersion, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 33, 1, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x22", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetSuperButton, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 5, 33, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::SetModeDInput, safety_class: crate::types::SafetyClass::SafeWrite, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 0, 81, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::Idle, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::Version, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 33, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte1=0x22", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::ReadProfile, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 6, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::WriteProfile, safety_class: crate::types::SafetyClass::SafeWrite, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 7, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::EnterBootloaderA, safety_class: crate::types::SafetyClass::UnsafeBoot, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[5, 0, 80, 1, 0, 0], expected_response: "none", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::EnterBootloaderB, safety_class: crate::types::SafetyClass::UnsafeBoot, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[0, 81, 0, 0, 0, 0], expected_response: "none", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::EnterBootloaderC, safety_class: crate::types::SafetyClass::UnsafeBoot, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[0, 80, 0, 0, 0], expected_response: "none", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::ExitBootloader, safety_class: crate::types::SafetyClass::UnsafeBoot, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[5, 0, 81, 1, 0, 0], expected_response: "none", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::FirmwareChunk, safety_class: crate::types::SafetyClass::UnsafeFirmware, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::FirmwareCommit, safety_class: crate::types::SafetyClass::UnsafeFirmware, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[], operation_group: "Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::Jp108ReadDedicatedMappings, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 5, 48, 32, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[21001, 21002], operation_group: "JP108Dedicated" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::Jp108WriteDedicatedMapping, safety_class: crate::types::SafetyClass::SafeWrite, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 5, 49, 32, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[21001, 21002], operation_group: "JP108Dedicated" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::Jp108ReadFeatureFlags, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 5, 50, 32, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[21001, 21002], operation_group: "JP108Dedicated" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::Jp108WriteFeatureFlags, safety_class: crate::types::SafetyClass::SafeWrite, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 5, 51, 32, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[21001, 21002], operation_group: "JP108Dedicated" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::Jp108ReadVoice, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 5, 52, 32, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[21001, 21002], operation_group: "JP108Dedicated" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::Jp108WriteVoice, safety_class: crate::types::SafetyClass::SafeWrite, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 5, 53, 32, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[21001, 21002], operation_group: "JP108Dedicated" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::U2GetCurrentSlot, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 5, 64, 18, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[24594, 24595], operation_group: "Ultimate2Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::U2ReadConfigSlot, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 5, 65, 18, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[24594, 24595], operation_group: "Ultimate2Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::U2WriteConfigSlot, safety_class: crate::types::SafetyClass::SafeWrite, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 5, 66, 18, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[24594, 24595], operation_group: "Ultimate2Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::U2ReadButtonMap, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 5, 67, 18, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[24594, 24595], operation_group: "Ultimate2Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::U2WriteButtonMap, safety_class: crate::types::SafetyClass::SafeWrite, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 5, 68, 18, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[24594, 24595], operation_group: "Ultimate2Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::U2SetMode, safety_class: crate::types::SafetyClass::SafeWrite, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 5, 69, 18, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[24594, 24595], operation_group: "Ultimate2Core" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::Jp108EnterBootloader, safety_class: crate::types::SafetyClass::UnsafeBoot, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[5, 0, 80, 1, 0, 0], expected_response: "none", applies_to: &[21001, 21002], operation_group: "Firmware" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::Jp108FirmwareChunk, safety_class: crate::types::SafetyClass::UnsafeFirmware, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 96, 16, 32, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[21001, 21002], operation_group: "Firmware" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::Jp108FirmwareCommit, safety_class: crate::types::SafetyClass::UnsafeFirmware, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 96, 17, 32, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[21001, 21002], operation_group: "Firmware" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::Jp108ExitBootloader, safety_class: crate::types::SafetyClass::UnsafeBoot, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[5, 0, 81, 1, 0, 0], expected_response: "none", applies_to: &[21001, 21002], operation_group: "Firmware" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::U2EnterBootloader, safety_class: crate::types::SafetyClass::UnsafeBoot, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[5, 0, 80, 1, 0, 0], expected_response: "none", applies_to: &[24594, 24595], operation_group: "Firmware" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::U2FirmwareChunk, safety_class: crate::types::SafetyClass::UnsafeFirmware, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 96, 16, 96, 18, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[24594, 24595], operation_group: "Firmware" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::U2FirmwareCommit, safety_class: crate::types::SafetyClass::UnsafeFirmware, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[129, 96, 17, 96, 18, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02", applies_to: &[24594, 24595], operation_group: "Firmware" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::U2ExitBootloader, safety_class: crate::types::SafetyClass::UnsafeBoot, confidence: crate::types::CommandConfidence::Inferred, experimental_default: true, report_id: 129, request: &[5, 0, 81, 1, 0, 0], expected_response: "none", applies_to: &[24594, 24595], operation_group: "Firmware" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetPid, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05;byte4=0xC1", applies_to: &[12544], operation_group: "CoreDiag" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetMode, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 5, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[12544], operation_group: "ModeProfileRead" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetControllerVersion, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 33, 1, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x22", applies_to: &[12544], operation_group: "FirmwarePreflight" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetPid, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05;byte4=0xC1", applies_to: &[12549], operation_group: "CoreDiag" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetMode, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 5, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[12549], operation_group: "ModeProfileRead" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetControllerVersion, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 33, 1, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x22", applies_to: &[12549], operation_group: "FirmwarePreflight" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetPid, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05;byte4=0xC1", applies_to: &[8448], operation_group: "CoreDiag" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetMode, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 5, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[8448], operation_group: "ModeProfileRead" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetControllerVersion, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 33, 1, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x22", applies_to: &[8448], operation_group: "FirmwarePreflight" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetPid, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05;byte4=0xC1", applies_to: &[8449], operation_group: "CoreDiag" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetMode, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 5, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[8449], operation_group: "ModeProfileRead" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetControllerVersion, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 33, 1, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x22", applies_to: &[8449], operation_group: "FirmwarePreflight" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetPid, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05;byte4=0xC1", applies_to: &[36890], operation_group: "CoreDiag" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetMode, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 5, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[36890], operation_group: "ModeProfileRead" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetControllerVersion, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 33, 1, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x22", applies_to: &[36890], operation_group: "FirmwarePreflight" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetPid, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05;byte4=0xC1", applies_to: &[24582], operation_group: "CoreDiag" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetMode, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 5, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[24582], operation_group: "ModeProfileRead" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetControllerVersion, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 33, 1, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x22", applies_to: &[24582], operation_group: "FirmwarePreflight" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetPid, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05;byte4=0xC1", applies_to: &[20995], operation_group: "CoreDiag" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetMode, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 5, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[20995], operation_group: "ModeProfileRead" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetControllerVersion, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 33, 1, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x22", applies_to: &[20995], operation_group: "FirmwarePreflight" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetPid, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05;byte4=0xC1", applies_to: &[20996], operation_group: "CoreDiag" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetMode, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 5, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[20996], operation_group: "ModeProfileRead" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetControllerVersion, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 33, 1, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x22", applies_to: &[20996], operation_group: "FirmwarePreflight" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetPid, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05;byte4=0xC1", applies_to: &[12314], operation_group: "CoreDiag" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetMode, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 5, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[12314], operation_group: "ModeProfileRead" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetControllerVersion, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 33, 1, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x22", applies_to: &[12314], operation_group: "FirmwarePreflight" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetPid, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05;byte4=0xC1", applies_to: &[36904], operation_group: "CoreDiag" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetMode, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 5, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[36904], operation_group: "ModeProfileRead" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetControllerVersion, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 33, 1, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x22", applies_to: &[36904], operation_group: "FirmwarePreflight" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetPid, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05;byte4=0xC1", applies_to: &[12326], operation_group: "CoreDiag" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetMode, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 5, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[12326], operation_group: "ModeProfileRead" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetControllerVersion, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 33, 1, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x22", applies_to: &[12326], operation_group: "FirmwarePreflight" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetPid, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 5, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05;byte4=0xC1", applies_to: &[12327], operation_group: "CoreDiag" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetMode, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 5, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x05", applies_to: &[12327], operation_group: "ModeProfileRead" },
crate::registry::CommandRegistryRow { id: crate::command::CommandId::GetControllerVersion, safety_class: crate::types::SafetyClass::SafeRead, confidence: crate::types::CommandConfidence::Confirmed, experimental_default: false, report_id: 129, request: &[129, 4, 33, 1, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], expected_response: "byte0=0x02;byte1=0x22", applies_to: &[12327], operation_group: "FirmwarePreflight" },
]
;

View File

@@ -1,5 +1,3 @@
#![cfg(feature = "hidapi-backend")]
use crate::error::{BitdoError, Result};
use crate::transport::Transport;
use crate::types::VidPid;

View File

@@ -21,10 +21,11 @@ pub use registry::{
};
pub use session::{
validate_response, CommandExecutionReport, DeviceSession, DiagCommandStatus, DiagProbeResult,
FirmwareTransferReport, IdentifyResult, ModeState, RetryPolicy, SessionConfig, TimeoutProfile,
DiagSeverity, FirmwareTransferReport, IdentifyResult, ModeState, RetryPolicy, SessionConfig,
TimeoutProfile,
};
pub use transport::{MockTransport, Transport};
pub use types::{
CommandConfidence, DeviceProfile, PidCapability, ProtocolFamily, SafetyClass, SupportEvidence,
SupportLevel, VidPid,
CommandConfidence, CommandRuntimePolicy, DeviceProfile, EvidenceConfidence, PidCapability,
ProtocolFamily, SafetyClass, SupportEvidence, SupportLevel, SupportTier, VidPid,
};

View File

@@ -0,0 +1,68 @@
// Hardcoded PID registry.
//
// Design note:
// - Every known PID from sanitized evidence/spec is declared here.
// - Declaration breadth is broad, but runtime execution remains conservative
// through session/runtime policy gates in `session.rs`.
// - PID values are unique here by policy; legacy aliases are documented in
// `spec/alias_index.md` instead of duplicated in runtime tables.
pub const PID_REGISTRY: &[crate::registry::PidRegistryRow] = &[
crate::registry::PidRegistryRow { name: "PID_None", pid: 0, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Unknown },
crate::registry::PidRegistryRow { name: "PID_IDLE", pid: 12553, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_SN30Plus", pid: 24578, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_USB_Ultimate", pid: 12544, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_USB_Ultimate2", pid: 12549, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_USB_UltimateClasses", pid: 12548, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_Xcloud", pid: 8448, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_Xcloud2", pid: 8449, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_ArcadeStick", pid: 36890, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_Pro2", pid: 24579, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_Pro2_CY", pid: 24582, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_Pro2_Wired", pid: 12304, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_Ultimate_PC", pid: 12305, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_Ultimate2_4", pid: 12306, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_Ultimate2_4RR", pid: 12307, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_UltimateBT", pid: 24583, support_level: crate::types::SupportLevel::Full, support_tier: crate::types::SupportTier::Full, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_UltimateBTRR", pid: 12550, support_level: crate::types::SupportLevel::Full, support_tier: crate::types::SupportTier::Full, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_JP", pid: 20992, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::JpHandshake },
crate::registry::PidRegistryRow { name: "PID_JPUSB", pid: 20993, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::JpHandshake },
crate::registry::PidRegistryRow { name: "PID_NUMPAD", pid: 20995, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_NUMPADRR", pid: 20996, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_QINGCHUN2", pid: 12554, support_level: crate::types::SupportLevel::Full, support_tier: crate::types::SupportTier::Full, protocol_family: crate::types::ProtocolFamily::DInput },
crate::registry::PidRegistryRow { name: "PID_QINGCHUN2RR", pid: 12316, support_level: crate::types::SupportLevel::Full, support_tier: crate::types::SupportTier::Full, protocol_family: crate::types::ProtocolFamily::DInput },
crate::registry::PidRegistryRow { name: "PID_Xinput", pid: 12555, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::DInput },
crate::registry::PidRegistryRow { name: "PID_Pro3", pid: 24585, support_level: crate::types::SupportLevel::Full, support_tier: crate::types::SupportTier::Full, protocol_family: crate::types::ProtocolFamily::DInput },
crate::registry::PidRegistryRow { name: "PID_Pro3USB", pid: 24586, support_level: crate::types::SupportLevel::Full, support_tier: crate::types::SupportTier::Full, protocol_family: crate::types::ProtocolFamily::DInput },
crate::registry::PidRegistryRow { name: "PID_Pro3DOCK", pid: 24589, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_108JP", pid: 21001, support_level: crate::types::SupportLevel::Full, support_tier: crate::types::SupportTier::Full, protocol_family: crate::types::ProtocolFamily::JpHandshake },
crate::registry::PidRegistryRow { name: "PID_108JPUSB", pid: 21002, support_level: crate::types::SupportLevel::Full, support_tier: crate::types::SupportTier::Full, protocol_family: crate::types::ProtocolFamily::JpHandshake },
crate::registry::PidRegistryRow { name: "PID_XBOXJP", pid: 8232, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::JpHandshake },
crate::registry::PidRegistryRow { name: "PID_XBOXJPUSB", pid: 8238, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::JpHandshake },
crate::registry::PidRegistryRow { name: "PID_NGCDIY", pid: 22352, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_NGCRR", pid: 36906, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_Ultimate2", pid: 24594, support_level: crate::types::SupportLevel::Full, support_tier: crate::types::SupportTier::Full, protocol_family: crate::types::ProtocolFamily::DInput },
crate::registry::PidRegistryRow { name: "PID_Ultimate2RR", pid: 24595, support_level: crate::types::SupportLevel::Full, support_tier: crate::types::SupportTier::Full, protocol_family: crate::types::ProtocolFamily::DInput },
crate::registry::PidRegistryRow { name: "PID_UltimateBT2", pid: 24591, support_level: crate::types::SupportLevel::Full, support_tier: crate::types::SupportTier::Full, protocol_family: crate::types::ProtocolFamily::DInput },
crate::registry::PidRegistryRow { name: "PID_UltimateBT2RR", pid: 24593, support_level: crate::types::SupportLevel::Full, support_tier: crate::types::SupportTier::Full, protocol_family: crate::types::ProtocolFamily::DInput },
crate::registry::PidRegistryRow { name: "PID_Mouse", pid: 20997, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_MouseRR", pid: 20998, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_SaturnRR", pid: 36907, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_UltimateBT2C", pid: 12314, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_Lashen", pid: 12318, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_HitBox", pid: 24587, support_level: crate::types::SupportLevel::Full, support_tier: crate::types::SupportTier::Full, protocol_family: crate::types::ProtocolFamily::DInput },
crate::registry::PidRegistryRow { name: "PID_HitBoxRR", pid: 24588, support_level: crate::types::SupportLevel::Full, support_tier: crate::types::SupportTier::Full, protocol_family: crate::types::ProtocolFamily::DInput },
crate::registry::PidRegistryRow { name: "PID_N64BT", pid: 12313, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_N64", pid: 12292, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_N64RR", pid: 36904, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_XBOXUK", pid: 12326, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_XBOXUKUSB", pid: 12327, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_LashenX", pid: 8203, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_68JP", pid: 8250, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::JpHandshake },
crate::registry::PidRegistryRow { name: "PID_68JPUSB", pid: 8265, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::CandidateReadOnly, protocol_family: crate::types::ProtocolFamily::JpHandshake },
crate::registry::PidRegistryRow { name: "PID_N64JoySticks", pid: 12321, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_DoubleSuper", pid: 8254, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_Cube2RR", pid: 8278, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_Cube2", pid: 8249, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::Standard64 },
crate::registry::PidRegistryRow { name: "PID_ASLGJP", pid: 8282, support_level: crate::types::SupportLevel::DetectOnly, support_tier: crate::types::SupportTier::DetectOnly, protocol_family: crate::types::ProtocolFamily::JpHandshake },
]
;

View File

@@ -1,14 +1,17 @@
use crate::command::CommandId;
use crate::types::{
CommandConfidence, DeviceProfile, PidCapability, ProtocolFamily, SafetyClass, SupportEvidence,
SupportLevel, VidPid,
CommandConfidence, CommandRuntimePolicy, DeviceProfile, EvidenceConfidence, PidCapability,
ProtocolFamily, SafetyClass, SupportEvidence, SupportLevel, SupportTier, VidPid,
};
use std::collections::HashSet;
use std::sync::OnceLock;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PidRegistryRow {
pub name: &'static str,
pub pid: u16,
pub support_level: SupportLevel,
pub support_tier: SupportTier,
pub protocol_family: ProtocolFamily,
}
@@ -21,12 +24,43 @@ pub struct CommandRegistryRow {
pub report_id: u8,
pub request: &'static [u8],
pub expected_response: &'static str,
pub applies_to: &'static [u16],
pub operation_group: &'static str,
}
include!(concat!(env!("OUT_DIR"), "/generated_pid_registry.rs"));
include!(concat!(env!("OUT_DIR"), "/generated_command_registry.rs"));
// Registry data is intentionally hardcoded in source files so support coverage
// is explicit in Rust code and does not depend on build-time CSV generation.
include!("pid_registry_table.rs");
include!("command_registry_table.rs");
impl CommandRegistryRow {
/// Convert evidence confidence into a stable reporting enum.
pub fn evidence_confidence(&self) -> EvidenceConfidence {
match self.confidence {
CommandConfidence::Confirmed => EvidenceConfidence::Confirmed,
CommandConfidence::Inferred => EvidenceConfidence::Inferred,
}
}
/// Runtime policy used by the session gate checker.
///
/// Policy rationale:
/// - Confirmed paths are enabled by default.
/// - Inferred safe reads can run only when experimental mode is enabled.
/// - Inferred write/unsafe paths stay blocked until explicit confirmation.
pub fn runtime_policy(&self) -> CommandRuntimePolicy {
match (self.confidence, self.safety_class) {
(CommandConfidence::Confirmed, _) => CommandRuntimePolicy::EnabledDefault,
(CommandConfidence::Inferred, SafetyClass::SafeRead) => {
CommandRuntimePolicy::ExperimentalGate
}
(CommandConfidence::Inferred, _) => CommandRuntimePolicy::BlockedUntilConfirmed,
}
}
}
pub fn pid_registry() -> &'static [PidRegistryRow] {
ensure_unique_pid_rows();
PID_REGISTRY
}
@@ -35,27 +69,133 @@ pub fn command_registry() -> &'static [CommandRegistryRow] {
}
pub fn find_pid(pid: u16) -> Option<&'static PidRegistryRow> {
PID_REGISTRY.iter().find(|row| row.pid == pid)
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 command_applies_to_pid(row: &CommandRegistryRow, pid: u16) -> bool {
row.applies_to.is_empty() || row.applies_to.contains(&pid)
}
pub fn default_capability_for(
support_level: SupportLevel,
_protocol_family: ProtocolFamily,
pid: u16,
support_tier: SupportTier,
protocol_family: ProtocolFamily,
) -> PidCapability {
match support_level {
SupportLevel::Full => PidCapability::full(),
SupportLevel::DetectOnly => PidCapability::identify_only(),
if support_tier == SupportTier::DetectOnly {
return PidCapability::identify_only();
}
const STANDARD_CANDIDATE_READ_DIAG_PIDS: &[u16] = &[
0x6002, 0x6003, 0x3010, 0x3011, 0x3012, 0x3013, 0x3004, 0x3019, 0x3100, 0x3105, 0x2100,
0x2101, 0x901a, 0x6006, 0x5203, 0x5204, 0x301a, 0x9028, 0x3026, 0x3027,
];
const JP_CANDIDATE_DIAG_PIDS: &[u16] = &[0x5200, 0x5201, 0x203a, 0x2049, 0x2028, 0x202e];
match (support_tier, pid) {
(SupportTier::CandidateReadOnly, 0x6002)
| (SupportTier::CandidateReadOnly, 0x6003)
| (SupportTier::CandidateReadOnly, 0x3010)
| (SupportTier::CandidateReadOnly, 0x3011)
| (SupportTier::CandidateReadOnly, 0x3012)
| (SupportTier::CandidateReadOnly, 0x3013)
| (SupportTier::CandidateReadOnly, 0x3004)
| (SupportTier::CandidateReadOnly, 0x3019)
| (SupportTier::CandidateReadOnly, 0x3100)
| (SupportTier::CandidateReadOnly, 0x3105)
| (SupportTier::CandidateReadOnly, 0x2100)
| (SupportTier::CandidateReadOnly, 0x2101)
| (SupportTier::CandidateReadOnly, 0x901a)
| (SupportTier::CandidateReadOnly, 0x6006)
| (SupportTier::CandidateReadOnly, 0x5203)
| (SupportTier::CandidateReadOnly, 0x5204)
| (SupportTier::CandidateReadOnly, 0x301a)
| (SupportTier::CandidateReadOnly, 0x9028)
| (SupportTier::CandidateReadOnly, 0x3026)
| (SupportTier::CandidateReadOnly, 0x3027) => PidCapability {
supports_mode: true,
supports_profile_rw: true,
supports_boot: false,
supports_firmware: false,
supports_jp108_dedicated_map: false,
supports_u2_slot_config: false,
supports_u2_button_map: false,
},
(SupportTier::CandidateReadOnly, 0x5200)
| (SupportTier::CandidateReadOnly, 0x5201)
| (SupportTier::CandidateReadOnly, 0x203a)
| (SupportTier::CandidateReadOnly, 0x2049)
| (SupportTier::CandidateReadOnly, 0x2028)
| (SupportTier::CandidateReadOnly, 0x202e) => PidCapability {
supports_mode: false,
supports_profile_rw: false,
supports_boot: false,
supports_firmware: false,
supports_jp108_dedicated_map: false,
supports_u2_slot_config: false,
supports_u2_button_map: false,
},
(SupportTier::CandidateReadOnly, _) if STANDARD_CANDIDATE_READ_DIAG_PIDS.contains(&pid) => {
PidCapability {
supports_mode: true,
supports_profile_rw: true,
supports_boot: false,
supports_firmware: false,
supports_jp108_dedicated_map: false,
supports_u2_slot_config: false,
supports_u2_button_map: false,
}
}
(SupportTier::CandidateReadOnly, _) if JP_CANDIDATE_DIAG_PIDS.contains(&pid) => {
PidCapability {
supports_mode: false,
supports_profile_rw: false,
supports_boot: false,
supports_firmware: false,
supports_jp108_dedicated_map: false,
supports_u2_slot_config: false,
supports_u2_button_map: false,
}
}
(_, 0x5209) | (_, 0x520a) => PidCapability {
supports_mode: false,
supports_profile_rw: false,
supports_boot: true,
supports_firmware: true,
supports_jp108_dedicated_map: true,
supports_u2_slot_config: false,
supports_u2_button_map: false,
},
(_, 0x6012) | (_, 0x6013) => PidCapability {
supports_mode: true,
supports_profile_rw: true,
supports_boot: true,
supports_firmware: true,
supports_jp108_dedicated_map: false,
supports_u2_slot_config: true,
supports_u2_button_map: true,
},
_ => {
let mut cap = PidCapability::full();
if protocol_family == ProtocolFamily::JpHandshake {
cap.supports_mode = false;
cap.supports_profile_rw = false;
}
cap.supports_jp108_dedicated_map = false;
cap.supports_u2_slot_config = false;
cap.supports_u2_button_map = false;
cap
}
}
}
pub fn default_evidence_for(support_level: SupportLevel) -> SupportEvidence {
match support_level {
SupportLevel::Full => SupportEvidence::Confirmed,
SupportLevel::DetectOnly => SupportEvidence::Inferred,
pub fn default_evidence_for(support_tier: SupportTier) -> SupportEvidence {
match support_tier {
SupportTier::Full => SupportEvidence::Confirmed,
SupportTier::CandidateReadOnly | SupportTier::DetectOnly => SupportEvidence::Inferred,
}
}
@@ -65,18 +205,35 @@ pub fn device_profile_for(vid_pid: VidPid) -> DeviceProfile {
vid_pid,
name: row.name.to_owned(),
support_level: row.support_level,
support_tier: row.support_tier,
protocol_family: row.protocol_family,
capability: default_capability_for(row.support_level, row.protocol_family),
evidence: default_evidence_for(row.support_level),
capability: default_capability_for(row.pid, row.support_tier, row.protocol_family),
evidence: default_evidence_for(row.support_tier),
}
} else {
DeviceProfile {
vid_pid,
name: "PID_UNKNOWN".to_owned(),
support_level: SupportLevel::DetectOnly,
support_tier: SupportTier::DetectOnly,
protocol_family: ProtocolFamily::Unknown,
capability: PidCapability::identify_only(),
evidence: SupportEvidence::Untested,
}
}
}
fn ensure_unique_pid_rows() {
static CHECK: OnceLock<()> = OnceLock::new();
CHECK.get_or_init(|| {
let mut seen = HashSet::new();
for row in PID_REGISTRY {
assert!(
seen.insert(row.pid),
"duplicate pid in runtime registry: {:#06x} ({})",
row.pid,
row.name
);
}
});
}

View File

@@ -2,11 +2,13 @@ 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::registry::{
command_applies_to_pid, device_profile_for, find_command, find_pid, CommandRegistryRow,
};
use crate::transport::Transport;
use crate::types::{
CommandConfidence, DeviceProfile, PidCapability, ProtocolFamily, SafetyClass, SupportEvidence,
SupportLevel, VidPid,
CommandRuntimePolicy, DeviceProfile, EvidenceConfidence, PidCapability, ProtocolFamily,
SafetyClass, SupportEvidence, SupportLevel, SupportTier, VidPid,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
@@ -83,15 +85,26 @@ pub struct CommandExecutionReport {
pub struct DiagCommandStatus {
pub command: CommandId,
pub ok: bool,
pub confidence: EvidenceConfidence,
pub is_experimental: bool,
pub severity: DiagSeverity,
pub error_code: Option<BitdoErrorCode>,
pub detail: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum DiagSeverity {
Ok,
Warning,
NeedsAttention,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DiagProbeResult {
pub target: VidPid,
pub profile_name: String,
pub support_level: SupportLevel,
pub support_tier: SupportTier,
pub protocol_family: ProtocolFamily,
pub capability: PidCapability,
pub evidence: SupportEvidence,
@@ -104,6 +117,7 @@ pub struct IdentifyResult {
pub target: VidPid,
pub profile_name: String,
pub support_level: SupportLevel,
pub support_tier: SupportTier,
pub protocol_family: ProtocolFamily,
pub capability: PidCapability,
pub evidence: SupportEvidence,
@@ -187,6 +201,7 @@ impl<T: Transport> DeviceSession<T> {
target: self.target,
profile_name: profile.name,
support_level: profile.support_level,
support_tier: profile.support_tier,
protocol_family: profile.protocol_family,
capability: profile.capability,
evidence: profile.evidence,
@@ -195,33 +210,66 @@ impl<T: Transport> DeviceSession<T> {
}
pub fn diag_probe(&mut self) -> DiagProbeResult {
let checks = [
let target_pid = self.target.pid;
let checks_to_run = [
CommandId::GetPid,
CommandId::GetReportRevision,
CommandId::GetMode,
CommandId::GetControllerVersion,
// Inferred safe reads are intentionally included in diagnostics so
// users always see signal quality, but results are labeled
// experimental and only strict safety conditions escalate.
CommandId::GetSuperButton,
CommandId::ReadProfile,
]
.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(),
},
.filter_map(|cmd| {
let row = find_command(*cmd)?;
if row.safety_class != SafetyClass::SafeRead {
return None;
}
if !command_applies_to_pid(row, target_pid) {
return None;
}
Some((*cmd, row.runtime_policy(), row.evidence_confidence()))
})
.collect::<Vec<_>>();
let mut checks = Vec::with_capacity(checks_to_run.len());
for (cmd, runtime_policy, confidence) in checks_to_run {
match self.send_command(cmd, None) {
Ok(_) => checks.push(DiagCommandStatus {
command: cmd,
ok: true,
confidence,
is_experimental: runtime_policy == CommandRuntimePolicy::ExperimentalGate,
severity: DiagSeverity::Ok,
error_code: None,
detail: "ok".to_owned(),
}),
Err(err) => checks.push(DiagCommandStatus {
command: cmd,
ok: false,
confidence,
is_experimental: runtime_policy == CommandRuntimePolicy::ExperimentalGate,
severity: classify_diag_failure(
cmd,
runtime_policy,
confidence,
err.code(),
self.target.pid,
),
error_code: Some(err.code()),
detail: err.to_string(),
}),
}
}
DiagProbeResult {
target: self.target,
profile_name: self.profile.name.clone(),
support_level: self.profile.support_level,
support_tier: self.profile.support_tier,
protocol_family: self.profile.protocol_family,
capability: self.profile.capability,
evidence: self.profile.evidence,
@@ -290,6 +338,116 @@ impl<T: Transport> DeviceSession<T> {
Ok(())
}
pub fn jp108_read_dedicated_mappings(&mut self) -> Result<Vec<(u8, u16)>> {
let resp = self.send_command(CommandId::Jp108ReadDedicatedMappings, None)?;
Ok(parse_indexed_u16_table(&resp.raw, 10))
}
pub fn jp108_write_dedicated_mapping(
&mut self,
index: u8,
target_hid_usage: u16,
) -> Result<()> {
let row = self.ensure_command_allowed(CommandId::Jp108WriteDedicatedMapping)?;
let mut payload = row.request.to_vec();
if payload.len() < 7 {
return Err(BitdoError::InvalidInput(
"Jp108WriteDedicatedMapping payload shorter than expected".to_owned(),
));
}
payload[4] = index;
let usage = target_hid_usage.to_le_bytes();
payload[5] = usage[0];
payload[6] = usage[1];
self.send_row(row, Some(&payload))?;
Ok(())
}
pub fn u2_get_current_slot(&mut self) -> Result<u8> {
let resp = self.send_command(CommandId::U2GetCurrentSlot, None)?;
Ok(resp.parsed_fields.get("slot").copied().unwrap_or(0) as u8)
}
pub fn u2_read_config_slot(&mut self, slot: u8) -> Result<Vec<u8>> {
let row = self.ensure_command_allowed(CommandId::U2ReadConfigSlot)?;
let mut payload = row.request.to_vec();
if payload.len() > 4 {
payload[4] = slot;
}
let resp = self.send_row(row, Some(&payload))?;
Ok(resp.raw)
}
pub fn u2_write_config_slot(&mut self, slot: u8, config_blob: &[u8]) -> Result<()> {
let row = self.ensure_command_allowed(CommandId::U2WriteConfigSlot)?;
let mut payload = row.request.to_vec();
if payload.len() < 8 {
return Err(BitdoError::InvalidInput(
"U2WriteConfigSlot payload shorter than expected".to_owned(),
));
}
payload[4] = slot;
let copy_len = config_blob.len().min(payload.len().saturating_sub(8));
if copy_len > 0 {
payload[8..8 + copy_len].copy_from_slice(&config_blob[..copy_len]);
}
self.send_row(row, Some(&payload))?;
Ok(())
}
pub fn u2_read_button_map(&mut self, slot: u8) -> Result<Vec<(u8, u16)>> {
let row = self.ensure_command_allowed(CommandId::U2ReadButtonMap)?;
let mut payload = row.request.to_vec();
if payload.len() > 4 {
payload[4] = slot;
}
let resp = self.send_row(row, Some(&payload))?;
Ok(parse_indexed_u16_table(&resp.raw, 17))
}
pub fn u2_write_button_map(&mut self, slot: u8, mappings: &[(u8, u16)]) -> Result<()> {
let row = self.ensure_command_allowed(CommandId::U2WriteButtonMap)?;
let mut payload = row.request.to_vec();
if payload.len() < 8 {
return Err(BitdoError::InvalidInput(
"U2WriteButtonMap payload shorter than expected".to_owned(),
));
}
payload[4] = slot;
for (index, usage) in mappings {
let pos = 8usize.saturating_add((*index as usize).saturating_mul(2));
if pos + 1 < payload.len() {
let bytes = usage.to_le_bytes();
payload[pos] = bytes[0];
payload[pos + 1] = bytes[1];
}
}
self.send_row(row, Some(&payload))?;
Ok(())
}
pub fn u2_set_mode(&mut self, mode: u8) -> Result<ModeState> {
let row = self.ensure_command_allowed(CommandId::U2SetMode)?;
let mut payload = row.request.to_vec();
if payload.len() < 5 {
return Err(BitdoError::InvalidInput(
"U2SetMode payload shorter than expected".to_owned(),
));
}
payload[4] = mode;
self.send_row(row, Some(&payload))?;
Ok(ModeState {
mode,
source: "U2SetMode".to_owned(),
})
}
pub fn enter_bootloader(&mut self) -> Result<()> {
self.send_command(CommandId::EnterBootloaderA, None)?;
self.send_command(CommandId::EnterBootloaderB, None)?;
@@ -529,12 +687,28 @@ impl<T: Transport> DeviceSession<T> {
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 });
// Gate 1: confidence/runtime policy.
// We intentionally keep inferred write/unsafe paths non-executable until
// they are upgraded to confirmed evidence.
match row.runtime_policy() {
CommandRuntimePolicy::EnabledDefault => {}
CommandRuntimePolicy::ExperimentalGate => {
if !self.config.experimental {
return Err(BitdoError::ExperimentalRequired { command });
}
}
CommandRuntimePolicy::BlockedUntilConfirmed => {
return Err(BitdoError::UnsupportedForPid {
command,
pid: self.target.pid,
});
}
}
// Gate 2: PID/family/capability applicability.
if !is_command_allowed_by_family(self.profile.protocol_family, command)
|| !is_command_allowed_by_capability(self.profile.capability, command)
|| !command_applies_to_pid(row, self.target.pid)
{
return Err(BitdoError::UnsupportedForPid {
command,
@@ -542,8 +716,19 @@ impl<T: Transport> DeviceSession<T> {
});
}
// Gate 3: support-tier restrictions.
if self.profile.support_tier == SupportTier::CandidateReadOnly
&& !is_command_allowed_for_candidate_pid(self.target.pid, command, row.safety_class)
{
return Err(BitdoError::UnsupportedForPid {
command,
pid: self.target.pid,
});
}
// Gate 4: explicit unsafe confirmation requirements.
if row.safety_class.is_unsafe() {
if self.profile.support_level != SupportLevel::Full {
if self.profile.support_tier != SupportTier::Full {
return Err(BitdoError::UnsupportedForPid {
command,
pid: self.target.pid,
@@ -555,7 +740,7 @@ impl<T: Transport> DeviceSession<T> {
}
if row.safety_class == SafetyClass::SafeWrite
&& self.profile.support_level == SupportLevel::DetectOnly
&& self.profile.support_tier != SupportTier::Full
{
return Err(BitdoError::UnsupportedForPid {
command,
@@ -567,6 +752,83 @@ impl<T: Transport> DeviceSession<T> {
}
}
fn classify_diag_failure(
command: CommandId,
runtime_policy: CommandRuntimePolicy,
confidence: EvidenceConfidence,
code: BitdoErrorCode,
pid: u16,
) -> DiagSeverity {
if runtime_policy != CommandRuntimePolicy::ExperimentalGate
|| confidence != EvidenceConfidence::Inferred
{
return DiagSeverity::Warning;
}
// Escalation is intentionally narrow for inferred checks:
// - identity mismatch / impossible transitions
// - command/schema applicability mismatch
// - precondition/capability mismatches implied by unsupported errors
let identity_or_transition_issue = matches!(
(command, code),
(CommandId::GetPid, BitdoErrorCode::InvalidResponse)
| (CommandId::GetPid, BitdoErrorCode::MalformedResponse)
| (CommandId::GetMode, BitdoErrorCode::InvalidResponse)
| (CommandId::GetModeAlt, BitdoErrorCode::InvalidResponse)
| (CommandId::ReadProfile, BitdoErrorCode::InvalidResponse)
| (
CommandId::GetControllerVersion,
BitdoErrorCode::InvalidResponse
)
| (CommandId::Version, BitdoErrorCode::InvalidResponse)
);
if identity_or_transition_issue {
return DiagSeverity::NeedsAttention;
}
if code == BitdoErrorCode::UnsupportedForPid
&& find_command(command)
.map(|row| command_applies_to_pid(row, pid))
.unwrap_or(false)
{
return DiagSeverity::NeedsAttention;
}
DiagSeverity::Warning
}
fn is_command_allowed_for_candidate_pid(pid: u16, command: CommandId, safety: SafetyClass) -> bool {
if safety != SafetyClass::SafeRead {
return false;
}
const BASE_DIAG_READS: &[CommandId] = &[
CommandId::GetPid,
CommandId::GetReportRevision,
CommandId::GetControllerVersion,
CommandId::Version,
CommandId::Idle,
];
const STANDARD_CANDIDATE_PIDS: &[u16] = &[
0x6002, 0x6003, 0x3010, 0x3011, 0x3012, 0x3013, 0x3004, 0x3019, 0x3100, 0x3105, 0x2100,
0x2101, 0x901a, 0x6006, 0x5203, 0x5204, 0x301a, 0x9028, 0x3026, 0x3027,
];
const JP_CANDIDATE_PIDS: &[u16] = &[0x5200, 0x5201, 0x203a, 0x2049, 0x2028, 0x202e];
if BASE_DIAG_READS.contains(&command) {
return STANDARD_CANDIDATE_PIDS.contains(&pid) || JP_CANDIDATE_PIDS.contains(&pid);
}
if STANDARD_CANDIDATE_PIDS.contains(&pid) {
return matches!(
command,
CommandId::GetMode | CommandId::GetModeAlt | CommandId::ReadProfile
);
}
false
}
fn is_command_allowed_by_capability(cap: PidCapability, command: CommandId) -> bool {
match command {
CommandId::GetPid
@@ -580,8 +842,29 @@ fn is_command_allowed_by_capability(cap: PidCapability, command: CommandId) -> b
CommandId::EnterBootloaderA
| CommandId::EnterBootloaderB
| CommandId::EnterBootloaderC
| CommandId::ExitBootloader => cap.supports_boot,
CommandId::FirmwareChunk | CommandId::FirmwareCommit => cap.supports_firmware,
| CommandId::ExitBootloader
| CommandId::Jp108EnterBootloader
| CommandId::Jp108ExitBootloader
| CommandId::U2EnterBootloader
| CommandId::U2ExitBootloader => cap.supports_boot,
CommandId::FirmwareChunk
| CommandId::FirmwareCommit
| CommandId::Jp108FirmwareChunk
| CommandId::Jp108FirmwareCommit
| CommandId::U2FirmwareChunk
| CommandId::U2FirmwareCommit => cap.supports_firmware,
CommandId::Jp108ReadDedicatedMappings
| CommandId::Jp108WriteDedicatedMapping
| CommandId::Jp108ReadFeatureFlags
| CommandId::Jp108WriteFeatureFlags
| CommandId::Jp108ReadVoice
| CommandId::Jp108WriteVoice => cap.supports_jp108_dedicated_map,
CommandId::U2GetCurrentSlot
| CommandId::U2ReadConfigSlot
| CommandId::U2WriteConfigSlot => cap.supports_u2_slot_config,
CommandId::U2ReadButtonMap | CommandId::U2WriteButtonMap | CommandId::U2SetMode => {
cap.supports_u2_button_map
}
}
}
@@ -602,6 +885,16 @@ fn is_command_allowed_by_family(family: ProtocolFamily, command: CommandId) -> b
| CommandId::WriteProfile
| CommandId::FirmwareChunk
| CommandId::FirmwareCommit
| CommandId::U2GetCurrentSlot
| CommandId::U2ReadConfigSlot
| CommandId::U2WriteConfigSlot
| CommandId::U2ReadButtonMap
| CommandId::U2WriteButtonMap
| CommandId::U2SetMode
| CommandId::U2EnterBootloader
| CommandId::U2FirmwareChunk
| CommandId::U2FirmwareCommit
| CommandId::U2ExitBootloader
),
ProtocolFamily::DS4Boot => matches!(
command,
@@ -653,6 +946,21 @@ pub fn validate_response(command: CommandId, response: &[u8]) -> ResponseStatus
ResponseStatus::Invalid
}
}
CommandId::Jp108ReadDedicatedMappings
| CommandId::Jp108ReadFeatureFlags
| CommandId::Jp108ReadVoice
| CommandId::U2ReadConfigSlot
| CommandId::U2ReadButtonMap
| CommandId::U2GetCurrentSlot => {
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;
@@ -689,6 +997,12 @@ fn minimum_response_len(command: CommandId) -> usize {
CommandId::GetPid => 24,
CommandId::GetReportRevision => 6,
CommandId::GetMode | CommandId::GetModeAlt => 6,
CommandId::U2GetCurrentSlot => 6,
CommandId::Jp108ReadDedicatedMappings
| CommandId::Jp108ReadFeatureFlags
| CommandId::Jp108ReadVoice
| CommandId::U2ReadConfigSlot
| CommandId::U2ReadButtonMap => 12,
CommandId::GetControllerVersion | CommandId::Version => 5,
_ => 2,
}
@@ -709,7 +1023,27 @@ fn parse_fields(command: CommandId, response: &[u8]) -> BTreeMap<String, u32> {
parsed.insert("version_x100".to_owned(), fw);
parsed.insert("beta".to_owned(), response[4] as u32);
}
CommandId::U2GetCurrentSlot if response.len() >= 6 => {
parsed.insert("slot".to_owned(), response[5] as u32);
}
_ => {}
}
parsed
}
fn parse_indexed_u16_table(raw: &[u8], expected_items: usize) -> Vec<(u8, u16)> {
let mut out = Vec::with_capacity(expected_items);
let offset = if raw.len() >= 8 { 8 } else { 2 };
for idx in 0..expected_items {
let pos = offset + idx * 2;
let usage = if pos + 1 < raw.len() {
u16::from_le_bytes([raw[pos], raw[pos + 1]])
} else {
0
};
out.push((idx as u8, usage));
}
out
}

View File

@@ -50,6 +50,13 @@ pub enum SupportLevel {
DetectOnly,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum SupportTier {
DetectOnly,
CandidateReadOnly,
Full,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum SafetyClass {
SafeRead,
@@ -70,6 +77,24 @@ pub enum CommandConfidence {
Inferred,
}
/// Runtime execution policy for a declared command path.
///
/// This allows us to hardcode every evidenced command in the registry while
/// still keeping unsafe or low-confidence paths blocked by default.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum CommandRuntimePolicy {
EnabledDefault,
ExperimentalGate,
BlockedUntilConfirmed,
}
/// Evidence confidence used by policy/reporting surfaces.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum EvidenceConfidence {
Confirmed,
Inferred,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum SupportEvidence {
Confirmed,
@@ -83,6 +108,9 @@ pub struct PidCapability {
pub supports_profile_rw: bool,
pub supports_boot: bool,
pub supports_firmware: bool,
pub supports_jp108_dedicated_map: bool,
pub supports_u2_slot_config: bool,
pub supports_u2_button_map: bool,
}
impl PidCapability {
@@ -92,6 +120,9 @@ impl PidCapability {
supports_profile_rw: true,
supports_boot: true,
supports_firmware: true,
supports_jp108_dedicated_map: true,
supports_u2_slot_config: true,
supports_u2_button_map: true,
}
}
@@ -101,6 +132,9 @@ impl PidCapability {
supports_profile_rw: false,
supports_boot: false,
supports_firmware: false,
supports_jp108_dedicated_map: false,
supports_u2_slot_config: false,
supports_u2_button_map: false,
}
}
}
@@ -110,6 +144,7 @@ pub struct DeviceProfile {
pub vid_pid: VidPid,
pub name: String,
pub support_level: SupportLevel,
pub support_tier: SupportTier,
pub protocol_family: ProtocolFamily,
pub capability: PidCapability,
pub evidence: SupportEvidence,

View File

@@ -0,0 +1,19 @@
[package]
name = "bitdo_tui"
version = "0.1.0"
edition = "2021"
license = "BSD-3-Clause"
[dependencies]
anyhow = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }
serde = { workspace = true }
tokio = { workspace = true }
chrono = { workspace = true }
toml = { workspace = true }
bitdo_proto = { path = "../bitdo_proto" }
bitdo_app_core = { path = "../bitdo_app_core" }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "time"] }

View File

@@ -0,0 +1,69 @@
use anyhow::{anyhow, Result};
use std::path::Path;
use std::process::{Command, Stdio};
/// Open a file or directory with the user's default desktop application.
pub(crate) fn open_path_with_default_app(path: &Path) -> Result<()> {
let mut cmd = if cfg!(target_os = "macos") {
let mut c = Command::new("open");
c.arg(path);
c
} else {
let mut c = Command::new("xdg-open");
c.arg(path);
c
};
let status = cmd.status()?;
if status.success() {
Ok(())
} else {
Err(anyhow!(
"failed to open path with default app: {}",
path.to_string_lossy()
))
}
}
/// Copy text into the system clipboard using platform-appropriate commands.
pub(crate) fn copy_text_to_clipboard(text: &str) -> Result<()> {
if cfg!(target_os = "macos") {
return copy_via_command("pbcopy", &[], text);
}
if command_exists("wl-copy") {
return copy_via_command("wl-copy", &[], text);
}
if command_exists("xclip") {
return copy_via_command("xclip", &["-selection", "clipboard"], text);
}
Err(anyhow!(
"no clipboard utility found (tried pbcopy/wl-copy/xclip)"
))
}
fn command_exists(name: &str) -> bool {
Command::new("sh")
.arg("-c")
.arg(format!("command -v {name} >/dev/null 2>&1"))
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn copy_via_command(command: &str, args: &[&str], text: &str) -> Result<()> {
let mut child = Command::new(command)
.args(args)
.stdin(Stdio::piped())
.spawn()?;
if let Some(stdin) = child.stdin.as_mut() {
use std::io::Write as _;
stdin.write_all(text.as_bytes())?;
}
let status = child.wait()?;
if status.success() {
Ok(())
} else {
Err(anyhow!("clipboard command failed: {command}"))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
use crate::ReportSaveMode;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PersistedSettings {
#[serde(default = "default_settings_schema_version")]
schema_version: u32,
#[serde(default)]
advanced_mode: bool,
#[serde(default)]
report_save_mode: ReportSaveMode,
}
const fn default_settings_schema_version() -> u32 {
1
}
/// Persist beginner/advanced preferences in a small TOML config file.
pub(crate) fn persist_user_settings(
path: &Path,
advanced_mode: bool,
report_save_mode: ReportSaveMode,
) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string_pretty(&PersistedSettings {
schema_version: default_settings_schema_version(),
advanced_mode,
report_save_mode,
})
.map_err(|err| anyhow!("failed to serialize user settings: {err}"))?;
std::fs::write(path, body)?;
Ok(())
}

View File

@@ -0,0 +1,217 @@
use crate::AppDevice;
use anyhow::{anyhow, Result};
use bitdo_app_core::FirmwareFinalReport;
use bitdo_proto::{DiagProbeResult, SupportLevel, SupportTier};
use chrono::Utc;
use serde::Serialize;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
const REPORT_MAX_COUNT: usize = 20;
const REPORT_MAX_AGE_DAYS: u64 = 30;
#[derive(Clone, Debug, Serialize)]
struct SupportReport {
schema_version: u32,
generated_at_utc: String,
operation: String,
device: Option<SupportReportDevice>,
status: String,
message: String,
diag: Option<DiagProbeResult>,
firmware: Option<FirmwareFinalReport>,
}
#[derive(Clone, Debug, Serialize)]
struct SupportReportDevice {
vid: u16,
pid: u16,
name: String,
canonical_id: String,
runtime_label: String,
serial: Option<String>,
support_level: String,
support_tier: String,
}
/// Persist a troubleshooting report as TOML.
///
/// Reports are intended for failure/support paths and are named with a timestamp plus
/// a serial-or-VID/PID token so users can share deterministic artifacts with support.
pub(crate) async fn persist_support_report(
operation: &str,
device: Option<&AppDevice>,
status: &str,
message: String,
diag: Option<&DiagProbeResult>,
firmware: Option<&FirmwareFinalReport>,
) -> Result<PathBuf> {
let now = Utc::now();
let report = SupportReport {
schema_version: 1,
generated_at_utc: now.to_rfc3339(),
operation: operation.to_owned(),
device: device.map(|d| SupportReportDevice {
vid: d.vid_pid.vid,
pid: d.vid_pid.pid,
name: d.name.clone(),
canonical_id: d.name.clone(),
runtime_label: d.support_status().as_str().to_owned(),
serial: d.serial.clone(),
support_level: match d.support_level {
SupportLevel::Full => "full".to_owned(),
SupportLevel::DetectOnly => "detect-only".to_owned(),
},
support_tier: match d.support_tier {
SupportTier::Full => "full".to_owned(),
SupportTier::CandidateReadOnly => "candidate-readonly".to_owned(),
SupportTier::DetectOnly => "detect-only".to_owned(),
},
}),
status: status.to_owned(),
message,
diag: diag.cloned(),
firmware: firmware.cloned(),
};
let report_dir = default_report_directory();
tokio::fs::create_dir_all(&report_dir).await?;
let token = report_subject_token(device);
let file_name = format!(
"{}-{}-{}.toml",
sanitize_token(operation),
now.format("%Y%m%d-%H%M%S"),
token
);
let path = report_dir.join(file_name);
let body = toml::to_string_pretty(&report)
.map_err(|err| anyhow!("failed to serialize support report: {err}"))?;
tokio::fs::write(&path, body).await?;
let _ = prune_reports_on_write().await;
Ok(path)
}
/// Startup pruning is age-based to keep stale files out of user systems.
pub(crate) async fn prune_reports_on_startup() -> Result<()> {
prune_reports_by_age().await
}
/// Write-time pruning is count-based to keep growth bounded deterministically.
async fn prune_reports_on_write() -> Result<()> {
prune_reports_by_count().await
}
pub(crate) fn report_subject_token(device: Option<&AppDevice>) -> String {
if let Some(device) = device {
if let Some(serial) = device.serial.as_deref() {
let cleaned = sanitize_token(serial);
if !cleaned.is_empty() {
return cleaned;
}
}
return format!("{:04x}{:04x}", device.vid_pid.vid, device.vid_pid.pid);
}
"unknown".to_owned()
}
fn sanitize_token(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
out.push(ch);
} else {
out.push('_');
}
}
out.trim_matches('_').to_owned()
}
fn default_report_directory() -> PathBuf {
if cfg!(target_os = "macos") {
return home_directory()
.join("Library")
.join("Application Support")
.join("OpenBitdo")
.join("reports");
}
if cfg!(target_os = "linux") {
if let Some(xdg_data_home) = std::env::var_os("XDG_DATA_HOME") {
return PathBuf::from(xdg_data_home)
.join("openbitdo")
.join("reports");
}
return home_directory()
.join(".local")
.join("share")
.join("openbitdo")
.join("reports");
}
std::env::temp_dir().join("openbitdo").join("reports")
}
async fn list_report_files() -> Result<Vec<PathBuf>> {
let report_dir = default_report_directory();
let mut out = Vec::new();
let mut entries = match tokio::fs::read_dir(&report_dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(err) => return Err(err.into()),
};
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("toml") {
out.push(path);
}
}
Ok(out)
}
async fn prune_reports_by_count() -> Result<()> {
let mut files = list_report_files().await?;
files.sort_by_key(|path| {
std::fs::metadata(path)
.and_then(|meta| meta.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
});
files.reverse();
for path in files.into_iter().skip(REPORT_MAX_COUNT) {
let _ = tokio::fs::remove_file(path).await;
}
Ok(())
}
async fn prune_reports_by_age() -> Result<()> {
let now = SystemTime::now();
let max_age = Duration::from_secs(REPORT_MAX_AGE_DAYS * 24 * 60 * 60);
for path in list_report_files().await? {
let Ok(meta) = std::fs::metadata(&path) else {
continue;
};
let Ok(modified) = meta.modified() else {
continue;
};
if now.duration_since(modified).unwrap_or_default() > max_age {
let _ = tokio::fs::remove_file(path).await;
}
}
Ok(())
}
fn home_directory() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
}

View File

@@ -0,0 +1,370 @@
use super::*;
use crate::support_report::report_subject_token;
use bitdo_app_core::{FirmwareOutcome, OpenBitdoCoreConfig};
use bitdo_proto::SupportLevel;
#[test]
fn about_state_roundtrip_returns_home() {
let mut app = TuiApp::default();
app.refresh_devices(vec![AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "Test".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: Some("SERIAL1".to_owned()),
connected: true,
}]);
app.open_about();
assert_eq!(app.state, TuiWorkflowState::About);
app.close_overlay();
assert_eq!(app.state, TuiWorkflowState::Home);
}
#[test]
fn refresh_devices_without_any_device_enters_wait_state() {
let mut app = TuiApp::default();
app.refresh_devices(Vec::new());
assert_eq!(app.state, TuiWorkflowState::WaitForDevice);
assert!(app.selected.is_none());
}
#[test]
fn refresh_devices_autoselects_single_device() {
let mut app = TuiApp::default();
app.refresh_devices(vec![AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "One".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: None,
connected: true,
}]);
assert_eq!(app.state, TuiWorkflowState::Home);
assert_eq!(app.selected_index, 0);
assert_eq!(app.selected, Some(VidPid::new(0x2dc8, 0x6009)));
}
#[test]
fn serial_token_prefers_serial_then_vidpid() {
let with_serial = AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "S".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: Some("ABC 123".to_owned()),
connected: true,
};
assert_eq!(report_subject_token(Some(&with_serial)), "ABC_123");
let without_serial = AppDevice {
serial: None,
..with_serial
};
assert_eq!(report_subject_token(Some(&without_serial)), "2dc86009");
}
#[test]
fn launch_options_default_to_failure_only_reports() {
let opts = TuiLaunchOptions::default();
assert_eq!(opts.report_save_mode, ReportSaveMode::FailureOnly);
}
#[test]
fn blocked_panel_text_matches_support_tier() {
let mut app = TuiApp::default();
app.refresh_devices(vec![AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x2100),
name: "Candidate".to_owned(),
support_level: SupportLevel::DetectOnly,
support_tier: SupportTier::CandidateReadOnly,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability {
supports_mode: true,
supports_profile_rw: true,
supports_boot: false,
supports_firmware: false,
supports_jp108_dedicated_map: false,
supports_u2_slot_config: false,
supports_u2_button_map: false,
},
evidence: bitdo_proto::SupportEvidence::Inferred,
serial: None,
connected: true,
}]);
let selected = app.selected_device().expect("selected");
let text = blocked_action_panel_text(selected);
assert!(text.contains("blocked"));
assert!(text.contains("Status shown as Blocked"));
assert_eq!(beginner_status_label(selected), "Blocked");
}
#[test]
fn non_advanced_report_mode_skips_off_setting() {
let mut app = TuiApp {
advanced_mode: false,
..Default::default()
};
assert_eq!(app.report_save_mode, ReportSaveMode::FailureOnly);
app.cycle_report_save_mode().expect("cycle");
assert_eq!(app.report_save_mode, ReportSaveMode::Always);
app.cycle_report_save_mode().expect("cycle");
assert_eq!(app.report_save_mode, ReportSaveMode::FailureOnly);
}
#[test]
fn unknown_device_label_is_beginner_friendly() {
let device = AppDevice {
vid_pid: VidPid::new(0x2dc8, 0xabcd),
name: "PID_UNKNOWN".to_owned(),
support_level: SupportLevel::DetectOnly,
support_tier: SupportTier::DetectOnly,
protocol_family: bitdo_proto::ProtocolFamily::Unknown,
capability: bitdo_proto::PidCapability::identify_only(),
evidence: bitdo_proto::SupportEvidence::Untested,
serial: None,
connected: true,
};
let label = super::display_device_name(&device);
assert!(label.contains("Unknown 8BitDo Device"));
assert!(label.contains("2dc8:abcd"));
}
#[tokio::test]
async fn home_refresh_loads_devices() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
assert!(!app.devices.is_empty());
assert!(app.selected_device().is_some());
}
#[tokio::test]
async fn run_tui_app_no_ui_blocks_detect_only_pid() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
let result = run_tui_app(
core,
TuiLaunchOptions {
no_ui: true,
selected_vid_pid: Some(VidPid::new(0x2dc8, 0x2100)),
..Default::default()
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn run_tui_app_no_ui_full_support_completes() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
run_tui_app(
core,
TuiLaunchOptions {
no_ui: true,
selected_vid_pid: Some(VidPid::new(0x2dc8, 0x6009)),
..Default::default()
},
)
.await
.expect("run app");
}
#[tokio::test]
async fn tui_flow_with_manual_path_completes() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
let path = std::env::temp_dir().join("openbitdo-tui-flow.bin");
tokio::fs::write(&path, vec![1u8; 128])
.await
.expect("write");
let report = run_tui_flow(
core,
TuiRunRequest {
vid_pid: VidPid::new(0x2dc8, 0x6009),
firmware_path: path.clone(),
allow_unsafe: true,
brick_risk_ack: true,
experimental: true,
chunk_size: Some(32),
acknowledged_risk: true,
no_ui: true,
},
)
.await
.expect("run tui flow");
assert_eq!(report.status, FirmwareOutcome::Completed);
let _ = tokio::fs::remove_file(path).await;
}
#[tokio::test]
async fn support_report_is_toml_file() {
let device = AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "Test".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: Some("RPT-1".to_owned()),
connected: true,
};
let report_path = persist_support_report(
"diag-probe",
Some(&device),
"ok",
"all checks passed".to_owned(),
None,
None,
)
.await
.expect("report path");
assert_eq!(
report_path.extension().and_then(|s| s.to_str()),
Some("toml")
);
let _ = tokio::fs::remove_file(report_path).await;
}
#[tokio::test]
async fn update_action_enters_jp108_wizard_for_jp108_device() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
let jp108_idx = app
.devices
.iter()
.position(|d| d.vid_pid.pid == 0x5209)
.expect("jp108 fixture");
app.select_index(jp108_idx);
app.state = TuiWorkflowState::Home;
let mut terminal = None;
let mut events = None;
let opts = TuiLaunchOptions::default();
execute_home_action(
&core,
&mut terminal,
&mut app,
&opts,
&mut events,
HomeAction::Update,
)
.await
.expect("execute");
assert_eq!(app.state, TuiWorkflowState::Jp108Mapping);
assert!(!app.jp108_mappings.is_empty());
}
#[tokio::test]
async fn update_action_enters_u2_wizard_for_ultimate2_device() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
let u2_idx = app
.devices
.iter()
.position(|d| d.vid_pid.pid == 0x6012)
.expect("u2 fixture");
app.select_index(u2_idx);
app.state = TuiWorkflowState::Home;
let mut terminal = None;
let mut events = None;
let opts = TuiLaunchOptions::default();
execute_home_action(
&core,
&mut terminal,
&mut app,
&opts,
&mut events,
HomeAction::Update,
)
.await
.expect("execute");
assert_eq!(app.state, TuiWorkflowState::U2CoreProfile);
assert!(app.u2_profile.is_some());
}
#[tokio::test]
async fn device_flow_backup_apply_sets_backup_id() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
let jp108_idx = app
.devices
.iter()
.position(|d| d.vid_pid.pid == 0x5209)
.expect("jp108 fixture");
app.select_index(jp108_idx);
app.begin_jp108_mapping(
core.jp108_read_dedicated_mapping(VidPid::new(0x2dc8, 0x5209))
.await
.expect("read"),
);
let mut terminal = None;
let mut events = None;
let opts = TuiLaunchOptions::default();
execute_device_flow_action(
&core,
&mut terminal,
&mut app,
&opts,
&mut events,
DeviceFlowAction::BackupApply,
)
.await
.expect("apply");
assert!(app.latest_backup.is_some());
}

View File

@@ -1,20 +0,0 @@
[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"

View File

@@ -1,518 +0,0 @@
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);
}
}

View File

@@ -0,0 +1,21 @@
[package]
name = "openbitdo"
version = "0.1.0"
edition = "2021"
license = "BSD-3-Clause"
build = "build.rs"
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
bitdo_app_core = { path = "../bitdo_app_core" }
bitdo_tui = { path = "../bitdo_tui" }
[dev-dependencies]
assert_cmd = "2.0"
predicates = "3.1"

View File

@@ -0,0 +1,42 @@
use std::env;
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=build.rs");
emit("OPENBITDO_APP_VERSION", env::var("CARGO_PKG_VERSION").ok());
emit("OPENBITDO_TARGET_TRIPLE", env::var("TARGET").ok());
emit(
"OPENBITDO_GIT_COMMIT_FULL",
run_cmd("git", &["rev-parse", "HEAD"]),
);
emit(
"OPENBITDO_GIT_COMMIT_SHORT",
run_cmd("git", &["rev-parse", "--short=12", "HEAD"]),
);
emit(
"OPENBITDO_BUILD_DATE_UTC",
run_cmd("date", &["-u", "+%Y-%m-%dT%H:%M:%SZ"]),
);
}
fn emit(key: &str, value: Option<String>) {
let normalized = value
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.unwrap_or("unknown");
println!("cargo:rustc-env={key}={normalized}");
}
fn run_cmd(program: &str, args: &[&str]) -> Option<String> {
let output = Command::new(program).args(args).output().ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout)
.ok()
.map(|v| v.trim().to_owned())
.filter(|v| !v.is_empty())
}

View File

@@ -0,0 +1,218 @@
use bitdo_app_core::{signing_key_fingerprint_active_sha256, signing_key_fingerprint_next_sha256};
use bitdo_tui::ReportSaveMode;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BuildInfo {
pub app_version: String,
pub git_commit_short: String,
pub git_commit_full: String,
pub build_date_utc: String,
pub target_triple: String,
pub runtime_platform: String,
pub signing_key_fingerprint_short: String,
pub signing_key_fingerprint_full: String,
pub signing_key_next_fingerprint_short: String,
}
impl BuildInfo {
pub fn current() -> Self {
Self::from_raw(
option_env!("OPENBITDO_APP_VERSION"),
option_env!("OPENBITDO_GIT_COMMIT_SHORT"),
option_env!("OPENBITDO_GIT_COMMIT_FULL"),
option_env!("OPENBITDO_BUILD_DATE_UTC"),
option_env!("OPENBITDO_TARGET_TRIPLE"),
)
}
pub fn to_tui_info(&self) -> bitdo_tui::BuildInfo {
bitdo_tui::BuildInfo {
app_version: self.app_version.clone(),
git_commit_short: self.git_commit_short.clone(),
git_commit_full: self.git_commit_full.clone(),
build_date_utc: self.build_date_utc.clone(),
target_triple: self.target_triple.clone(),
runtime_platform: self.runtime_platform.clone(),
signing_key_fingerprint_short: self.signing_key_fingerprint_short.clone(),
signing_key_fingerprint_full: self.signing_key_fingerprint_full.clone(),
signing_key_next_fingerprint_short: self.signing_key_next_fingerprint_short.clone(),
}
}
fn from_raw(
app_version: Option<&'static str>,
git_commit_short: Option<&'static str>,
git_commit_full: Option<&'static str>,
build_date_utc: Option<&'static str>,
target_triple: Option<&'static str>,
) -> Self {
Self {
app_version: normalize(app_version),
git_commit_short: normalize(git_commit_short),
git_commit_full: normalize(git_commit_full),
build_date_utc: normalize(build_date_utc),
target_triple: normalize(target_triple),
runtime_platform: format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH),
signing_key_fingerprint_short: short_fingerprint(
&signing_key_fingerprint_active_sha256(),
),
signing_key_fingerprint_full: signing_key_fingerprint_active_sha256(),
signing_key_next_fingerprint_short: short_fingerprint(
&signing_key_fingerprint_next_sha256(),
),
}
}
}
fn normalize(value: Option<&str>) -> String {
value
.map(str::trim)
.filter(|v| !v.is_empty())
.unwrap_or("unknown")
.to_owned()
}
fn short_fingerprint(full: &str) -> String {
if full == "unknown" {
return "unknown".to_owned();
}
full.chars().take(16).collect()
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct UserSettings {
#[serde(default = "default_settings_schema_version")]
pub schema_version: u32,
#[serde(default)]
pub advanced_mode: bool,
#[serde(default)]
pub report_save_mode: ReportSaveMode,
}
impl Default for UserSettings {
fn default() -> Self {
Self {
schema_version: default_settings_schema_version(),
advanced_mode: false,
report_save_mode: ReportSaveMode::FailureOnly,
}
}
}
const fn default_settings_schema_version() -> u32 {
1
}
pub fn user_settings_path() -> PathBuf {
if cfg!(target_os = "macos") {
return home_directory()
.join("Library")
.join("Application Support")
.join("OpenBitdo")
.join("config.toml");
}
if cfg!(target_os = "linux") {
if let Some(xdg_config_home) = std::env::var_os("XDG_CONFIG_HOME") {
return PathBuf::from(xdg_config_home)
.join("openbitdo")
.join("config.toml");
}
return home_directory()
.join(".config")
.join("openbitdo")
.join("config.toml");
}
std::env::temp_dir().join("openbitdo").join("config.toml")
}
pub fn load_user_settings(path: &Path) -> UserSettings {
let Ok(raw) = std::fs::read_to_string(path) else {
return UserSettings::default();
};
let mut settings: UserSettings = toml::from_str(&raw).unwrap_or_default();
if !settings.advanced_mode && settings.report_save_mode == ReportSaveMode::Off {
settings.report_save_mode = ReportSaveMode::FailureOnly;
}
settings
}
pub fn save_user_settings(path: &Path, settings: &UserSettings) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string_pretty(settings)?;
std::fs::write(path, body)?;
Ok(())
}
fn home_directory() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn build_info_falls_back_to_unknown_when_missing() {
let info = BuildInfo::from_raw(None, None, None, None, None);
assert_eq!(info.app_version, "unknown");
assert_eq!(info.git_commit_short, "unknown");
assert_eq!(info.git_commit_full, "unknown");
assert_eq!(info.build_date_utc, "unknown");
assert_eq!(info.target_triple, "unknown");
}
#[test]
fn runtime_platform_has_expected_separator() {
let info = BuildInfo::from_raw(None, None, None, None, None);
assert!(info.runtime_platform.contains('/'));
}
#[test]
fn normalize_trims_and_preserves_values() {
let info = BuildInfo::from_raw(
Some(" 0.1.0 "),
Some(" abc123 "),
Some(" abc123def456 "),
Some(" 2026-01-01T00:00:00Z "),
Some(" x86_64-unknown-linux-gnu "),
);
assert_eq!(info.app_version, "0.1.0");
assert_eq!(info.git_commit_short, "abc123");
assert_eq!(info.git_commit_full, "abc123def456");
assert_eq!(info.build_date_utc, "2026-01-01T00:00:00Z");
assert_eq!(info.target_triple, "x86_64-unknown-linux-gnu");
}
#[test]
fn settings_roundtrip_toml() {
let tmp =
std::env::temp_dir().join(format!("openbitdo-settings-{}.toml", std::process::id()));
let settings = UserSettings {
schema_version: 1,
advanced_mode: true,
report_save_mode: ReportSaveMode::Always,
};
save_user_settings(&tmp, &settings).expect("save settings");
let loaded = load_user_settings(&tmp);
assert_eq!(loaded, settings);
let _ = std::fs::remove_file(tmp);
}
#[test]
fn missing_settings_uses_defaults() {
let path = PathBuf::from("/tmp/openbitdo-nonexistent-settings.toml");
let loaded = load_user_settings(&path);
assert!(!loaded.advanced_mode);
assert_eq!(loaded.report_save_mode, ReportSaveMode::FailureOnly);
}
}

View File

@@ -0,0 +1,58 @@
use anyhow::Result;
use bitdo_app_core::{OpenBitdoCore, OpenBitdoCoreConfig};
use bitdo_tui::{run_tui_app, TuiLaunchOptions};
use clap::Parser;
use openbitdo::{load_user_settings, user_settings_path, BuildInfo};
#[derive(Debug, Parser)]
#[command(name = "openbitdo")]
#[command(about = "OpenBitdo beginner-first launcher")]
struct Cli {
#[arg(long, help = "Use mock transport/devices")]
mock: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let cli = Cli::parse();
let settings_path = user_settings_path();
let settings = load_user_settings(&settings_path);
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: cli.mock,
advanced_mode: settings.advanced_mode,
progress_interval_ms: 5,
..Default::default()
});
run_tui_app(
core,
TuiLaunchOptions {
build_info: BuildInfo::current().to_tui_info(),
advanced_mode: settings.advanced_mode,
report_save_mode: settings.report_save_mode,
settings_path: Some(settings_path),
..Default::default()
},
)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::error::ErrorKind;
#[test]
fn cli_supports_mock_only() {
let cli = Cli::parse_from(["openbitdo", "--mock"]);
assert!(cli.mock);
}
#[test]
fn cli_rejects_cmd_subcommand() {
let err = Cli::try_parse_from(["openbitdo", "cmd"]).expect_err("must reject cmd");
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
}
}

View File

@@ -0,0 +1,13 @@
use assert_cmd::cargo::cargo_bin_cmd;
use predicates::prelude::*;
#[test]
fn help_mentions_beginner_flow() {
let mut cmd = cargo_bin_cmd!("openbitdo");
cmd.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("beginner-first"))
.stdout(predicate::str::contains("--mock"))
.stdout(predicate::str::contains("cmd").not());
}

View File

@@ -5,9 +5,9 @@ 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)
scan_paths=(crates tests scripts ../.github)
if rg -n --hidden -g '!target/**' "$forbidden_pattern" "${scan_paths[@]}"; then
if rg -n --hidden -g '!target/**' -g '!scripts/cleanroom_guard.sh' "$forbidden_pattern" "${scan_paths[@]}"; then
echo "cleanroom guard failed: forbidden dirty-room reference detected"
exit 1
fi

68
sdk/scripts/package-linux.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO_ROOT="$(cd "$ROOT/.." && pwd)"
VERSION="${1:-v0.0.1-rc.1}"
ARCH_LABEL="${2:-$(uname -m)}"
TARGET_TRIPLE="${3:-}"
if [[ "$(uname -s)" != "Linux" ]]; then
echo "package-linux.sh must run on Linux" >&2
exit 1
fi
case "$ARCH_LABEL" in
x86_64|aarch64) ;;
arm64) ARCH_LABEL="aarch64" ;;
*)
echo "unsupported linux arch label: $ARCH_LABEL" >&2
exit 1
;;
esac
DIST_ROOT="$ROOT/dist"
PKG_NAME="openbitdo-${VERSION}-linux-${ARCH_LABEL}"
PKG_DIR="$DIST_ROOT/$PKG_NAME"
BIN_ASSET="$DIST_ROOT/${PKG_NAME}"
checksum_file() {
local path="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$path" > "${path}.sha256"
elif command -v sha256sum >/dev/null 2>&1; then
sha256sum "$path" > "${path}.sha256"
else
echo "warning: no checksum tool found for $path" >&2
fi
}
build_binary() {
cd "$ROOT"
if [[ -n "$TARGET_TRIPLE" ]]; then
cargo build --release -p openbitdo --target "$TARGET_TRIPLE"
echo "$ROOT/target/$TARGET_TRIPLE/release/openbitdo"
else
cargo build --release -p openbitdo
echo "$ROOT/target/release/openbitdo"
fi
}
BIN_PATH="$(build_binary)"
rm -rf "$PKG_DIR"
mkdir -p "$PKG_DIR/bin" "$DIST_ROOT"
cp "$BIN_PATH" "$PKG_DIR/bin/openbitdo"
cp "$BIN_PATH" "$BIN_ASSET"
cp "$REPO_ROOT/README.md" "$PKG_DIR/README.md"
cp "$ROOT/README.md" "$PKG_DIR/SDK_README.md"
cp "$REPO_ROOT/LICENSE" "$PKG_DIR/LICENSE"
tar -C "$DIST_ROOT" -czf "$DIST_ROOT/${PKG_NAME}.tar.gz" "$PKG_NAME"
checksum_file "$DIST_ROOT/${PKG_NAME}.tar.gz"
checksum_file "$BIN_ASSET"
echo "created package: $DIST_ROOT/${PKG_NAME}.tar.gz"
echo "created standalone binary: $BIN_ASSET"

77
sdk/scripts/package-macos.sh Executable file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO_ROOT="$(cd "$ROOT/.." && pwd)"
VERSION="${1:-v0.0.1-rc.1}"
ARCH_LABEL="${2:-arm64}"
TARGET_TRIPLE="${3:-aarch64-apple-darwin}"
INSTALL_PREFIX="${4:-/opt/homebrew/bin}"
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "package-macos.sh must run on macOS" >&2
exit 1
fi
if [[ "$ARCH_LABEL" != "arm64" ]]; then
echo "unsupported macOS arch label: $ARCH_LABEL (expected arm64)" >&2
exit 1
fi
DIST_ROOT="$ROOT/dist"
PKG_NAME="openbitdo-${VERSION}-macos-${ARCH_LABEL}"
PKG_DIR="$DIST_ROOT/$PKG_NAME"
BIN_ASSET="$DIST_ROOT/${PKG_NAME}"
PKG_ASSET="$DIST_ROOT/${PKG_NAME}.pkg"
PKGROOT="$DIST_ROOT/${PKG_NAME}-pkgroot"
checksum_file() {
local path="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$path" > "${path}.sha256"
elif command -v sha256sum >/dev/null 2>&1; then
sha256sum "$path" > "${path}.sha256"
else
echo "warning: no checksum tool found for $path" >&2
fi
}
build_binary() {
cd "$ROOT"
cargo build --release -p openbitdo --target "$TARGET_TRIPLE"
echo "$ROOT/target/$TARGET_TRIPLE/release/openbitdo"
}
BIN_PATH="$(build_binary)"
VERSION_STRIPPED="${VERSION#v}"
rm -rf "$PKG_DIR" "$PKGROOT" "$PKG_ASSET"
mkdir -p "$PKG_DIR/bin" "$DIST_ROOT"
cp "$BIN_PATH" "$PKG_DIR/bin/openbitdo"
cp "$BIN_PATH" "$BIN_ASSET"
cp "$REPO_ROOT/README.md" "$PKG_DIR/README.md"
cp "$ROOT/README.md" "$PKG_DIR/SDK_README.md"
cp "$REPO_ROOT/LICENSE" "$PKG_DIR/LICENSE"
tar -C "$DIST_ROOT" -czf "$DIST_ROOT/${PKG_NAME}.tar.gz" "$PKG_NAME"
mkdir -p "$PKGROOT${INSTALL_PREFIX}"
cp "$BIN_PATH" "$PKGROOT${INSTALL_PREFIX}/openbitdo"
chmod 755 "$PKGROOT${INSTALL_PREFIX}/openbitdo"
pkgbuild \
--root "$PKGROOT" \
--identifier "io.openbitdo.cli" \
--version "$VERSION_STRIPPED" \
"$PKG_ASSET"
rm -rf "$PKGROOT"
checksum_file "$DIST_ROOT/${PKG_NAME}.tar.gz"
checksum_file "$BIN_ASSET"
checksum_file "$PKG_ASSET"
echo "created package: $DIST_ROOT/${PKG_NAME}.tar.gz"
echo "created standalone binary: $BIN_ASSET"
echo "created installer pkg: $PKG_ASSET"

View File

@@ -3,37 +3,234 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
LAB_CONFIG="${ROOT}/../harness/lab/device_lab.yaml"
if [[ ! -f "$LAB_CONFIG" ]]; then
echo "missing lab config: $LAB_CONFIG" >&2
exit 1
fi
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 '[]')"
SUITE="${BITDO_REQUIRED_SUITE:-family}"
REQUIRED_FAMILIES="${BITDO_REQUIRED_FAMILIES:-Standard64,DInput}"
TEST_OUTPUT_FILE="$(mktemp)"
PARSE_OUTPUT="$(mktemp)"
set +e
BITDO_HARDWARE=1 cargo test --workspace --test hardware_smoke -- --ignored >"$TEST_OUTPUT_FILE" 2>&1
TEST_STATUS=$?
python3 - <<'PY' "$LAB_CONFIG" "$SUITE" "$REQUIRED_FAMILIES" >"$PARSE_OUTPUT"
import pathlib
import re
import sys
config_path = pathlib.Path(sys.argv[1])
suite = sys.argv[2].strip()
required_families = [item.strip() for item in sys.argv[3].split(",") if item.strip()]
lines = config_path.read_text().splitlines()
devices = []
current = None
in_devices = False
def parse_scalar(text: str):
value = text.split("#", 1)[0].strip()
if not value:
return value
if value.startswith(("0x", "0X")):
return int(value, 16)
try:
return int(value)
except ValueError:
return value
for line in lines:
stripped = line.strip()
if stripped.startswith("devices:"):
in_devices = True
continue
if not in_devices:
continue
if stripped.startswith("policies:"):
if current:
devices.append(current)
current = None
break
if re.match(r"^\s*-\s+", line):
if current:
devices.append(current)
current = {}
continue
if current is None:
continue
field_match = re.match(r"^\s*([A-Za-z0-9_]+)\s*:\s*(.+)$", line)
if not field_match:
continue
key = field_match.group(1)
value = parse_scalar(field_match.group(2))
current[key] = value
if current:
devices.append(current)
if not devices:
sys.stderr.write(f"no devices found in {config_path}\n")
sys.exit(1)
family_to_pid = {}
fixture_to_pid = {}
for device in devices:
family = device.get("protocol_family")
pid = device.get("pid")
fixture_id = device.get("fixture_id")
if isinstance(family, str) and isinstance(pid, int) and family not in family_to_pid:
family_to_pid[family] = pid
if isinstance(fixture_id, str) and isinstance(pid, int) and fixture_id not in fixture_to_pid:
fixture_to_pid[fixture_id] = pid
if suite == "family":
missing = [fam for fam in required_families if fam not in family_to_pid]
if missing:
available = ", ".join(sorted(family_to_pid.keys())) if family_to_pid else "none"
sys.stderr.write(
f"missing required family fixtures in {config_path}: {', '.join(missing)}; available: {available}\n"
)
sys.exit(1)
for fam in required_families:
print(f"FAMILY:{fam}={family_to_pid[fam]:#06x}")
elif suite == "ultimate2":
if "ultimate2" not in fixture_to_pid:
available = ", ".join(sorted(fixture_to_pid.keys())) if fixture_to_pid else "none"
sys.stderr.write(
f"missing fixture_id=ultimate2 in {config_path}; available fixture_ids: {available}\n"
)
sys.exit(1)
print(f"FIXTURE:ultimate2={fixture_to_pid['ultimate2']:#06x}")
elif suite == "108jp":
if "108jp" not in fixture_to_pid:
available = ", ".join(sorted(fixture_to_pid.keys())) if fixture_to_pid else "none"
sys.stderr.write(
f"missing fixture_id=108jp in {config_path}; available fixture_ids: {available}\n"
)
sys.exit(1)
print(f"FIXTURE:108jp={fixture_to_pid['108jp']:#06x}")
else:
sys.stderr.write(f"unsupported BITDO_REQUIRED_SUITE value: {suite}\n")
sys.exit(1)
PY
PARSE_STATUS=$?
set -e
python3 - <<'PY' "$REPORT_PATH" "$TEST_STATUS" "$TEST_OUTPUT_FILE" "$LIST_JSON"
if [[ $PARSE_STATUS -ne 0 ]]; then
rm -f "$PARSE_OUTPUT"
exit $PARSE_STATUS
fi
while IFS='=' read -r key pid_hex; do
[[ -z "$key" ]] && continue
if [[ "$key" == FAMILY:* ]]; then
family="${key#FAMILY:}"
case "$family" in
DInput) export BITDO_EXPECT_DINPUT_PID="$pid_hex" ;;
Standard64) export BITDO_EXPECT_STANDARD64_PID="$pid_hex" ;;
JpHandshake) export BITDO_EXPECT_JPHANDSHAKE_PID="$pid_hex" ;;
*)
echo "unsupported family in parsed lab config: $family" >&2
rm -f "$PARSE_OUTPUT"
exit 1
;;
esac
elif [[ "$key" == FIXTURE:* ]]; then
fixture="${key#FIXTURE:}"
case "$fixture" in
ultimate2) export BITDO_EXPECT_ULTIMATE2_PID="$pid_hex" ;;
108jp) export BITDO_EXPECT_108JP_PID="$pid_hex" ;;
*)
echo "unsupported fixture in parsed lab config: $fixture" >&2
rm -f "$PARSE_OUTPUT"
exit 1
;;
esac
fi
done <"$PARSE_OUTPUT"
rm -f "$PARSE_OUTPUT"
TEST_OUTPUT_FILE="$(mktemp)"
TEST_STATUS=0
run_test() {
local test_name="$1"
set +e
BITDO_HARDWARE=1 cargo test --workspace --test hardware_smoke -- --ignored --exact "$test_name" >>"$TEST_OUTPUT_FILE" 2>&1
local status=$?
set -e
if [[ $status -ne 0 ]]; then
TEST_STATUS=$status
fi
}
run_test "hardware_smoke_detect_devices"
case "$SUITE" in
family)
IFS=',' read -r -a FAMILY_LIST <<<"$REQUIRED_FAMILIES"
for family in "${FAMILY_LIST[@]}"; do
case "$family" in
DInput) run_test "hardware_smoke_dinput_family" ;;
Standard64) run_test "hardware_smoke_standard64_family" ;;
JpHandshake) run_test "hardware_smoke_jphandshake_family" ;;
*)
echo "unsupported required family for tests: $family" >>"$TEST_OUTPUT_FILE"
TEST_STATUS=1
;;
esac
done
;;
ultimate2)
run_test "hardware_smoke_ultimate2_core_ops"
;;
108jp)
run_test "hardware_smoke_108jp_dedicated_ops"
;;
*)
echo "unsupported suite: $SUITE" >>"$TEST_OUTPUT_FILE"
TEST_STATUS=1
;;
esac
python3 - <<'PY' "$REPORT_PATH" "$TEST_STATUS" "$TEST_OUTPUT_FILE" "$SUITE" "$REQUIRED_FAMILIES" "${BITDO_EXPECT_STANDARD64_PID:-}" "${BITDO_EXPECT_DINPUT_PID:-}" "${BITDO_EXPECT_JPHANDSHAKE_PID:-}" "${BITDO_EXPECT_ULTIMATE2_PID:-}" "${BITDO_EXPECT_108JP_PID:-}"
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 = []
suite = sys.argv[4]
required_families = [x for x in sys.argv[5].split(",") if x]
expected_standard64 = sys.argv[6]
expected_dinput = sys.argv[7]
expected_jphandshake = sys.argv[8]
expected_ultimate2 = sys.argv[9]
expected_108jp = sys.argv[10]
report = {
"timestamp_utc": datetime.datetime.utcnow().isoformat() + "Z",
"suite": suite,
"test_status": test_status,
"tests_passed": test_status == 0,
"devices": devices,
"required_families": required_families,
"required_family_fixtures": {
"Standard64": expected_standard64,
"DInput": expected_dinput,
"JpHandshake": expected_jphandshake,
},
"required_device_fixtures": {
"ultimate2": expected_ultimate2,
"108jp": expected_108jp,
},
"raw_test_output": output_file.read_text(errors="replace"),
}
@@ -43,3 +240,4 @@ PY
rm -f "$TEST_OUTPUT_FILE"
echo "hardware smoke report written: $REPORT_PATH"
exit "$TEST_STATUS"

View File

@@ -0,0 +1,26 @@
use bitdo_proto::pid_registry;
use std::fs;
use std::path::PathBuf;
#[test]
fn alias_index_matches_unique_registry_policy() {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let alias_path = manifest.join("../../../spec/alias_index.md");
let body = fs::read_to_string(alias_path).expect("read alias_index.md");
assert!(body.contains("PID_Pro2_OLD"));
assert!(body.contains("PID_Pro2"));
assert!(body.contains("0x6003"));
assert!(body.contains("PID_ASLGMouse"));
assert!(body.contains("PID_Mouse"));
assert!(body.contains("0x5205"));
let names = pid_registry()
.iter()
.map(|row| row.name)
.collect::<Vec<_>>();
assert!(names.contains(&"PID_Pro2"));
assert!(names.contains(&"PID_Mouse"));
assert!(!names.contains(&"PID_Pro2_OLD"));
assert!(!names.contains(&"PID_ASLGMouse"));
}

View File

@@ -0,0 +1,136 @@
use bitdo_proto::{
device_profile_for, BitdoError, DeviceSession, MockTransport, SessionConfig, SupportLevel,
SupportTier, VidPid,
};
const CANDIDATE_READONLY_PIDS: &[u16] = &[
0x6002, 0x6003, 0x3010, 0x3011, 0x3012, 0x3013, 0x5200, 0x5201, 0x203a, 0x2049, 0x2028, 0x202e,
0x3004, 0x3019, 0x3100, 0x3105, 0x2100, 0x2101, 0x901a, 0x6006, 0x5203, 0x5204, 0x301a, 0x9028,
0x3026, 0x3027,
];
#[test]
fn candidate_targets_are_candidate_readonly() {
for pid in CANDIDATE_READONLY_PIDS {
let profile = device_profile_for(VidPid::new(0x2dc8, *pid));
assert_eq!(
profile.support_tier,
SupportTier::CandidateReadOnly,
"expected candidate-readonly for pid={pid:#06x}"
);
assert_eq!(
profile.support_level,
SupportLevel::DetectOnly,
"support_level remains detect-only until full promotion"
);
}
}
#[test]
fn candidate_standard_pid_allows_diag_read_but_blocks_write_and_unsafe() {
let pid = 0x6002;
let mut transport = MockTransport::default();
// get_mode issues up to 3 reads; allow timeout outcome to prove it was permitted by policy.
transport.push_read_timeout();
transport.push_read_timeout();
transport.push_read_timeout();
let mut session = DeviceSession::new(
transport,
VidPid::new(0x2dc8, pid),
SessionConfig {
experimental: true,
..Default::default()
},
)
.expect("open session");
let mode_err = session
.get_mode()
.expect_err("candidate get_mode should execute and fail only at transport/response stage");
assert!(matches!(
mode_err,
BitdoError::Timeout | BitdoError::MalformedResponse { .. }
));
let write_err = session
.set_mode(1)
.expect_err("candidate safe-write must be blocked");
assert!(matches!(write_err, BitdoError::UnsupportedForPid { .. }));
let unsafe_err = session
.enter_bootloader()
.expect_err("candidate unsafe command must be blocked");
assert!(matches!(unsafe_err, BitdoError::UnsupportedForPid { .. }));
let _ = session.close();
}
#[test]
fn candidate_jp_pid_remains_diag_only() {
let pid = 0x5200;
let mut transport = MockTransport::default();
transport.push_read_data({
let mut response = vec![0u8; 64];
response[0] = 0x02;
response[1] = 0x05;
response[4] = 0xC1;
response
});
let mut session = DeviceSession::new(
transport,
VidPid::new(0x2dc8, pid),
SessionConfig {
experimental: true,
..Default::default()
},
)
.expect("open session");
let identify = session.identify().expect("identify allowed");
assert_eq!(identify.target.pid, pid);
let profile = device_profile_for(VidPid::new(0x2dc8, pid));
assert_eq!(profile.support_tier, SupportTier::CandidateReadOnly);
let mode_err = session
.get_mode()
.expect_err("jp candidate should not expose mode read path");
assert!(matches!(mode_err, BitdoError::UnsupportedForPid { .. }));
let _ = session.close();
}
#[test]
fn wave2_candidate_standard_pid_allows_safe_reads_only() {
let pid = 0x3100;
let mut transport = MockTransport::default();
transport.push_read_timeout();
transport.push_read_timeout();
transport.push_read_timeout();
let mut session = DeviceSession::new(
transport,
VidPid::new(0x2dc8, pid),
SessionConfig {
experimental: true,
..Default::default()
},
)
.expect("open session");
let mode_err = session.get_mode().expect_err(
"wave2 candidate get_mode should be permitted and fail at transport/response stage",
);
assert!(matches!(
mode_err,
BitdoError::Timeout | BitdoError::MalformedResponse { .. }
));
let write_err = session
.set_mode(1)
.expect_err("wave2 candidate safe-write must be blocked");
assert!(matches!(write_err, BitdoError::UnsupportedForPid { .. }));
let _ = session.close();
}

View File

@@ -1,66 +0,0 @@
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);
}

View File

@@ -0,0 +1,59 @@
use bitdo_proto::{command_registry, CommandRuntimePolicy};
use std::fs;
use std::path::PathBuf;
#[test]
fn command_registry_matches_spec_rows_and_runtime_policy() {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let csv_path = manifest.join("../../../spec/command_matrix.csv");
let content = fs::read_to_string(csv_path).expect("read command_matrix.csv");
let mut lines = content.lines();
let header = lines.next().expect("command matrix header");
let columns = header.split(',').collect::<Vec<_>>();
let idx_command = col_index(&columns, "command_id");
let idx_safety = col_index(&columns, "safety_class");
let idx_confidence = col_index(&columns, "confidence");
let spec_rows = content
.lines()
.skip(1)
.filter(|row| !row.trim().is_empty() && !row.starts_with("command_id,"))
.collect::<Vec<_>>();
assert_eq!(
spec_rows.len(),
command_registry().len(),
"command registry size mismatch vs command_matrix.csv"
);
for row in spec_rows {
let fields = row.split(',').collect::<Vec<_>>();
let command_name = fields[idx_command];
let safety = fields[idx_safety];
let confidence = fields[idx_confidence];
let reg = command_registry()
.iter()
.find(|entry| format!("{:?}", entry.id) == command_name)
.unwrap_or_else(|| panic!("missing command in registry: {command_name}"));
let expected_policy = match (confidence, safety) {
("confirmed", _) => CommandRuntimePolicy::EnabledDefault,
("inferred", "SafeRead") => CommandRuntimePolicy::ExperimentalGate,
("inferred", _) => CommandRuntimePolicy::BlockedUntilConfirmed,
other => panic!("unknown confidence/safety tuple: {other:?}"),
};
assert_eq!(
reg.runtime_policy(),
expected_policy,
"runtime policy mismatch for command={command_name}"
);
}
}
fn col_index(columns: &[&str], name: &str) -> usize {
columns
.iter()
.position(|c| *c == name)
.unwrap_or_else(|| panic!("missing column: {name}"))
}

View File

@@ -32,14 +32,27 @@ fn diag_probe_returns_command_checks() {
ver[4] = 1;
transport.push_read_data(ver);
let mut super_button = vec![0u8; 64];
super_button[0] = 0x02;
super_button[1] = 0x05;
transport.push_read_data(super_button);
let mut profile = vec![0u8; 64];
profile[0] = 0x02;
profile[1] = 0x05;
transport.push_read_data(profile);
let mut session = DeviceSession::new(
transport,
VidPid::new(0x2dc8, 24585),
SessionConfig::default(),
SessionConfig {
experimental: true,
..Default::default()
},
)
.expect("session init");
let diag = session.diag_probe();
assert_eq!(diag.command_checks.len(), 4);
assert_eq!(diag.command_checks.len(), 6);
assert!(diag.command_checks.iter().all(|c| c.ok));
}

View File

@@ -1,7 +1,7 @@
use bitdo_proto::{DeviceSession, MockTransport, SessionConfig, VidPid};
use bitdo_proto::{BitdoError, DeviceSession, MockTransport, SessionConfig, VidPid};
#[test]
fn firmware_transfer_chunks_and_commit() {
fn inferred_firmware_transfer_is_blocked_until_confirmed() {
let mut transport = MockTransport::default();
for _ in 0..4 {
transport.push_read_data(vec![0x02, 0x10, 0x00, 0x00]);
@@ -20,11 +20,11 @@ fn firmware_transfer_chunks_and_commit() {
.expect("session init");
let image = vec![0xAB; 120];
let report = session
let err = session
.firmware_transfer(&image, 50, false)
.expect("fw transfer");
assert_eq!(report.chunks_sent, 3);
.expect_err("inferred firmware chunk/commit must remain blocked");
assert!(matches!(err, BitdoError::UnsupportedForPid { .. }));
let transport = session.into_transport();
assert_eq!(transport.writes().len(), 4);
assert_eq!(transport.writes().len(), 0);
}

View File

@@ -1,8 +1,14 @@
use bitdo_proto::{command_registry, CommandFrame, CommandId, Report64};
use std::collections::HashSet;
#[test]
fn frame_encode_decode_roundtrip_for_all_commands() {
assert_eq!(command_registry().len(), CommandId::all().len());
let unique = command_registry()
.iter()
.map(|row| row.id)
.collect::<HashSet<_>>();
assert_eq!(unique.len(), CommandId::all().len());
assert!(command_registry().len() >= unique.len());
for row in command_registry() {
let frame = CommandFrame {

View File

@@ -1,23 +1,91 @@
use bitdo_proto::{device_profile_for, enumerate_hid_devices, ProtocolFamily, VidPid};
use bitdo_proto::{
device_profile_for, enumerate_hid_devices, DeviceSession, HidTransport, ProtocolFamily,
SessionConfig, 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()
}
fn parse_pid(input: &str) -> Option<u16> {
let trimmed = input.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()
}
}
fn expected_pid(env_key: &str, family: &str) -> u16 {
let raw = std::env::var(env_key)
.unwrap_or_else(|_| panic!("missing required {env_key} for {family} family hardware gate"));
parse_pid(&raw).unwrap_or_else(|| {
panic!("invalid {env_key} value '{raw}' for {family} family hardware gate")
})
}
fn attached_8bitdo_pids() -> Vec<u16> {
enumerate_hid_devices()
.expect("enumeration")
.into_iter()
.filter(|d| d.vid_pid.vid == 0x2dc8)
.map(|d| d.vid_pid.pid)
.collect()
}
fn assert_family_fixture(env_key: &str, family: &str, expected_family: ProtocolFamily) {
if !hardware_enabled() {
return;
}
let pid = expected_pid(env_key, family);
let attached_pids = attached_8bitdo_pids();
assert!(
attached_pids.contains(&pid),
"missing fixture for {family}: expected attached pid={pid:#06x}, attached={:?}",
attached_pids
.iter()
.map(|value| format!("{value:#06x}"))
.collect::<Vec<_>>()
);
let profile = device_profile_for(VidPid::new(0x2dc8, pid));
assert_eq!(
profile.protocol_family, expected_family,
"expected {family} family for pid={pid:#06x}, got {:?}",
profile.protocol_family
);
}
fn assert_named_fixture(env_key: &str, name: &str, expected_family: ProtocolFamily) -> u16 {
if !hardware_enabled() {
return 0;
}
let pid = expected_pid(env_key, name);
let attached_pids = attached_8bitdo_pids();
assert!(
attached_pids.contains(&pid),
"missing fixture for {name}: expected attached pid={pid:#06x}, attached={:?}",
attached_pids
.iter()
.map(|value| format!("{value:#06x}"))
.collect::<Vec<_>>()
);
let profile = device_profile_for(VidPid::new(0x2dc8, pid));
assert_eq!(
profile.protocol_family, expected_family,
"expected {name} family {:?} for pid={pid:#06x}, got {:?}",
expected_family, profile.protocol_family
);
pid
}
#[test]
#[ignore = "requires lab hardware and BITDO_HARDWARE=1"]
fn hardware_smoke_detect_devices() {
@@ -36,61 +104,140 @@ fn hardware_smoke_detect_devices() {
}
#[test]
#[ignore = "optional family check; set BITDO_EXPECT_DINPUT_PID"]
#[ignore = "requires lab hardware and 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
);
assert_family_fixture("BITDO_EXPECT_DINPUT_PID", "DInput", ProtocolFamily::DInput);
}
#[test]
#[ignore = "optional family check; set BITDO_EXPECT_STANDARD64_PID"]
#[ignore = "requires lab hardware and 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,
assert_family_fixture(
"BITDO_EXPECT_STANDARD64_PID",
"Standard64",
ProtocolFamily::Standard64,
"expected Standard64 family for pid={pid:#06x}, got {:?}",
profile.protocol_family
);
}
#[test]
#[ignore = "optional family check; set BITDO_EXPECT_JPHANDSHAKE_PID"]
#[ignore = "requires lab hardware and BITDO_EXPECT_JPHANDSHAKE_PID"]
fn hardware_smoke_jphandshake_family() {
assert_family_fixture(
"BITDO_EXPECT_JPHANDSHAKE_PID",
"JpHandshake",
ProtocolFamily::JpHandshake,
);
}
#[test]
#[ignore = "requires lab hardware and BITDO_EXPECT_ULTIMATE2_PID"]
fn hardware_smoke_ultimate2_core_ops() {
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
let pid = assert_named_fixture(
"BITDO_EXPECT_ULTIMATE2_PID",
"Ultimate2",
ProtocolFamily::DInput,
);
let profile = device_profile_for(VidPid::new(0x2dc8, pid));
assert!(profile.capability.supports_u2_slot_config);
assert!(profile.capability.supports_u2_button_map);
let mut session = DeviceSession::new(
HidTransport::new(),
VidPid::new(0x2dc8, pid),
SessionConfig {
experimental: true,
..Default::default()
},
)
.expect("open session");
let mode_before = session.get_mode().expect("read mode").mode;
session
.u2_set_mode(mode_before)
.expect("mode read/write/readback");
let mode_after = session.get_mode().expect("read mode after write").mode;
assert_eq!(mode_after, mode_before);
let slot = session.u2_get_current_slot().expect("read current slot");
let config_before = session.u2_read_config_slot(slot).expect("read config slot");
session
.u2_write_config_slot(slot, &config_before)
.expect("write config slot");
let config_after = session
.u2_read_config_slot(slot)
.expect("read config readback");
assert!(!config_after.is_empty());
let map_before = session.u2_read_button_map(slot).expect("read button map");
session
.u2_write_button_map(slot, &map_before)
.expect("write button map");
let map_after = session
.u2_read_button_map(slot)
.expect("read button map readback");
assert_eq!(map_before.len(), map_after.len());
// Firmware smoke is preflight-only in CI: dry_run avoids any transfer/write.
session
.firmware_transfer(&[0xAA; 128], 32, true)
.expect("firmware preflight dry-run");
let _ = session.close();
}
#[test]
#[ignore = "requires lab hardware and BITDO_EXPECT_108JP_PID"]
fn hardware_smoke_108jp_dedicated_ops() {
if !hardware_enabled() {
return;
}
let pid = assert_named_fixture(
"BITDO_EXPECT_108JP_PID",
"JP108",
ProtocolFamily::JpHandshake,
);
let profile = device_profile_for(VidPid::new(0x2dc8, pid));
assert!(profile.capability.supports_jp108_dedicated_map);
let mut session = DeviceSession::new(
HidTransport::new(),
VidPid::new(0x2dc8, pid),
SessionConfig {
experimental: true,
..Default::default()
},
)
.expect("open session");
let mappings_before = session
.jp108_read_dedicated_mappings()
.expect("read dedicated mappings");
assert!(mappings_before.len() >= 3);
for idx in [0u8, 1u8, 2u8] {
let usage = mappings_before
.iter()
.find(|(entry_idx, _)| *entry_idx == idx)
.map(|(_, usage)| *usage)
.unwrap_or(0);
session
.jp108_write_dedicated_mapping(idx, usage)
.expect("write dedicated mapping");
}
let mappings_after = session
.jp108_read_dedicated_mappings()
.expect("read dedicated mappings readback");
assert!(mappings_after.len() >= 3);
session
.firmware_transfer(&[0xBB; 128], 32, true)
.expect("firmware preflight dry-run");
let _ = session.close();
}

View File

@@ -1,4 +1,5 @@
use bitdo_proto::pid_registry;
use bitdo_proto::{find_pid, pid_registry, ProtocolFamily, SupportLevel, SupportTier};
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
@@ -7,10 +8,87 @@ 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();
let mut lines = content.lines();
let header = lines.next().expect("pid matrix header");
let columns = header.split(',').collect::<Vec<_>>();
let idx_name = col_index(&columns, "pid_name");
let idx_pid = col_index(&columns, "pid_hex");
let idx_level = col_index(&columns, "support_level");
let idx_tier = col_index(&columns, "support_tier");
let idx_family = col_index(&columns, "protocol_family");
let rows = lines.filter(|l| !l.trim().is_empty()).count();
assert_eq!(rows, pid_registry().len());
let mut seen = HashSet::new();
for row in content.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let fields = row.split(',').collect::<Vec<_>>();
let name = fields[idx_name];
let pid_hex = fields[idx_pid];
let level = fields[idx_level];
let tier = fields[idx_tier];
let family = fields[idx_family];
let pid = parse_hex_u16(pid_hex);
assert!(
seen.insert(pid),
"duplicate PID found in pid_matrix.csv: {pid_hex} (name={name})"
);
let reg = find_pid(pid).unwrap_or_else(|| panic!("missing pid in registry: {pid_hex}"));
assert_eq!(reg.name, name, "name mismatch for pid={pid_hex}");
assert_eq!(
reg.support_level,
parse_support_level(level),
"support_level mismatch for pid={pid_hex}"
);
assert_eq!(
reg.support_tier,
parse_support_tier(tier),
"support_tier mismatch for pid={pid_hex}"
);
assert_eq!(
reg.protocol_family,
parse_family(family),
"protocol_family mismatch for pid={pid_hex}"
);
}
}
fn col_index(columns: &[&str], name: &str) -> usize {
columns
.iter()
.position(|c| *c == name)
.unwrap_or_else(|| panic!("missing column: {name}"))
}
fn parse_hex_u16(v: &str) -> u16 {
u16::from_str_radix(v.trim_start_matches("0x"), 16).expect("valid pid hex")
}
fn parse_support_level(v: &str) -> SupportLevel {
match v {
"full" => SupportLevel::Full,
"detect-only" => SupportLevel::DetectOnly,
other => panic!("unknown support_level: {other}"),
}
}
fn parse_support_tier(v: &str) -> SupportTier {
match v {
"full" => SupportTier::Full,
"candidate-readonly" => SupportTier::CandidateReadOnly,
"detect-only" => SupportTier::DetectOnly,
other => panic!("unknown support_tier: {other}"),
}
}
fn parse_family(v: &str) -> ProtocolFamily {
match v {
"Standard64" => ProtocolFamily::Standard64,
"JpHandshake" => ProtocolFamily::JpHandshake,
"DInput" => ProtocolFamily::DInput,
"DS4Boot" => ProtocolFamily::DS4Boot,
"Unknown" => ProtocolFamily::Unknown,
other => panic!("unknown protocol_family: {other}"),
}
}

View File

@@ -0,0 +1,15 @@
use bitdo_proto::pid_registry;
use std::collections::HashSet;
#[test]
fn pid_registry_contains_unique_pid_values() {
let mut seen = HashSet::new();
for row in pid_registry() {
assert!(
seen.insert(row.pid),
"duplicate pid in runtime registry: {:#06x} ({})",
row.pid,
row.name
);
}
}

View File

@@ -0,0 +1,78 @@
use bitdo_proto::{
find_command, BitdoError, CommandId, CommandRuntimePolicy, DeviceSession, DiagSeverity,
EvidenceConfidence, MockTransport, SessionConfig, VidPid,
};
#[test]
fn inferred_safe_read_requires_experimental_mode() {
let row = find_command(CommandId::GetSuperButton).expect("command present");
assert_eq!(row.runtime_policy(), CommandRuntimePolicy::ExperimentalGate);
let mut session = DeviceSession::new(
MockTransport::default(),
VidPid::new(0x2dc8, 0x6012),
SessionConfig::default(),
)
.expect("session opens");
let err = session
.send_command(CommandId::GetSuperButton, None)
.expect_err("experimental gate must deny inferred safe-read by default");
assert!(matches!(err, BitdoError::ExperimentalRequired { .. }));
}
#[test]
fn inferred_write_is_blocked_until_confirmed() {
let row = find_command(CommandId::WriteProfile).expect("command present");
assert_eq!(
row.runtime_policy(),
CommandRuntimePolicy::BlockedUntilConfirmed
);
let mut session = DeviceSession::new(
MockTransport::default(),
VidPid::new(0x2dc8, 0x6012),
SessionConfig {
experimental: true,
..Default::default()
},
)
.expect("session opens");
let err = session
.send_command(CommandId::WriteProfile, Some(&[1, 2, 3]))
.expect_err("inferred writes remain blocked even in experimental mode");
assert!(matches!(err, BitdoError::UnsupportedForPid { .. }));
}
#[test]
fn confirmed_read_remains_enabled_default() {
let row = find_command(CommandId::GetPid).expect("command present");
assert_eq!(row.runtime_policy(), CommandRuntimePolicy::EnabledDefault);
}
#[test]
fn diag_probe_marks_inferred_reads_as_experimental() {
let mut session = DeviceSession::new(
MockTransport::default(),
VidPid::new(0x2dc8, 0x6012),
SessionConfig {
experimental: true,
..Default::default()
},
)
.expect("session opens");
let diag = session.diag_probe();
let inferred = diag
.command_checks
.iter()
.find(|c| c.command == CommandId::GetSuperButton)
.expect("inferred check present");
assert!(inferred.is_experimental);
assert_eq!(inferred.confidence, EvidenceConfidence::Inferred);
assert!(matches!(
inferred.severity,
DiagSeverity::Ok | DiagSeverity::Warning | DiagSeverity::NeedsAttention
));
}