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

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
}