From 2b473df1b213df04b9d6b177d2eaaef10983e41d Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Fri, 31 Jul 2026 22:02:17 +0000 Subject: [PATCH 1/6] Update crossbeam-epoch Upgrade crossbeam-epoch from 0.9.18 to 0.9.20 to address RUSTSEC-2026-0204. The dependency is pulled through the existing sysinfo/rayon graph used by ec-test-tui. Assisted-by: GitHub Copilot:gpt-5.6-sol Copilot-Session: 6cb7060d-6d47-49ad-a710-428fe4c2f3dc --- ec/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ec/Cargo.lock b/ec/Cargo.lock index 78c61d9..e2fb021 100644 --- a/ec/Cargo.lock +++ b/ec/Cargo.lock @@ -356,9 +356,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] From 289a1c6829096d72d47b7bc22fc5145ec644bc10 Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Fri, 31 Jul 2026 22:10:09 +0000 Subject: [PATCH 2/6] Update crossbeam vet exemption Move the existing safe-to-deploy exemption from crossbeam-epoch 0.9.18 to the patched 0.9.20 release so cargo-vet accepts the lockfile security update. Assisted-by: GitHub Copilot:gpt-5.6-sol Copilot-Session: 6cb7060d-6d47-49ad-a710-428fe4c2f3dc --- common/supply-chain/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/supply-chain/config.toml b/common/supply-chain/config.toml index 541abbf..0c8d28e 100644 --- a/common/supply-chain/config.toml +++ b/common/supply-chain/config.toml @@ -115,7 +115,7 @@ version = "0.8.6" criteria = "safe-to-deploy" [[exemptions.crossbeam-epoch]] -version = "0.9.18" +version = "0.9.20" criteria = "safe-to-deploy" [[exemptions.crossbeam-utils]] From 56332ccb82671ce04546862aac9e65e2a1c61481 Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Fri, 31 Jul 2026 18:11:50 +0000 Subject: [PATCH 3/6] Add host UCSI source and USB-C TUI tab Add a read-only host UCSI slice so ec-test-lib and the ec-test-tui Ratatui app can query the secure-world UCSI stub over ACPI. ec-test-lib: - New platform-neutral `ucsi` module: small value types (version, capability, connector capability/status, CCI) plus 48-byte mailbox decoding that validates VERSION, CCI and the response data length. Kept off the Windows-only path so decode is unit-tested on Linux. - `UcsiSource` trait (get_version, get_capability, get_connector_capability, get_connector_status) added to the `Source` supertrait with an `Arc` forwarding impl. - Mock returns deterministic single-connector PD-sink fixtures. - Serial gains an `Unsupported` error (mapped to `ErrorKind::Other`) as it has no EC-side UCSI peer; it never fakes success. - Windows Acpi backend evaluates `\_SB.ECT0.USND` with an 8-byte CONTROL buffer and decodes the returned 48-byte mailbox. ec-test-tui: - Extend `DynSource` and the manual battery/thermal test doubles. - Add `UcsiState`/`UcsiUpdater` and a compact, stateless USB-C tab plus dashboard card modeled on the RTC module (version, connector 1 capability, connected sink status). Errors render honestly. Assisted-by: GitHub Copilot:claude-opus-4.8 Copilot-Session: 6cb7060d-6d47-49ad-a710-428fe4c2f3dc --- ec/test-lib/src/acpi.rs | 51 +++- ec/test-lib/src/lib.rs | 37 ++- ec/test-lib/src/mock.rs | 88 ++++++- ec/test-lib/src/serial.rs | 37 ++- ec/test-lib/src/ucsi.rs | 478 +++++++++++++++++++++++++++++++++++++ ec/test-tui/src/app.rs | 45 +++- ec/test-tui/src/battery.rs | 30 +++ ec/test-tui/src/main.rs | 8 + ec/test-tui/src/source.rs | 20 ++ ec/test-tui/src/state.rs | 18 ++ ec/test-tui/src/thermal.rs | 30 +++ ec/test-tui/src/ucsi.rs | 271 +++++++++++++++++++++ ec/test-tui/src/updater.rs | 69 ++++++ 13 files changed, 1172 insertions(+), 10 deletions(-) create mode 100644 ec/test-lib/src/ucsi.rs create mode 100644 ec/test-tui/src/ucsi.rs diff --git a/ec/test-lib/src/acpi.rs b/ec/test-lib/src/acpi.rs index 3b52bdf..ffc384a 100644 --- a/ec/test-lib/src/acpi.rs +++ b/ec/test-lib/src/acpi.rs @@ -1,4 +1,5 @@ -use crate::{BatterySource, ErrorType, RtcSource, ThermalSource, Threshold, common}; +use crate::ucsi::{self, Mailbox, UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus, UcsiVersion}; +use crate::{BatterySource, ErrorType, RtcSource, ThermalSource, Threshold, UcsiSource, common}; use battery_service_interface::{ BatteryState, BatterySwapCapability, BatteryTechnology, BixFixedStrings, BstReturn, PowerUnit, }; @@ -148,6 +149,8 @@ pub enum Error { OperationFailed, /// Data validation failed (invalid enum discriminant, malformed field, etc.) InvalidData, + /// Decoding a UCSI mailbox response failed + Ucsi(ucsi::MailboxError), } impl std::fmt::Display for Error { @@ -158,6 +161,7 @@ impl std::fmt::Display for Error { Self::UnexpectedArgumentType(t) => write!(f, "Unexpected argument type: {t}"), Self::OperationFailed => write!(f, "Operation failed"), Self::InvalidData => write!(f, "Invalid data"), + Self::Ucsi(e) => write!(f, "UCSI mailbox error: {e}"), } } } @@ -172,10 +176,17 @@ impl crate::Error for Error { Self::UnexpectedArgumentType(_) => crate::ErrorKind::UnexpectedResponse, Self::OperationFailed => crate::ErrorKind::Other, Self::InvalidData => crate::ErrorKind::InvalidData, + Self::Ucsi(_) => crate::ErrorKind::InvalidData, } } } +impl From for Error { + fn from(e: ucsi::MailboxError) -> Self { + Self::Ucsi(e) + } +} + impl From for Error { fn from(e: AcpiParseError) -> Self { Self::Parse(e) @@ -694,3 +705,41 @@ impl RtcSource for Acpi { Ok(()) } } + +impl Acpi { + /// Issue one UCSI command by writing the 8-byte CONTROL buffer to + /// `\_SB.ECT0.USND` and decoding the returned 48-byte mailbox. + fn ucsi_command(&self, control: [u8; ucsi::CONTROL_LEN]) -> Result { + let output = self.evaluate("\\_SB.ECT0.USND", Some(&[AcpiMethodArgument::Buffer(control.to_vec())]))?; + if output.count != 1 { + return Err(Error::UnexpectedResponse); + } + let arg = output.arg(0)?; + if arg.type_ != AcpiArgumentType::Buffer as u16 { + return Err(Error::UnexpectedArgumentType(arg.type_)); + } + Ok(Mailbox::decode(&arg.data)?) + } +} + +impl UcsiSource for Acpi { + fn get_version(&self) -> Result { + let mailbox = self.ucsi_command(ucsi::control(ucsi::opcode::GET_CAPABILITY, 0))?; + Ok(mailbox.version()) + } + + fn get_capability(&self) -> Result { + let mailbox = self.ucsi_command(ucsi::control(ucsi::opcode::GET_CAPABILITY, 0))?; + Ok(mailbox.capability()?) + } + + fn get_connector_capability(&self, connector: u8) -> Result { + let mailbox = self.ucsi_command(ucsi::control(ucsi::opcode::GET_CONNECTOR_CAPABILITY, connector))?; + Ok(mailbox.connector_capability()?) + } + + fn get_connector_status(&self, connector: u8) -> Result { + let mailbox = self.ucsi_command(ucsi::control(ucsi::opcode::GET_CONNECTOR_STATUS, connector))?; + Ok(mailbox.connector_status()?) + } +} diff --git a/ec/test-lib/src/lib.rs b/ec/test-lib/src/lib.rs index 381ff2b..1fe6ef4 100644 --- a/ec/test-lib/src/lib.rs +++ b/ec/test-lib/src/lib.rs @@ -5,6 +5,8 @@ use time_alarm_service_interface::{ AcpiTimerId, AcpiTimestamp, AlarmExpiredWakePolicy, AlarmTimerSeconds, TimeAlarmDeviceCapabilities, TimerStatus, }; +use crate::ucsi::{UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus, UcsiVersion}; + pub(crate) mod common; #[cfg(target_os = "windows")] @@ -12,6 +14,7 @@ pub mod acpi; pub mod mock; pub mod serial; +pub mod ucsi; /// EC data source error. /// @@ -153,9 +156,24 @@ pub trait RtcSource: ErrorType { fn clear_wake_status(&self, timer_id: AcpiTimerId) -> Result<(), Self::Error>; } +/// Trait for host-side UCSI data sources (read-only PPM/connector queries). +pub trait UcsiSource: ErrorType { + /// Get the UCSI interface version - see UCSI mailbox VERSION. + fn get_version(&self) -> Result; + + /// Get PPM capabilities - see GET_CAPABILITY. + fn get_capability(&self) -> Result; + + /// Get per-connector capabilities - see GET_CONNECTOR_CAPABILITY. + fn get_connector_capability(&self, connector: u8) -> Result; + + /// Get connector status - see GET_CONNECTOR_STATUS. + fn get_connector_status(&self, connector: u8) -> Result; +} + /// Marker trait implemented by all EC data sources. -pub trait Source: ThermalSource + BatterySource + RtcSource {} -impl Source for T {} +pub trait Source: ThermalSource + BatterySource + RtcSource + UcsiSource {} +impl Source for T {} // Blanket impls so that Arc can be used anywhere a source trait is required. // This lets modules share one source instance via Arc instead of each owning a clone. @@ -235,6 +253,21 @@ impl RtcSource for Arc { } } +impl UcsiSource for Arc { + fn get_version(&self) -> Result { + self.as_ref().get_version() + } + fn get_capability(&self) -> Result { + self.as_ref().get_capability() + } + fn get_connector_capability(&self, connector: u8) -> Result { + self.as_ref().get_connector_capability(connector) + } + fn get_connector_status(&self, connector: u8) -> Result { + self.as_ref().get_connector_status(connector) + } +} + /// Fan threshold type pub enum Threshold { /// On threshold temperature diff --git a/ec/test-lib/src/mock.rs b/ec/test-lib/src/mock.rs index b540970..7312ff9 100644 --- a/ec/test-lib/src/mock.rs +++ b/ec/test-lib/src/mock.rs @@ -1,4 +1,7 @@ -use crate::{BatterySource, ErrorType, RtcSource, ThermalSource, Threshold}; +use crate::ucsi::{ + OperationMode, PowerDirection, UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus, UcsiVersion, +}; +use crate::{BatterySource, ErrorType, RtcSource, ThermalSource, Threshold, UcsiSource}; use battery_service_interface::{ BatteryState, BatterySwapCapability, BatteryTechnology, BixFixedStrings, BstReturn, PowerUnit, }; @@ -444,3 +447,86 @@ impl RtcSource for Mock { Ok(()) } } + +impl UcsiSource for Mock { + fn get_version(&self) -> Result { + Ok(UcsiVersion(0x0120)) + } + fn get_capability(&self) -> Result { + Ok(UcsiCapability { + num_connectors: 1, + usb_pd_supported: true, + bcd_pd_version: 0x0300, + bcd_usb_type_c_version: 0x0200, + }) + } + fn get_connector_capability(&self, _connector: u8) -> Result { + Ok(UcsiConnectorCapability { + operation_mode: OperationMode { + drp: true, + usb2: true, + usb3: true, + }, + provider: true, + consumer: true, + }) + } + fn get_connector_status(&self, _connector: u8) -> Result { + Ok(UcsiConnectorStatus { + connected: true, + power_direction: PowerDirection::Sink, + partner_usb: true, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ucsi_version_is_1_2() { + assert_eq!(Mock::new().get_version().unwrap(), UcsiVersion(0x0120)); + } + + #[test] + fn ucsi_capability_reports_single_pd_connector() { + assert_eq!( + Mock::new().get_capability().unwrap(), + UcsiCapability { + num_connectors: 1, + usb_pd_supported: true, + bcd_pd_version: 0x0300, + bcd_usb_type_c_version: 0x0200, + } + ); + } + + #[test] + fn ucsi_connector_capability_is_drp_provider_consumer() { + assert_eq!( + Mock::new().get_connector_capability(1).unwrap(), + UcsiConnectorCapability { + operation_mode: OperationMode { + drp: true, + usb2: true, + usb3: true, + }, + provider: true, + consumer: true, + } + ); + } + + #[test] + fn ucsi_connector_status_is_connected_sink() { + assert_eq!( + Mock::new().get_connector_status(1).unwrap(), + UcsiConnectorStatus { + connected: true, + power_direction: PowerDirection::Sink, + partner_usb: true, + } + ); + } +} diff --git a/ec/test-lib/src/serial.rs b/ec/test-lib/src/serial.rs index 4bd3db7..ada9cc6 100644 --- a/ec/test-lib/src/serial.rs +++ b/ec/test-lib/src/serial.rs @@ -1,4 +1,4 @@ -use crate::{BatterySource, ErrorType, RtcSource, ThermalSource, Threshold, common}; +use crate::{BatterySource, ErrorType, RtcSource, ThermalSource, Threshold, UcsiSource, common}; use battery_service_interface::{BixFixedStrings, BstReturn, Btp}; use battery_service_relay::{AcpiBatteryRequest, AcpiBatteryResponse}; use embedded_services::relay::SerializableMessage; @@ -17,6 +17,8 @@ use time_alarm_service_interface::{ }; use time_alarm_service_relay::{AcpiTimeAlarmRequest, AcpiTimeAlarmResponse}; +use crate::ucsi::{UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus, UcsiVersion}; + /// Errors produced by serial data source operations. #[derive(Debug)] pub enum Error { @@ -28,6 +30,8 @@ pub enum Error { Serialization(String), /// Response had an unexpected format UnexpectedResponse, + /// Operation is not supported by the serial backend (no EC-side peer) + Unsupported(&'static str), } impl std::fmt::Display for Error { @@ -37,6 +41,7 @@ impl std::fmt::Display for Error { Self::Protocol(msg) => write!(f, "serial protocol error: {msg}"), Self::Serialization(msg) => write!(f, "serialization error: {msg}"), Self::UnexpectedResponse => write!(f, "unexpected response"), + Self::Unsupported(what) => write!(f, "unsupported over serial: {what}"), } } } @@ -50,6 +55,7 @@ impl crate::Error for Error { Self::Protocol(_) => crate::ErrorKind::Protocol, Self::Serialization(_) => crate::ErrorKind::Serialization, Self::UnexpectedResponse => crate::ErrorKind::UnexpectedResponse, + Self::Unsupported(_) => crate::ErrorKind::Other, } } } @@ -545,3 +551,32 @@ impl RtcSource for Serial { } } } + +/// The serial backend has no EC-side UCSI relay peer, so every UCSI read is +/// explicitly unsupported (mapped to [`crate::ErrorKind::Other`]) rather than +/// faking success. +impl UcsiSource for Serial { + fn get_version(&self) -> Result { + Err(Error::Unsupported("UCSI get_version")) + } + fn get_capability(&self) -> Result { + Err(Error::Unsupported("UCSI get_capability")) + } + fn get_connector_capability(&self, _connector: u8) -> Result { + Err(Error::Unsupported("UCSI get_connector_capability")) + } + fn get_connector_status(&self, _connector: u8) -> Result { + Err(Error::Unsupported("UCSI get_connector_status")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Error as _; + + #[test] + fn unsupported_maps_to_other_kind() { + assert_eq!(Error::Unsupported("UCSI get_version").kind(), crate::ErrorKind::Other); + } +} diff --git a/ec/test-lib/src/ucsi.rs b/ec/test-lib/src/ucsi.rs new file mode 100644 index 0000000..3adc8c2 --- /dev/null +++ b/ec/test-lib/src/ucsi.rs @@ -0,0 +1,478 @@ +//! Host-side UCSI value types and platform-neutral mailbox decoding. +//! +//! The UCSI shared mailbox is 48 bytes; the PPM fills VERSION, CCI and the +//! MESSAGE IN payload. Decoding lives here — not in the Windows-only [`crate::acpi`] +//! backend — so it can be unit-tested on any host. + +use std::fmt; + +/// Total UCSI mailbox size in bytes. +pub const MAILBOX_LEN: usize = 48; +/// UCSI 1.2 version word reported in the mailbox VERSION field. +pub const UCSI_VERSION_1_2: u16 = 0x0120; +/// Length of the UCSI CONTROL field the OS writes to issue a command. +pub const CONTROL_LEN: usize = 8; + +const VERSION_OFFSET: usize = 0; +const CCI_OFFSET: usize = 4; +const MESSAGE_IN_OFFSET: usize = 16; +const MESSAGE_IN_LEN: usize = 16; + +/// UCSI command opcodes for the host read surface. +pub mod opcode { + /// GET_CAPABILITY (PPM capabilities, 16-byte response). + pub const GET_CAPABILITY: u8 = 0x06; + /// GET_CONNECTOR_CAPABILITY (per-connector, 2-byte response). + pub const GET_CONNECTOR_CAPABILITY: u8 = 0x07; + /// GET_CONNECTOR_STATUS (per-connector, 11-byte response). + pub const GET_CONNECTOR_STATUS: u8 = 0x12; +} + +/// Build an 8-byte CONTROL buffer: byte 0 = opcode, byte 2 = connector number. +/// +/// Matches the UCSI command header (opcode, data-length=0) followed by the +/// LPM connector number in the command-specific field. +pub fn control(opcode: u8, connector: u8) -> [u8; CONTROL_LEN] { + let mut buf = [0u8; CONTROL_LEN]; + buf[0] = opcode; + buf[2] = connector; + buf +} + +/// UCSI interface version (BCD; `0x0120` == UCSI 1.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UcsiVersion(pub u16); + +impl UcsiVersion { + /// Major version digit. + pub fn major(self) -> u8 { + (self.0 >> 8) as u8 + } + /// Minor version digit. + pub fn minor(self) -> u8 { + ((self.0 >> 4) & 0xf) as u8 + } +} + +impl fmt::Display for UcsiVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}", self.major(), self.minor()) + } +} + +/// Command Status and Connector Change Indicator (UCSI spec 4.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cci(pub u32); + +impl Cci { + /// Length of the returned MESSAGE IN data (bits 15..8). + pub fn data_len(self) -> u8 { + (self.0 >> 8) as u8 + } + /// Command was not supported (bit 25). + pub fn not_supported(self) -> bool { + self.0 & (1 << 25) != 0 + } + /// Busy (bit 28). + pub fn busy(self) -> bool { + self.0 & (1 << 28) != 0 + } + /// Command error (bit 30). + pub fn error(self) -> bool { + self.0 & (1 << 30) != 0 + } + /// Command complete (bit 31). + pub fn cmd_complete(self) -> bool { + self.0 & (1 << 31) != 0 + } +} + +/// PPM capabilities (GET_CAPABILITY response, subset used by the host UI). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UcsiCapability { + /// Number of connectors managed by the PPM. + pub num_connectors: u8, + /// PPM supports the USB Power Delivery specification. + pub usb_pd_supported: bool, + /// BCD-coded USB PD spec version. + pub bcd_pd_version: u16, + /// BCD-coded USB Type-C spec version. + pub bcd_usb_type_c_version: u16, +} + +/// Connector operation-mode flags (subset used by the host UI). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct OperationMode { + /// Dual-role port. + pub drp: bool, + /// USB 2.0 capable. + pub usb2: bool, + /// USB 3.x capable. + pub usb3: bool, +} + +/// Per-connector capabilities (GET_CONNECTOR_CAPABILITY response). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UcsiConnectorCapability { + /// Supported operation modes. + pub operation_mode: OperationMode, + /// Connector can act as a power provider (source). + pub provider: bool, + /// Connector can act as a power consumer (sink). + pub consumer: bool, +} + +/// Power direction of a connected connector. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PowerDirection { + /// Consuming power (sink). + Sink, + /// Providing power (source). + Source, +} + +impl fmt::Display for PowerDirection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Sink => write!(f, "Sink"), + Self::Source => write!(f, "Source"), + } + } +} + +/// Connector status (GET_CONNECTOR_STATUS response, subset used by the host UI). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UcsiConnectorStatus { + /// A partner is attached. + pub connected: bool, + /// Current power direction. + pub power_direction: PowerDirection, + /// Partner is a USB device. + pub partner_usb: bool, +} + +/// Error decoding a UCSI mailbox response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MailboxError { + /// The mailbox buffer was not exactly [`MAILBOX_LEN`] bytes. + WrongLength { + /// Expected byte count. + expected: usize, + /// Actual byte count. + actual: usize, + }, + /// The VERSION field did not match a supported UCSI version. + UnsupportedVersion(u16), + /// CCI did not report command-complete. + NotComplete, + /// CCI reported a command error. + CommandError, + /// CCI reported the command was not supported. + NotSupported, + /// CCI data length did not match the expected response size. + UnexpectedDataLen { + /// Expected data length. + expected: usize, + /// Actual data length reported in CCI. + actual: usize, + }, +} + +impl fmt::Display for MailboxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongLength { expected, actual } => { + write!(f, "mailbox length {actual} bytes, expected {expected}") + } + Self::UnsupportedVersion(v) => write!(f, "unsupported UCSI version {v:#06x}"), + Self::NotComplete => write!(f, "CCI did not report command complete"), + Self::CommandError => write!(f, "CCI reported command error"), + Self::NotSupported => write!(f, "CCI reported command not supported"), + Self::UnexpectedDataLen { expected, actual } => { + write!(f, "CCI data length {actual}, expected {expected}") + } + } + } +} + +impl std::error::Error for MailboxError {} + +/// A decoded UCSI mailbox: validated VERSION + CCI plus the raw MESSAGE IN bytes. +#[derive(Debug, Clone)] +pub struct Mailbox { + version: UcsiVersion, + cci: Cci, + message_in: [u8; MESSAGE_IN_LEN], +} + +impl Mailbox { + /// Validate VERSION and CCI, returning the decoded mailbox. + /// + /// Rejects a wrong-sized buffer, an unsupported VERSION, or a CCI that is + /// not command-complete / reports error / not-supported. + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() != MAILBOX_LEN { + return Err(MailboxError::WrongLength { + expected: MAILBOX_LEN, + actual: bytes.len(), + }); + } + let version = UcsiVersion(u16::from_le_bytes([bytes[VERSION_OFFSET], bytes[VERSION_OFFSET + 1]])); + if version.0 != UCSI_VERSION_1_2 { + return Err(MailboxError::UnsupportedVersion(version.0)); + } + let cci = Cci(u32::from_le_bytes( + bytes[CCI_OFFSET..CCI_OFFSET + 4].try_into().expect("4-byte CCI slice"), + )); + if !cci.cmd_complete() { + return Err(MailboxError::NotComplete); + } + if cci.error() { + return Err(MailboxError::CommandError); + } + if cci.not_supported() { + return Err(MailboxError::NotSupported); + } + let mut message_in = [0u8; MESSAGE_IN_LEN]; + message_in.copy_from_slice(&bytes[MESSAGE_IN_OFFSET..MESSAGE_IN_OFFSET + MESSAGE_IN_LEN]); + Ok(Self { + version, + cci, + message_in, + }) + } + + /// UCSI version reported in the mailbox. + pub fn version(&self) -> UcsiVersion { + self.version + } + + /// Raw CCI reported in the mailbox. + pub fn cci(&self) -> Cci { + self.cci + } + + /// The first `expected` MESSAGE IN bytes, after checking CCI data length. + fn data(&self, expected: usize) -> Result<&[u8], MailboxError> { + let actual = self.cci.data_len() as usize; + if actual != expected { + return Err(MailboxError::UnexpectedDataLen { expected, actual }); + } + Ok(&self.message_in[..expected]) + } + + /// Decode a GET_CAPABILITY (16-byte) response. + pub fn capability(&self) -> Result { + let d = self.data(16)?; + let attributes = u32::from_le_bytes([d[0], d[1], d[2], d[3]]); + Ok(UcsiCapability { + num_connectors: d[4], + usb_pd_supported: attributes & (1 << 2) != 0, + bcd_pd_version: u16::from_le_bytes([d[12], d[13]]), + bcd_usb_type_c_version: u16::from_le_bytes([d[14], d[15]]), + }) + } + + /// Decode a GET_CONNECTOR_CAPABILITY (2-byte) response. + pub fn connector_capability(&self) -> Result { + let d = self.data(2)?; + let raw = u16::from_le_bytes([d[0], d[1]]); + let op = (raw & 0xff) as u8; + Ok(UcsiConnectorCapability { + operation_mode: OperationMode { + drp: op & (1 << 2) != 0, + usb2: op & (1 << 5) != 0, + usb3: op & (1 << 6) != 0, + }, + provider: raw & (1 << 8) != 0, + consumer: raw & (1 << 9) != 0, + }) + } + + /// Decode a GET_CONNECTOR_STATUS (11-byte) response. + pub fn connector_status(&self) -> Result { + let d = self.data(11)?; + Ok(UcsiConnectorStatus { + connected: bit(d, 19), + power_direction: if bit(d, 20) { + PowerDirection::Source + } else { + PowerDirection::Sink + }, + partner_usb: bit(d, 21), + }) + } +} + +/// Read bit `index` from a little-endian byte slice (bit 0 = LSB of byte 0). +fn bit(bytes: &[u8], index: usize) -> bool { + (bytes[index / 8] >> (index % 8)) & 1 == 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// CCI for a completed command carrying `data_len` bytes. + fn cci_complete(data_len: u8) -> u32 { + (1 << 31) | ((data_len as u32) << 8) + } + + /// Assemble a 48-byte mailbox from a CCI word and MESSAGE IN bytes. + fn mailbox(cci: u32, message_in: &[u8]) -> [u8; MAILBOX_LEN] { + let mut buf = [0u8; MAILBOX_LEN]; + buf[VERSION_OFFSET..VERSION_OFFSET + 2].copy_from_slice(&UCSI_VERSION_1_2.to_le_bytes()); + buf[CCI_OFFSET..CCI_OFFSET + 4].copy_from_slice(&cci.to_le_bytes()); + buf[MESSAGE_IN_OFFSET..MESSAGE_IN_OFFSET + message_in.len()].copy_from_slice(message_in); + buf + } + + // ── control ─────────────────────────────────────────────────────────────── + + #[test] + fn control_places_opcode_and_connector() { + let c = control(opcode::GET_CONNECTOR_STATUS, 1); + assert_eq!(c[0], 0x12); + assert_eq!(c[1], 0x00); + assert_eq!(c[2], 0x01); + assert_eq!(&c[3..], &[0u8; 5]); + } + + // ── version / display ───────────────────────────────────────────────────── + + #[test] + fn version_display_is_major_minor() { + assert_eq!(UcsiVersion(0x0120).to_string(), "1.2"); + } + + // ── decode: VERSION / CCI / length validation ───────────────────────────── + + #[test] + fn decode_rejects_wrong_length() { + assert_eq!( + Mailbox::decode(&[0u8; 47]).unwrap_err(), + MailboxError::WrongLength { + expected: 48, + actual: 47 + } + ); + } + + #[test] + fn decode_rejects_unsupported_version() { + let mut buf = mailbox(cci_complete(16), &[]); + buf[0..2].copy_from_slice(&0x0100u16.to_le_bytes()); + assert_eq!( + Mailbox::decode(&buf).unwrap_err(), + MailboxError::UnsupportedVersion(0x0100) + ); + } + + #[test] + fn decode_rejects_incomplete_cci() { + let buf = mailbox(0, &[]); + assert_eq!(Mailbox::decode(&buf).unwrap_err(), MailboxError::NotComplete); + } + + #[test] + fn decode_rejects_error_cci() { + let buf = mailbox((1 << 31) | (1 << 30), &[]); + assert_eq!(Mailbox::decode(&buf).unwrap_err(), MailboxError::CommandError); + } + + #[test] + fn decode_rejects_not_supported_cci() { + let buf = mailbox((1 << 31) | (1 << 25), &[]); + assert_eq!(Mailbox::decode(&buf).unwrap_err(), MailboxError::NotSupported); + } + + #[test] + fn decode_accepts_valid_header() { + let buf = mailbox(cci_complete(16), &[]); + let mb = Mailbox::decode(&buf).expect("valid mailbox"); + assert_eq!(mb.version(), UcsiVersion(0x0120)); + assert_eq!(mb.cci().data_len(), 16); + } + + // ── capability (16 bytes) ───────────────────────────────────────────────── + + #[test] + fn capability_decodes_fixture() { + // attributes bit2 (USB PD), num_connectors=1, bcdPD=0x0300, bcdTypeC=0x0200. + let mut msg = [0u8; 16]; + msg[0] = 0b0000_0100; + msg[4] = 1; + msg[12..14].copy_from_slice(&0x0300u16.to_le_bytes()); + msg[14..16].copy_from_slice(&0x0200u16.to_le_bytes()); + let mb = Mailbox::decode(&mailbox(cci_complete(16), &msg)).unwrap(); + assert_eq!( + mb.capability().unwrap(), + UcsiCapability { + num_connectors: 1, + usb_pd_supported: true, + bcd_pd_version: 0x0300, + bcd_usb_type_c_version: 0x0200, + } + ); + } + + #[test] + fn capability_rejects_wrong_data_len() { + let mb = Mailbox::decode(&mailbox(cci_complete(2), &[0u8; 16])).unwrap(); + assert_eq!( + mb.capability(), + Err(MailboxError::UnexpectedDataLen { + expected: 16, + actual: 2 + }) + ); + } + + // ── connector capability (2 bytes) ──────────────────────────────────────── + + #[test] + fn connector_capability_decodes_fixture() { + // operation_mode = drp|usb2|usb3, provider + consumer. + let op = (1 << 2) | (1 << 5) | (1 << 6); + let raw: u16 = op | (1 << 8) | (1 << 9); + let mb = Mailbox::decode(&mailbox(cci_complete(2), &raw.to_le_bytes())).unwrap(); + assert_eq!( + mb.connector_capability().unwrap(), + UcsiConnectorCapability { + operation_mode: OperationMode { + drp: true, + usb2: true, + usb3: true, + }, + provider: true, + consumer: true, + } + ); + } + + // ── connector status (11 bytes) ─────────────────────────────────────────── + + #[test] + fn connector_status_decodes_connected_sink() { + // connect_status bit19, power_direction bit20=0 (sink), partner usb bit21. + let mut msg = [0u8; 11]; + msg[2] = (1 << 3) | (1 << 5); // bit19 (connect) + bit21 (partner usb) + let mb = Mailbox::decode(&mailbox(cci_complete(11), &msg)).unwrap(); + assert_eq!( + mb.connector_status().unwrap(), + UcsiConnectorStatus { + connected: true, + power_direction: PowerDirection::Sink, + partner_usb: true, + } + ); + } + + #[test] + fn connector_status_decodes_source_direction() { + let mut msg = [0u8; 11]; + msg[2] = (1 << 3) | (1 << 4); // connect + power_direction=source (bit20) + let mb = Mailbox::decode(&mailbox(cci_complete(11), &msg)).unwrap(); + assert_eq!(mb.connector_status().unwrap().power_direction, PowerDirection::Source); + } +} diff --git a/ec/test-tui/src/app.rs b/ec/test-tui/src/app.rs index d180fbc..c240fa5 100644 --- a/ec/test-tui/src/app.rs +++ b/ec/test-tui/src/app.rs @@ -1,9 +1,10 @@ use crate::battery::Battery; use crate::logging::LogBuffer; use crate::rtc::Rtc; -use crate::state::{BatteryCommand, BatteryState, RtcState, SystemState, ThermalCommand, ThermalState}; +use crate::state::{BatteryCommand, BatteryState, RtcState, SystemState, ThermalCommand, ThermalState, UcsiState}; use crate::system::System; use crate::thermal::Thermal; +use crate::ucsi::Ucsi; use crate::common::SYMBOLS; @@ -33,6 +34,7 @@ pub(crate) enum TabModule { Thermal(Thermal), Rtc(Rtc), System(System), + Ucsi(Ucsi), } impl TabModule { @@ -42,6 +44,7 @@ impl TabModule { Self::Thermal(_) => "Thermal", Self::Rtc(_) => "RTC", Self::System(_) => "System", + Self::Ucsi(_) => "USB-C", } } @@ -51,6 +54,7 @@ impl TabModule { Self::Thermal(m) => m.handle_event(evt), Self::Rtc(m) => m.handle_event(evt), Self::System(m) => m.handle_event(evt), + Self::Ucsi(m) => m.handle_event(evt), } } @@ -84,6 +88,12 @@ impl TabModule { } } + pub(crate) fn render_ucsi(&self, state: &UcsiState, area: Rect, buf: &mut Buffer) { + if let Self::Ucsi(m) = self { + m.render(state, area, buf); + } + } + pub(crate) fn render_card_power(&self, state: &BatteryState, area: Rect, buf: &mut Buffer) { if let Self::Power(m) = self { m.render_card(state, area, buf); @@ -108,11 +118,17 @@ impl TabModule { } } + pub(crate) fn render_card_ucsi(&self, state: &UcsiState, area: Rect, buf: &mut Buffer) { + if let Self::Ucsi(m) = self { + m.render_card(state, area, buf); + } + } + pub(crate) fn is_popup_open(&self) -> bool { match self { Self::Power(m) => m.is_popup_open(), Self::Thermal(m) => m.is_popup_open(), - Self::Rtc(_) | Self::System(_) => false, + Self::Rtc(_) | Self::System(_) | Self::Ucsi(_) => false, } } } @@ -137,17 +153,20 @@ enum SelectedTab { TabRTC, #[strum(to_string = "System")] TabSystem, + #[strum(to_string = "USB-C")] + TabUsbC, } /// The main application: holds UI state and a read handle on the shared data. pub struct App { run_state: RunState, selected_tab: SelectedTab, - modules: [TabModule; 4], + modules: [TabModule; 5], battery_state: Arc>, thermal_state: Arc>, rtc_state: Arc>, system_state: Arc>, + ucsi_state: Arc>, log_buffer: LogBuffer, log_visible: bool, log_scroll: usize, @@ -159,11 +178,13 @@ impl App { /// * `battery_state` / `thermal_state` / `rtc_state` / `system_state` — /// populated by the background updater threads. /// * `battery_tx` / `thermal_tx` — command channels for hardware write-backs. + #[allow(clippy::too_many_arguments)] pub fn new( battery_state: Arc>, thermal_state: Arc>, rtc_state: Arc>, system_state: Arc>, + ucsi_state: Arc>, battery_tx: mpsc::Sender, thermal_tx: mpsc::Sender, log_buffer: LogBuffer, @@ -173,6 +194,7 @@ impl App { TabModule::Thermal(Thermal::new(thermal_tx)), TabModule::Rtc(Rtc::new()), TabModule::System(System::new()), + TabModule::Ucsi(Ucsi::new()), ]; let app = Self { @@ -183,6 +205,7 @@ impl App { thermal_state, rtc_state, system_state, + ucsi_state, log_buffer, log_visible: false, log_scroll: 0, @@ -244,6 +267,7 @@ impl App { KeyCode::Char('3') => self.selected_tab = SelectedTab::TabThermal, KeyCode::Char('4') => self.selected_tab = SelectedTab::TabRTC, KeyCode::Char('5') => self.selected_tab = SelectedTab::TabSystem, + KeyCode::Char('6') => self.selected_tab = SelectedTab::TabUsbC, KeyCode::Char('l') => { self.log_visible = !self.log_visible; if self.log_visible { @@ -313,6 +337,7 @@ impl App { let thm = self.thermal_state.read().expect("thermal RwLock poisoned"); module.render_system(&sys, Some(&thm), inner, buf); } + 4 => module.render_ucsi(&self.ucsi_state.read().expect("ucsi RwLock poisoned"), inner, buf), _ => unreachable!(), } } @@ -325,19 +350,27 @@ impl App { let inner = block.inner(area); block.render(area, buf); - let [row0, row1] = Layout::vertical([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(inner); + let [row0, row1, row2] = Layout::vertical([ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ]) + .areas(inner); let [card00, card01] = Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(row0); let [card10, card11] = Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(row1); + let [card20, _card21] = Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(row2); let bat = self.battery_state.read().expect("battery RwLock poisoned"); let thm = self.thermal_state.read().expect("thermal RwLock poisoned"); let rtc = self.rtc_state.read().expect("rtc RwLock poisoned"); let sys = self.system_state.read().expect("system RwLock poisoned"); + let ucsi = self.ucsi_state.read().expect("ucsi RwLock poisoned"); self.modules[0].render_card_power(&bat, card00, buf); self.modules[1].render_card_thermal(&thm, card01, buf); self.modules[2].render_card_rtc(&rtc, card10, buf); self.modules[3].render_card_system(&sys, card11, buf); + self.modules[4].render_card_ucsi(&ucsi, card20, buf); } } @@ -439,7 +472,7 @@ impl App { let mut spans = vec![ Span::styled(format!(" {} {} ", SYMBOLS.arrow_left, SYMBOLS.arrow_right), key), Span::styled(" switch tab ", desc), - Span::styled(" 1-5 ", key), + Span::styled(" 1-6 ", key), Span::styled(" jump to tab ", desc), Span::styled(" l ", key), Span::styled(log_hint, desc), @@ -469,6 +502,7 @@ impl SelectedTab { Self::TabThermal => Some(1), Self::TabRTC => Some(2), Self::TabSystem => Some(3), + Self::TabUsbC => Some(4), } } @@ -519,6 +553,7 @@ impl SelectedTab { Self::TabThermal => tailwind::ORANGE, Self::TabRTC => tailwind::VIOLET, Self::TabSystem => tailwind::EMERALD, + Self::TabUsbC => tailwind::CYAN, } } } diff --git a/ec/test-tui/src/battery.rs b/ec/test-tui/src/battery.rs index 78bebeb..a5c913a 100644 --- a/ec/test-tui/src/battery.rs +++ b/ec/test-tui/src/battery.rs @@ -626,6 +626,21 @@ mod tests { ) -> color_eyre::Result { Err(eyre!("unused")) } + fn get_ucsi_version(&self) -> color_eyre::Result { + Err(eyre!("unused")) + } + fn get_ucsi_capability(&self) -> color_eyre::Result { + Err(eyre!("unused")) + } + fn get_ucsi_connector_capability( + &self, + _: u8, + ) -> color_eyre::Result { + Err(eyre!("unused")) + } + fn get_ucsi_connector_status(&self, _: u8) -> color_eyre::Result { + Err(eyre!("unused")) + } } struct ErrSource; @@ -681,6 +696,21 @@ mod tests { ) -> color_eyre::Result { Err(eyre!("unused")) } + fn get_ucsi_version(&self) -> color_eyre::Result { + Err(eyre!("unused")) + } + fn get_ucsi_capability(&self) -> color_eyre::Result { + Err(eyre!("unused")) + } + fn get_ucsi_connector_capability( + &self, + _: u8, + ) -> color_eyre::Result { + Err(eyre!("unused")) + } + fn get_ucsi_connector_status(&self, _: u8) -> color_eyre::Result { + Err(eyre!("unused")) + } } // ── poll_bst ───────────────────────────────────────────────────────────── diff --git a/ec/test-tui/src/main.rs b/ec/test-tui/src/main.rs index 9047abe..3e221e5 100644 --- a/ec/test-tui/src/main.rs +++ b/ec/test-tui/src/main.rs @@ -7,6 +7,7 @@ mod source; mod state; mod system; mod thermal; +mod ucsi; mod updater; mod widgets; @@ -91,6 +92,7 @@ const BATTERY_PERIOD: Duration = Duration::from_secs(1); const THERMAL_PERIOD: Duration = Duration::from_secs(1); const RTC_PERIOD: Duration = Duration::from_secs(1); const SYSTEM_PERIOD: Duration = Duration::from_millis(500); +const UCSI_PERIOD: Duration = Duration::from_secs(1); fn init_tracing(cli: &Cli) -> color_eyre::Result { let file_layer: Option<_> = cli @@ -129,6 +131,7 @@ async fn main() -> color_eyre::Result<()> { let thermal_state = Arc::new(RwLock::new(state::ThermalState::default())); let rtc_state = Arc::new(RwLock::new(state::RtcState::default())); let system_state = Arc::new(RwLock::new(state::SystemState::default())); + let ucsi_state = Arc::new(RwLock::new(state::UcsiState::default())); let (battery_tx, battery_rx) = std::sync::mpsc::channel::(); let (thermal_tx, thermal_rx) = std::sync::mpsc::channel::(); @@ -149,12 +152,17 @@ async fn main() -> color_eyre::Result<()> { let upd = updater::SystemUpdater::new(Arc::clone(&system_state)); async move { upd.run(SYSTEM_PERIOD).await } }); + tokio::task::spawn({ + let upd = updater::UcsiUpdater::new(Arc::clone(&source), Arc::clone(&ucsi_state)); + async move { upd.run(UCSI_PERIOD).await } + }); app::App::new( battery_state, thermal_state, rtc_state, system_state, + ucsi_state, battery_tx, thermal_tx, log_buffer, diff --git a/ec/test-tui/src/source.rs b/ec/test-tui/src/source.rs index 972f1b6..46f0920 100644 --- a/ec/test-tui/src/source.rs +++ b/ec/test-tui/src/source.rs @@ -14,6 +14,7 @@ use std::sync::Arc; use battery_service_interface::{BixFixedStrings, BstReturn}; use color_eyre::Result; use ec_test_lib::Threshold; +use ec_test_lib::ucsi::{UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus, UcsiVersion}; use time_alarm_service_interface::{ AcpiTimerId, AcpiTimestamp, AlarmExpiredWakePolicy, AlarmTimerSeconds, TimeAlarmDeviceCapabilities, TimerStatus, }; @@ -43,6 +44,12 @@ pub(crate) trait DynSource: Send + Sync { fn get_wake_status(&self, timer_id: AcpiTimerId) -> Result; fn get_expired_timer_wake_policy(&self, timer_id: AcpiTimerId) -> Result; fn get_timer_value(&self, timer_id: AcpiTimerId) -> Result; + + // UCSI + fn get_ucsi_version(&self) -> Result; + fn get_ucsi_capability(&self) -> Result; + fn get_ucsi_connector_capability(&self, connector: u8) -> Result; + fn get_ucsi_connector_status(&self, connector: u8) -> Result; } // ── Blanket impl ───────────────────────────────────────────────────────────── @@ -96,6 +103,19 @@ where fn get_timer_value(&self, timer_id: AcpiTimerId) -> Result { ec_test_lib::RtcSource::get_timer_value(self, timer_id).map_err(Into::into) } + + fn get_ucsi_version(&self) -> Result { + ec_test_lib::UcsiSource::get_version(self).map_err(Into::into) + } + fn get_ucsi_capability(&self) -> Result { + ec_test_lib::UcsiSource::get_capability(self).map_err(Into::into) + } + fn get_ucsi_connector_capability(&self, connector: u8) -> Result { + ec_test_lib::UcsiSource::get_connector_capability(self, connector).map_err(Into::into) + } + fn get_ucsi_connector_status(&self, connector: u8) -> Result { + ec_test_lib::UcsiSource::get_connector_status(self, connector).map_err(Into::into) + } } // ── Factory ────────────────────────────────────────────────────────────────── diff --git a/ec/test-tui/src/state.rs b/ec/test-tui/src/state.rs index 9282041..dbed9c7 100644 --- a/ec/test-tui/src/state.rs +++ b/ec/test-tui/src/state.rs @@ -1,4 +1,5 @@ use battery_service_interface::{BixFixedStrings, BstReturn}; +use ec_test_lib::ucsi::{UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus, UcsiVersion}; use time_alarm_service_interface::{ AcpiTimestamp, AlarmExpiredWakePolicy, AlarmTimerSeconds, TimeAlarmDeviceCapabilities, TimerStatus, }; @@ -174,3 +175,20 @@ pub struct RtcState { /// `[0]` = AC Power timer, `[1]` = DC Power timer. pub timers: [TimerData; 2], } + +// ── UCSI (USB-C) ────────────────────────────────────────────────────────────── + +/// The single connector queried by the host UCSI slice. +pub const UCSI_CONNECTOR: u8 = 1; + +/// Live UCSI state for the USB-C tab and dashboard card. +/// +/// Written exclusively by [`crate::updater::UcsiUpdater`]; read by the UCSI UI +/// module for rendering. +#[derive(Default)] +pub struct UcsiState { + pub version: Fetched, + pub capability: Fetched, + pub connector_capability: Fetched, + pub connector_status: Fetched, +} diff --git a/ec/test-tui/src/thermal.rs b/ec/test-tui/src/thermal.rs index b7b8aa2..8f434c1 100644 --- a/ec/test-tui/src/thermal.rs +++ b/ec/test-tui/src/thermal.rs @@ -587,6 +587,21 @@ mod tests { ) -> color_eyre::Result { Err(eyre!("unused")) } + fn get_ucsi_version(&self) -> color_eyre::Result { + Err(eyre!("unused")) + } + fn get_ucsi_capability(&self) -> color_eyre::Result { + Err(eyre!("unused")) + } + fn get_ucsi_connector_capability( + &self, + _: u8, + ) -> color_eyre::Result { + Err(eyre!("unused")) + } + fn get_ucsi_connector_status(&self, _: u8) -> color_eyre::Result { + Err(eyre!("unused")) + } } struct ErrThermal; @@ -642,6 +657,21 @@ mod tests { ) -> color_eyre::Result { Err(eyre!("unused")) } + fn get_ucsi_version(&self) -> color_eyre::Result { + Err(eyre!("unused")) + } + fn get_ucsi_capability(&self) -> color_eyre::Result { + Err(eyre!("unused")) + } + fn get_ucsi_connector_capability( + &self, + _: u8, + ) -> color_eyre::Result { + Err(eyre!("unused")) + } + fn get_ucsi_connector_status(&self, _: u8) -> color_eyre::Result { + Err(eyre!("unused")) + } } // ── SensorData ─────────────────────────────────────────────────────────── diff --git a/ec/test-tui/src/ucsi.rs b/ec/test-tui/src/ucsi.rs new file mode 100644 index 0000000..93e73bb --- /dev/null +++ b/ec/test-tui/src/ucsi.rs @@ -0,0 +1,271 @@ +use crate::common; +use crate::common::SYMBOLS; +use crate::state::{Fetched, UcsiState}; +use ec_test_lib::ucsi::{UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus, UcsiVersion}; +use ratatui::{ + buffer::Buffer, + crossterm::event::Event, + layout::{Constraint, Layout, Rect}, + prelude::*, + style::{Color, Style, Stylize, palette::tailwind}, + text::{Line, Span}, + widgets::{Block, Paragraph}, +}; + +const LABEL_COLOR: Color = tailwind::CYAN.c300; + +/// USB-C / UCSI UI module — stateless; all data is read from [`UcsiState`]. +pub struct Ucsi; + +impl Ucsi { + pub fn new() -> Self { + Self + } + + pub(crate) fn handle_event(&mut self, _evt: &Event) {} + + pub(crate) fn render(&self, state: &UcsiState, area: Rect, buf: &mut Buffer) { + use Constraint::{Length, Min}; + + let is_healthy = matches!(state.version, Some(Ok(_))) && matches!(state.connector_status, Some(Ok(_))); + + let [version_area, bottom_area] = Layout::vertical([Length(4), Min(0)]).areas(area); + let [cap_area, conn_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(bottom_area); + + let block = Block::bordered() + .title(common::status_title("USB-C (UCSI)", is_healthy)) + .border_style(tailwind::CYAN.c600); + let inner = block.inner(version_area); + block.render(version_area, buf); + Paragraph::new(vec![ + Line::from(Span::styled( + format!("UCSI {}", format_version(&state.version)), + Style::default().fg(Color::White).bold(), + )), + Line::from(format_capability_line(&state.capability)), + ]) + .render(inner, buf); + + self.render_connector_capability(state, cap_area, buf); + self.render_connector_status(state, conn_area, buf); + } + + pub(crate) fn render_card(&self, state: &UcsiState, area: Rect, buf: &mut Buffer) { + use Constraint::{Length, Min}; + + let is_healthy = matches!(state.connector_status, Some(Ok(_))); + let block = Block::bordered() + .title(common::status_title("USB-C", is_healthy)) + .border_style(tailwind::CYAN.c700); + let inner = block.inner(area); + block.render(area, buf); + + let [version_area, meta_area, status_area] = Layout::vertical([Length(1), Length(2), Min(0)]).areas(inner); + + Line::from(Span::styled( + format!("UCSI {}", format_version(&state.version)), + Style::default().fg(Color::White).bold(), + )) + .render(version_area, buf); + + Paragraph::new(vec![ + common::metric_row("Connectors", format_connectors(&state.capability), LABEL_COLOR), + common::metric_row( + "Conn 1", + format_connector_capability(&state.connector_capability), + LABEL_COLOR, + ), + ]) + .render(meta_area, buf); + + Paragraph::new(vec![common::metric_row( + "Status", + format_status(&state.connector_status), + LABEL_COLOR, + )]) + .render(status_area, buf); + } + + fn render_connector_capability(&self, state: &UcsiState, area: Rect, buf: &mut Buffer) { + let is_ok = matches!(state.connector_capability, Some(Ok(_))); + let lines: Vec> = match &state.connector_capability { + None => vec![Line::raw("Pending...")], + Some(Err(e)) => vec![Line::raw(format!("Error: {e}"))], + Some(Ok(cap)) => vec![ + Line::raw(format!("Modes: {}", format_operation_mode(cap))), + Line::raw(format!("Provider: {}", yes_no(cap.provider))), + Line::raw(format!("Consumer: {}", yes_no(cap.consumer))), + ], + }; + Paragraph::new(lines) + .block(common::title_block( + common::status_title("Connector 1 Capability", is_ok), + 0, + LABEL_COLOR, + )) + .render(area, buf); + } + + fn render_connector_status(&self, state: &UcsiState, area: Rect, buf: &mut Buffer) { + let is_ok = matches!(state.connector_status, Some(Ok(_))); + let lines: Vec> = match &state.connector_status { + None => vec![Line::raw("Pending...")], + Some(Err(e)) => vec![Line::raw(format!("Error: {e}"))], + Some(Ok(status)) => vec![ + Line::raw(format!("Attached: {}", yes_no(status.connected))), + Line::raw(format!("Direction: {}", status.power_direction)), + Line::raw(format!("Partner: {}", if status.partner_usb { "USB" } else { "-" })), + ], + }; + Paragraph::new(lines) + .block(common::title_block( + common::status_title("Connector 1 Status", is_ok), + 0, + LABEL_COLOR, + )) + .render(area, buf); + } +} + +// ── Formatting helpers ──────────────────────────────────────────────────────── + +fn yes_no(v: bool) -> &'static str { + if v { "Yes" } else { "No" } +} + +fn format_version(version: &Fetched) -> String { + match version { + None => "Pending...".to_string(), + Some(Err(_)) => "Error".to_string(), + Some(Ok(v)) => v.to_string(), + } +} + +fn format_connectors(capability: &Fetched) -> String { + match capability { + None => "Pending...".to_string(), + Some(Err(_)) => "Error".to_string(), + Some(Ok(cap)) => cap.num_connectors.to_string(), + } +} + +fn format_capability_line(capability: &Fetched) -> Span<'static> { + let text = match capability { + None => "Pending...".to_string(), + Some(Err(e)) => format!("Error: {e}"), + Some(Ok(cap)) => format!( + "{} connector(s){} PD {:x}.{:02x}", + cap.num_connectors, + if cap.usb_pd_supported { " USB-PD" } else { "" }, + cap.bcd_pd_version >> 8, + cap.bcd_pd_version & 0xff, + ), + }; + Span::styled(text, Style::default().fg(tailwind::SLATE.c400)) +} + +fn format_operation_mode(cap: &UcsiConnectorCapability) -> String { + let mut modes = Vec::new(); + if cap.operation_mode.drp { + modes.push("DRP"); + } + if cap.operation_mode.usb2 { + modes.push("USB2"); + } + if cap.operation_mode.usb3 { + modes.push("USB3"); + } + if modes.is_empty() { + "none".to_string() + } else { + modes.join(&format!(" {} ", SYMBOLS.mid_dot)) + } +} + +fn format_connector_capability(cap: &Fetched) -> String { + match cap { + None => "Pending...".to_string(), + Some(Err(_)) => "Error".to_string(), + Some(Ok(c)) => { + let roles = match (c.provider, c.consumer) { + (true, true) => "provider/consumer", + (true, false) => "provider", + (false, true) => "consumer", + (false, false) => "-", + }; + format!("{} {} {roles}", format_operation_mode(c), SYMBOLS.mid_dot) + } + } +} + +fn format_status(status: &Fetched) -> String { + match status { + None => "Pending...".to_string(), + Some(Err(_)) => "Error".to_string(), + Some(Ok(s)) => { + if !s.connected { + "Disconnected".to_string() + } else { + let partner = if s.partner_usb { "USB" } else { "partner" }; + format!( + "Connected {} {} {} {partner}", + SYMBOLS.mid_dot, s.power_direction, SYMBOLS.mid_dot + ) + } + } + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use ec_test_lib::ucsi::{OperationMode, PowerDirection}; + + #[test] + fn status_connected_sink_summary() { + let status = Some(Ok(UcsiConnectorStatus { + connected: true, + power_direction: PowerDirection::Sink, + partner_usb: true, + })); + let s = format_status(&status); + assert!(s.contains("Connected"), "{s}"); + assert!(s.contains("Sink"), "{s}"); + assert!(s.contains("USB"), "{s}"); + } + + #[test] + fn status_disconnected_summary() { + let status = Some(Ok(UcsiConnectorStatus { + connected: false, + power_direction: PowerDirection::Source, + partner_usb: false, + })); + assert_eq!(format_status(&status), "Disconnected"); + } + + #[test] + fn status_pending_and_error() { + assert_eq!(format_status(&None), "Pending..."); + assert_eq!(format_status(&Some(Err(color_eyre::eyre::eyre!("x")))), "Error"); + } + + #[test] + fn operation_mode_lists_enabled_flags() { + let cap = UcsiConnectorCapability { + operation_mode: OperationMode { + drp: true, + usb2: true, + usb3: false, + }, + provider: true, + consumer: true, + }; + let s = format_operation_mode(&cap); + assert!(s.contains("DRP") && s.contains("USB2"), "{s}"); + assert!(!s.contains("USB3"), "{s}"); + } +} diff --git a/ec/test-tui/src/updater.rs b/ec/test-tui/src/updater.rs index a8814a1..7a77514 100644 --- a/ec/test-tui/src/updater.rs +++ b/ec/test-tui/src/updater.rs @@ -9,6 +9,7 @@ use crate::battery::{poll_bix, poll_bst}; use crate::source::DynSource; use crate::state::{ BatteryCommand, BatteryState, FanRpmBounds, FanStateLevels, RtcState, SystemState, ThermalCommand, ThermalState, + UCSI_CONNECTOR, UcsiState, }; // ── Battery ─────────────────────────────────────────────────────────────────── @@ -385,3 +386,71 @@ impl SystemUpdater { } } } + +// ── UCSI (USB-C) ────────────────────────────────────────────────────────────── + +/// Polls the UCSI version, PPM capability, and connector state on every tick. +pub struct UcsiUpdater { + source: Arc, + state: Arc>, +} + +impl UcsiUpdater { + pub fn new(source: Arc, state: Arc>) -> Self { + Self { source, state } + } + + #[tracing::instrument(skip_all)] + fn update(&mut self) { + let version = self.source.get_ucsi_version(); + let capability = self.source.get_ucsi_capability(); + let connector_capability = self.source.get_ucsi_connector_capability(UCSI_CONNECTOR); + let connector_status = self.source.get_ucsi_connector_status(UCSI_CONNECTOR); + + if let Err(ref e) = version { + warn!(error = %e, "failed to read UCSI version"); + } + if let Err(ref e) = connector_status { + warn!(error = %e, "failed to read UCSI connector status"); + } + + let mut s = self.state.write().expect("state RwLock poisoned"); + s.version = Some(version); + s.capability = Some(capability); + s.connector_capability = Some(connector_capability); + s.connector_status = Some(connector_status); + } + + pub async fn run(mut self, interval: Duration) { + info!(interval_ms = interval.as_millis(), "UCSI updater started"); + self.update(); + loop { + tokio::time::sleep(interval).await; + self.update(); + } + } +} + +#[cfg(test)] +mod ucsi_tests { + use super::*; + use ec_test_lib::mock::Mock; + use ec_test_lib::ucsi::{PowerDirection, UcsiVersion}; + + #[test] + fn update_populates_cells_from_source() { + let source: Arc = Arc::new(Mock::default()); + let state = Arc::new(RwLock::new(UcsiState::default())); + let mut updater = UcsiUpdater::new(source, Arc::clone(&state)); + + updater.update(); + + let s = state.read().unwrap(); + assert_eq!(s.version.as_ref().unwrap().as_ref().unwrap(), &UcsiVersion(0x0120)); + assert_eq!(s.capability.as_ref().unwrap().as_ref().unwrap().num_connectors, 1); + assert!(s.connector_capability.as_ref().unwrap().as_ref().unwrap().provider); + let status = s.connector_status.as_ref().unwrap().as_ref().unwrap(); + assert!(status.connected); + assert_eq!(status.power_direction, PowerDirection::Sink); + } +} From 83d08ee422d70de1f8abb57a727fc45cb4c0ee92 Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Fri, 31 Jul 2026 19:54:56 +0000 Subject: [PATCH 4/6] Simplify UCSI decode and USB-C TUI per review Apply the independent code-judo findings, cutting LOC and concepts while preserving behavior and the unique tests. ec-test-lib: - Replace the public `Mailbox` + `Cci` intermediate types with two private helpers (`validate` / `message_in`) plus free `decode_*` functions. They enforce 48-byte length, VERSION 0x0120, cmd_complete, no error/not_supported, and the expected data length, then decode each response directly. - Flatten `UcsiConnectorCapability` (drop the `OperationMode` wrapper) and drop the unused CCI `busy` accessor and capability Type-C version field. `UcsiVersion` is retained since Source/TUI consume it. - Delete the duplicate Mock tests that mirrored the fixture constants; the retained updater test already exercises the Mock values. ec-test-tui: - Collapse the UCSI UI to one shared metric-row builder used by both the tab and the dashboard card; remove the dedicated panel renderers and redundant formatting helpers. Pending/error text stays honest. - Stop warning every poll for the steadily-unsupported serial backend; the Fetched error cells still surface the failure to the UI. - Give `DynSource` default UCSI methods so the battery/thermal test doubles need no stubs, reverting those files to pristine. - Drop the UI formatting tests that only mirrored string output. Assisted-by: GitHub Copilot:claude-opus-4.8 Copilot-Session: 6cb7060d-6d47-49ad-a710-428fe4c2f3dc --- ec/test-lib/src/acpi.rs | 16 +- ec/test-lib/src/mock.rs | 64 +------ ec/test-lib/src/ucsi.rs | 354 +++++++++++++------------------------ ec/test-tui/src/battery.rs | 30 ---- ec/test-tui/src/source.rs | 20 ++- ec/test-tui/src/thermal.rs | 30 ---- ec/test-tui/src/ucsi.rs | 274 ++++++---------------------- ec/test-tui/src/updater.rs | 9 +- 8 files changed, 206 insertions(+), 591 deletions(-) diff --git a/ec/test-lib/src/acpi.rs b/ec/test-lib/src/acpi.rs index ffc384a..67d008a 100644 --- a/ec/test-lib/src/acpi.rs +++ b/ec/test-lib/src/acpi.rs @@ -1,4 +1,4 @@ -use crate::ucsi::{self, Mailbox, UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus, UcsiVersion}; +use crate::ucsi::{self, UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus, UcsiVersion}; use crate::{BatterySource, ErrorType, RtcSource, ThermalSource, Threshold, UcsiSource, common}; use battery_service_interface::{ BatteryState, BatterySwapCapability, BatteryTechnology, BixFixedStrings, BstReturn, PowerUnit, @@ -708,8 +708,8 @@ impl RtcSource for Acpi { impl Acpi { /// Issue one UCSI command by writing the 8-byte CONTROL buffer to - /// `\_SB.ECT0.USND` and decoding the returned 48-byte mailbox. - fn ucsi_command(&self, control: [u8; ucsi::CONTROL_LEN]) -> Result { + /// `\_SB.ECT0.USND` and returning the raw 48-byte mailbox response. + fn ucsi_command(&self, control: [u8; ucsi::CONTROL_LEN]) -> Result, Error> { let output = self.evaluate("\\_SB.ECT0.USND", Some(&[AcpiMethodArgument::Buffer(control.to_vec())]))?; if output.count != 1 { return Err(Error::UnexpectedResponse); @@ -718,28 +718,28 @@ impl Acpi { if arg.type_ != AcpiArgumentType::Buffer as u16 { return Err(Error::UnexpectedArgumentType(arg.type_)); } - Ok(Mailbox::decode(&arg.data)?) + Ok(arg.data.clone()) } } impl UcsiSource for Acpi { fn get_version(&self) -> Result { let mailbox = self.ucsi_command(ucsi::control(ucsi::opcode::GET_CAPABILITY, 0))?; - Ok(mailbox.version()) + Ok(ucsi::decode_version(&mailbox)?) } fn get_capability(&self) -> Result { let mailbox = self.ucsi_command(ucsi::control(ucsi::opcode::GET_CAPABILITY, 0))?; - Ok(mailbox.capability()?) + Ok(ucsi::decode_capability(&mailbox)?) } fn get_connector_capability(&self, connector: u8) -> Result { let mailbox = self.ucsi_command(ucsi::control(ucsi::opcode::GET_CONNECTOR_CAPABILITY, connector))?; - Ok(mailbox.connector_capability()?) + Ok(ucsi::decode_connector_capability(&mailbox)?) } fn get_connector_status(&self, connector: u8) -> Result { let mailbox = self.ucsi_command(ucsi::control(ucsi::opcode::GET_CONNECTOR_STATUS, connector))?; - Ok(mailbox.connector_status()?) + Ok(ucsi::decode_connector_status(&mailbox)?) } } diff --git a/ec/test-lib/src/mock.rs b/ec/test-lib/src/mock.rs index 7312ff9..133fa04 100644 --- a/ec/test-lib/src/mock.rs +++ b/ec/test-lib/src/mock.rs @@ -1,6 +1,4 @@ -use crate::ucsi::{ - OperationMode, PowerDirection, UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus, UcsiVersion, -}; +use crate::ucsi::{PowerDirection, UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus, UcsiVersion}; use crate::{BatterySource, ErrorType, RtcSource, ThermalSource, Threshold, UcsiSource}; use battery_service_interface::{ BatteryState, BatterySwapCapability, BatteryTechnology, BixFixedStrings, BstReturn, PowerUnit, @@ -457,16 +455,13 @@ impl UcsiSource for Mock { num_connectors: 1, usb_pd_supported: true, bcd_pd_version: 0x0300, - bcd_usb_type_c_version: 0x0200, }) } fn get_connector_capability(&self, _connector: u8) -> Result { Ok(UcsiConnectorCapability { - operation_mode: OperationMode { - drp: true, - usb2: true, - usb3: true, - }, + drp: true, + usb2: true, + usb3: true, provider: true, consumer: true, }) @@ -479,54 +474,3 @@ impl UcsiSource for Mock { }) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ucsi_version_is_1_2() { - assert_eq!(Mock::new().get_version().unwrap(), UcsiVersion(0x0120)); - } - - #[test] - fn ucsi_capability_reports_single_pd_connector() { - assert_eq!( - Mock::new().get_capability().unwrap(), - UcsiCapability { - num_connectors: 1, - usb_pd_supported: true, - bcd_pd_version: 0x0300, - bcd_usb_type_c_version: 0x0200, - } - ); - } - - #[test] - fn ucsi_connector_capability_is_drp_provider_consumer() { - assert_eq!( - Mock::new().get_connector_capability(1).unwrap(), - UcsiConnectorCapability { - operation_mode: OperationMode { - drp: true, - usb2: true, - usb3: true, - }, - provider: true, - consumer: true, - } - ); - } - - #[test] - fn ucsi_connector_status_is_connected_sink() { - assert_eq!( - Mock::new().get_connector_status(1).unwrap(), - UcsiConnectorStatus { - connected: true, - power_direction: PowerDirection::Sink, - partner_usb: true, - } - ); - } -} diff --git a/ec/test-lib/src/ucsi.rs b/ec/test-lib/src/ucsi.rs index 3adc8c2..ead2c11 100644 --- a/ec/test-lib/src/ucsi.rs +++ b/ec/test-lib/src/ucsi.rs @@ -6,17 +6,12 @@ use std::fmt; -/// Total UCSI mailbox size in bytes. -pub const MAILBOX_LEN: usize = 48; -/// UCSI 1.2 version word reported in the mailbox VERSION field. -pub const UCSI_VERSION_1_2: u16 = 0x0120; /// Length of the UCSI CONTROL field the OS writes to issue a command. pub const CONTROL_LEN: usize = 8; -const VERSION_OFFSET: usize = 0; -const CCI_OFFSET: usize = 4; +const MAILBOX_LEN: usize = 48; +const UCSI_VERSION_1_2: u16 = 0x0120; const MESSAGE_IN_OFFSET: usize = 16; -const MESSAGE_IN_LEN: usize = 16; /// UCSI command opcodes for the host read surface. pub mod opcode { @@ -43,47 +38,9 @@ pub fn control(opcode: u8, connector: u8) -> [u8; CONTROL_LEN] { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UcsiVersion(pub u16); -impl UcsiVersion { - /// Major version digit. - pub fn major(self) -> u8 { - (self.0 >> 8) as u8 - } - /// Minor version digit. - pub fn minor(self) -> u8 { - ((self.0 >> 4) & 0xf) as u8 - } -} - impl fmt::Display for UcsiVersion { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}.{}", self.major(), self.minor()) - } -} - -/// Command Status and Connector Change Indicator (UCSI spec 4.2). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Cci(pub u32); - -impl Cci { - /// Length of the returned MESSAGE IN data (bits 15..8). - pub fn data_len(self) -> u8 { - (self.0 >> 8) as u8 - } - /// Command was not supported (bit 25). - pub fn not_supported(self) -> bool { - self.0 & (1 << 25) != 0 - } - /// Busy (bit 28). - pub fn busy(self) -> bool { - self.0 & (1 << 28) != 0 - } - /// Command error (bit 30). - pub fn error(self) -> bool { - self.0 & (1 << 30) != 0 - } - /// Command complete (bit 31). - pub fn cmd_complete(self) -> bool { - self.0 & (1 << 31) != 0 + write!(f, "{}.{}", self.0 >> 8, (self.0 >> 4) & 0xf) } } @@ -96,26 +53,17 @@ pub struct UcsiCapability { pub usb_pd_supported: bool, /// BCD-coded USB PD spec version. pub bcd_pd_version: u16, - /// BCD-coded USB Type-C spec version. - pub bcd_usb_type_c_version: u16, } -/// Connector operation-mode flags (subset used by the host UI). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct OperationMode { +/// Per-connector capabilities (GET_CONNECTOR_CAPABILITY response). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UcsiConnectorCapability { /// Dual-role port. pub drp: bool, /// USB 2.0 capable. pub usb2: bool, /// USB 3.x capable. pub usb3: bool, -} - -/// Per-connector capabilities (GET_CONNECTOR_CAPABILITY response). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct UcsiConnectorCapability { - /// Supported operation modes. - pub operation_mode: OperationMode, /// Connector can act as a power provider (source). pub provider: bool, /// Connector can act as a power consumer (sink). @@ -154,13 +102,8 @@ pub struct UcsiConnectorStatus { /// Error decoding a UCSI mailbox response. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MailboxError { - /// The mailbox buffer was not exactly [`MAILBOX_LEN`] bytes. - WrongLength { - /// Expected byte count. - expected: usize, - /// Actual byte count. - actual: usize, - }, + /// The mailbox buffer was not exactly 48 bytes. + WrongLength(usize), /// The VERSION field did not match a supported UCSI version. UnsupportedVersion(u16), /// CCI did not report command-complete. @@ -181,9 +124,7 @@ pub enum MailboxError { impl fmt::Display for MailboxError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::WrongLength { expected, actual } => { - write!(f, "mailbox length {actual} bytes, expected {expected}") - } + Self::WrongLength(n) => write!(f, "mailbox length {n} bytes, expected 48"), Self::UnsupportedVersion(v) => write!(f, "unsupported UCSI version {v:#06x}"), Self::NotComplete => write!(f, "CCI did not report command complete"), Self::CommandError => write!(f, "CCI reported command error"), @@ -197,111 +138,36 @@ impl fmt::Display for MailboxError { impl std::error::Error for MailboxError {} -/// A decoded UCSI mailbox: validated VERSION + CCI plus the raw MESSAGE IN bytes. -#[derive(Debug, Clone)] -pub struct Mailbox { - version: UcsiVersion, - cci: Cci, - message_in: [u8; MESSAGE_IN_LEN], -} - -impl Mailbox { - /// Validate VERSION and CCI, returning the decoded mailbox. - /// - /// Rejects a wrong-sized buffer, an unsupported VERSION, or a CCI that is - /// not command-complete / reports error / not-supported. - pub fn decode(bytes: &[u8]) -> Result { - if bytes.len() != MAILBOX_LEN { - return Err(MailboxError::WrongLength { - expected: MAILBOX_LEN, - actual: bytes.len(), - }); - } - let version = UcsiVersion(u16::from_le_bytes([bytes[VERSION_OFFSET], bytes[VERSION_OFFSET + 1]])); - if version.0 != UCSI_VERSION_1_2 { - return Err(MailboxError::UnsupportedVersion(version.0)); - } - let cci = Cci(u32::from_le_bytes( - bytes[CCI_OFFSET..CCI_OFFSET + 4].try_into().expect("4-byte CCI slice"), - )); - if !cci.cmd_complete() { - return Err(MailboxError::NotComplete); - } - if cci.error() { - return Err(MailboxError::CommandError); - } - if cci.not_supported() { - return Err(MailboxError::NotSupported); - } - let mut message_in = [0u8; MESSAGE_IN_LEN]; - message_in.copy_from_slice(&bytes[MESSAGE_IN_OFFSET..MESSAGE_IN_OFFSET + MESSAGE_IN_LEN]); - Ok(Self { - version, - cci, - message_in, - }) +/// Validate the 48-byte mailbox header (length, VERSION, CCI status) and return +/// the CCI data-length field. +fn validate(bytes: &[u8]) -> Result { + if bytes.len() != MAILBOX_LEN { + return Err(MailboxError::WrongLength(bytes.len())); } - - /// UCSI version reported in the mailbox. - pub fn version(&self) -> UcsiVersion { - self.version + let version = u16::from_le_bytes([bytes[0], bytes[1]]); + if version != UCSI_VERSION_1_2 { + return Err(MailboxError::UnsupportedVersion(version)); } - - /// Raw CCI reported in the mailbox. - pub fn cci(&self) -> Cci { - self.cci + let cci = u32::from_le_bytes(bytes[4..8].try_into().expect("4-byte CCI slice")); + if cci & (1 << 31) == 0 { + return Err(MailboxError::NotComplete); } - - /// The first `expected` MESSAGE IN bytes, after checking CCI data length. - fn data(&self, expected: usize) -> Result<&[u8], MailboxError> { - let actual = self.cci.data_len() as usize; - if actual != expected { - return Err(MailboxError::UnexpectedDataLen { expected, actual }); - } - Ok(&self.message_in[..expected]) - } - - /// Decode a GET_CAPABILITY (16-byte) response. - pub fn capability(&self) -> Result { - let d = self.data(16)?; - let attributes = u32::from_le_bytes([d[0], d[1], d[2], d[3]]); - Ok(UcsiCapability { - num_connectors: d[4], - usb_pd_supported: attributes & (1 << 2) != 0, - bcd_pd_version: u16::from_le_bytes([d[12], d[13]]), - bcd_usb_type_c_version: u16::from_le_bytes([d[14], d[15]]), - }) + if cci & (1 << 30) != 0 { + return Err(MailboxError::CommandError); } - - /// Decode a GET_CONNECTOR_CAPABILITY (2-byte) response. - pub fn connector_capability(&self) -> Result { - let d = self.data(2)?; - let raw = u16::from_le_bytes([d[0], d[1]]); - let op = (raw & 0xff) as u8; - Ok(UcsiConnectorCapability { - operation_mode: OperationMode { - drp: op & (1 << 2) != 0, - usb2: op & (1 << 5) != 0, - usb3: op & (1 << 6) != 0, - }, - provider: raw & (1 << 8) != 0, - consumer: raw & (1 << 9) != 0, - }) + if cci & (1 << 25) != 0 { + return Err(MailboxError::NotSupported); } + Ok(((cci >> 8) & 0xff) as usize) +} - /// Decode a GET_CONNECTOR_STATUS (11-byte) response. - pub fn connector_status(&self) -> Result { - let d = self.data(11)?; - Ok(UcsiConnectorStatus { - connected: bit(d, 19), - power_direction: if bit(d, 20) { - PowerDirection::Source - } else { - PowerDirection::Sink - }, - partner_usb: bit(d, 21), - }) +/// Validate the header and return the first `expected` MESSAGE IN bytes. +fn message_in(bytes: &[u8], expected: usize) -> Result<&[u8], MailboxError> { + let actual = validate(bytes)?; + if actual != expected { + return Err(MailboxError::UnexpectedDataLen { expected, actual }); } + Ok(&bytes[MESSAGE_IN_OFFSET..MESSAGE_IN_OFFSET + expected]) } /// Read bit `index` from a little-endian byte slice (bit 0 = LSB of byte 0). @@ -309,6 +175,50 @@ fn bit(bytes: &[u8], index: usize) -> bool { (bytes[index / 8] >> (index % 8)) & 1 == 1 } +/// Validate a mailbox and return the UCSI version. +pub fn decode_version(bytes: &[u8]) -> Result { + validate(bytes)?; + Ok(UcsiVersion(u16::from_le_bytes([bytes[0], bytes[1]]))) +} + +/// Decode a GET_CAPABILITY (16-byte) response. +pub fn decode_capability(bytes: &[u8]) -> Result { + let d = message_in(bytes, 16)?; + Ok(UcsiCapability { + num_connectors: d[4], + usb_pd_supported: d[0] & (1 << 2) != 0, + bcd_pd_version: u16::from_le_bytes([d[12], d[13]]), + }) +} + +/// Decode a GET_CONNECTOR_CAPABILITY (2-byte) response. +pub fn decode_connector_capability(bytes: &[u8]) -> Result { + let d = message_in(bytes, 2)?; + let raw = u16::from_le_bytes([d[0], d[1]]); + let op = raw as u8; + Ok(UcsiConnectorCapability { + drp: op & (1 << 2) != 0, + usb2: op & (1 << 5) != 0, + usb3: op & (1 << 6) != 0, + provider: raw & (1 << 8) != 0, + consumer: raw & (1 << 9) != 0, + }) +} + +/// Decode a GET_CONNECTOR_STATUS (11-byte) response. +pub fn decode_connector_status(bytes: &[u8]) -> Result { + let d = message_in(bytes, 11)?; + Ok(UcsiConnectorStatus { + connected: bit(d, 19), + power_direction: if bit(d, 20) { + PowerDirection::Source + } else { + PowerDirection::Sink + }, + partner_usb: bit(d, 21), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -321,14 +231,12 @@ mod tests { /// Assemble a 48-byte mailbox from a CCI word and MESSAGE IN bytes. fn mailbox(cci: u32, message_in: &[u8]) -> [u8; MAILBOX_LEN] { let mut buf = [0u8; MAILBOX_LEN]; - buf[VERSION_OFFSET..VERSION_OFFSET + 2].copy_from_slice(&UCSI_VERSION_1_2.to_le_bytes()); - buf[CCI_OFFSET..CCI_OFFSET + 4].copy_from_slice(&cci.to_le_bytes()); + buf[0..2].copy_from_slice(&UCSI_VERSION_1_2.to_le_bytes()); + buf[4..8].copy_from_slice(&cci.to_le_bytes()); buf[MESSAGE_IN_OFFSET..MESSAGE_IN_OFFSET + message_in.len()].copy_from_slice(message_in); buf } - // ── control ─────────────────────────────────────────────────────────────── - #[test] fn control_places_opcode_and_connector() { let c = control(opcode::GET_CONNECTOR_STATUS, 1); @@ -338,128 +246,106 @@ mod tests { assert_eq!(&c[3..], &[0u8; 5]); } - // ── version / display ───────────────────────────────────────────────────── + // ── header validation boundaries ────────────────────────────────────────── #[test] - fn version_display_is_major_minor() { - assert_eq!(UcsiVersion(0x0120).to_string(), "1.2"); + fn rejects_wrong_length() { + assert_eq!(decode_version(&[0u8; 47]).unwrap_err(), MailboxError::WrongLength(47)); } - // ── decode: VERSION / CCI / length validation ───────────────────────────── - #[test] - fn decode_rejects_wrong_length() { - assert_eq!( - Mailbox::decode(&[0u8; 47]).unwrap_err(), - MailboxError::WrongLength { - expected: 48, - actual: 47 - } - ); - } - - #[test] - fn decode_rejects_unsupported_version() { + fn rejects_unsupported_version() { let mut buf = mailbox(cci_complete(16), &[]); buf[0..2].copy_from_slice(&0x0100u16.to_le_bytes()); assert_eq!( - Mailbox::decode(&buf).unwrap_err(), + decode_version(&buf).unwrap_err(), MailboxError::UnsupportedVersion(0x0100) ); } #[test] - fn decode_rejects_incomplete_cci() { - let buf = mailbox(0, &[]); - assert_eq!(Mailbox::decode(&buf).unwrap_err(), MailboxError::NotComplete); + fn rejects_incomplete_cci() { + assert_eq!(decode_version(&mailbox(0, &[])).unwrap_err(), MailboxError::NotComplete); } #[test] - fn decode_rejects_error_cci() { + fn rejects_error_cci() { let buf = mailbox((1 << 31) | (1 << 30), &[]); - assert_eq!(Mailbox::decode(&buf).unwrap_err(), MailboxError::CommandError); + assert_eq!(decode_version(&buf).unwrap_err(), MailboxError::CommandError); } #[test] - fn decode_rejects_not_supported_cci() { + fn rejects_not_supported_cci() { let buf = mailbox((1 << 31) | (1 << 25), &[]); - assert_eq!(Mailbox::decode(&buf).unwrap_err(), MailboxError::NotSupported); + assert_eq!(decode_version(&buf).unwrap_err(), MailboxError::NotSupported); + } + + #[test] + fn rejects_wrong_data_len() { + let buf = mailbox(cci_complete(2), &[0u8; 16]); + assert_eq!( + decode_capability(&buf).unwrap_err(), + MailboxError::UnexpectedDataLen { + expected: 16, + actual: 2 + } + ); } #[test] - fn decode_accepts_valid_header() { - let buf = mailbox(cci_complete(16), &[]); - let mb = Mailbox::decode(&buf).expect("valid mailbox"); - assert_eq!(mb.version(), UcsiVersion(0x0120)); - assert_eq!(mb.cci().data_len(), 16); + fn version_decodes_and_displays() { + assert_eq!( + decode_version(&mailbox(cci_complete(16), &[])).unwrap(), + UcsiVersion(0x0120) + ); + assert_eq!(UcsiVersion(0x0120).to_string(), "1.2"); } - // ── capability (16 bytes) ───────────────────────────────────────────────── + // ── field bit decode ────────────────────────────────────────────────────── #[test] fn capability_decodes_fixture() { - // attributes bit2 (USB PD), num_connectors=1, bcdPD=0x0300, bcdTypeC=0x0200. + // attributes bit2 (USB PD), num_connectors=1, bcdPD=0x0300. let mut msg = [0u8; 16]; msg[0] = 0b0000_0100; msg[4] = 1; msg[12..14].copy_from_slice(&0x0300u16.to_le_bytes()); - msg[14..16].copy_from_slice(&0x0200u16.to_le_bytes()); - let mb = Mailbox::decode(&mailbox(cci_complete(16), &msg)).unwrap(); + let cap = decode_capability(&mailbox(cci_complete(16), &msg)).unwrap(); assert_eq!( - mb.capability().unwrap(), + cap, UcsiCapability { num_connectors: 1, usb_pd_supported: true, bcd_pd_version: 0x0300, - bcd_usb_type_c_version: 0x0200, } ); } - #[test] - fn capability_rejects_wrong_data_len() { - let mb = Mailbox::decode(&mailbox(cci_complete(2), &[0u8; 16])).unwrap(); - assert_eq!( - mb.capability(), - Err(MailboxError::UnexpectedDataLen { - expected: 16, - actual: 2 - }) - ); - } - - // ── connector capability (2 bytes) ──────────────────────────────────────── - #[test] fn connector_capability_decodes_fixture() { // operation_mode = drp|usb2|usb3, provider + consumer. let op = (1 << 2) | (1 << 5) | (1 << 6); let raw: u16 = op | (1 << 8) | (1 << 9); - let mb = Mailbox::decode(&mailbox(cci_complete(2), &raw.to_le_bytes())).unwrap(); + let cap = decode_connector_capability(&mailbox(cci_complete(2), &raw.to_le_bytes())).unwrap(); assert_eq!( - mb.connector_capability().unwrap(), + cap, UcsiConnectorCapability { - operation_mode: OperationMode { - drp: true, - usb2: true, - usb3: true, - }, + drp: true, + usb2: true, + usb3: true, provider: true, consumer: true, } ); } - // ── connector status (11 bytes) ─────────────────────────────────────────── - #[test] fn connector_status_decodes_connected_sink() { // connect_status bit19, power_direction bit20=0 (sink), partner usb bit21. let mut msg = [0u8; 11]; - msg[2] = (1 << 3) | (1 << 5); // bit19 (connect) + bit21 (partner usb) - let mb = Mailbox::decode(&mailbox(cci_complete(11), &msg)).unwrap(); + msg[2] = (1 << 3) | (1 << 5); assert_eq!( - mb.connector_status().unwrap(), + decode_connector_status(&mailbox(cci_complete(11), &msg)).unwrap(), UcsiConnectorStatus { connected: true, power_direction: PowerDirection::Sink, @@ -472,7 +358,7 @@ mod tests { fn connector_status_decodes_source_direction() { let mut msg = [0u8; 11]; msg[2] = (1 << 3) | (1 << 4); // connect + power_direction=source (bit20) - let mb = Mailbox::decode(&mailbox(cci_complete(11), &msg)).unwrap(); - assert_eq!(mb.connector_status().unwrap().power_direction, PowerDirection::Source); + let status = decode_connector_status(&mailbox(cci_complete(11), &msg)).unwrap(); + assert_eq!(status.power_direction, PowerDirection::Source); } } diff --git a/ec/test-tui/src/battery.rs b/ec/test-tui/src/battery.rs index a5c913a..78bebeb 100644 --- a/ec/test-tui/src/battery.rs +++ b/ec/test-tui/src/battery.rs @@ -626,21 +626,6 @@ mod tests { ) -> color_eyre::Result { Err(eyre!("unused")) } - fn get_ucsi_version(&self) -> color_eyre::Result { - Err(eyre!("unused")) - } - fn get_ucsi_capability(&self) -> color_eyre::Result { - Err(eyre!("unused")) - } - fn get_ucsi_connector_capability( - &self, - _: u8, - ) -> color_eyre::Result { - Err(eyre!("unused")) - } - fn get_ucsi_connector_status(&self, _: u8) -> color_eyre::Result { - Err(eyre!("unused")) - } } struct ErrSource; @@ -696,21 +681,6 @@ mod tests { ) -> color_eyre::Result { Err(eyre!("unused")) } - fn get_ucsi_version(&self) -> color_eyre::Result { - Err(eyre!("unused")) - } - fn get_ucsi_capability(&self) -> color_eyre::Result { - Err(eyre!("unused")) - } - fn get_ucsi_connector_capability( - &self, - _: u8, - ) -> color_eyre::Result { - Err(eyre!("unused")) - } - fn get_ucsi_connector_status(&self, _: u8) -> color_eyre::Result { - Err(eyre!("unused")) - } } // ── poll_bst ───────────────────────────────────────────────────────────── diff --git a/ec/test-tui/src/source.rs b/ec/test-tui/src/source.rs index 46f0920..6aed095 100644 --- a/ec/test-tui/src/source.rs +++ b/ec/test-tui/src/source.rs @@ -13,6 +13,7 @@ use std::sync::Arc; use battery_service_interface::{BixFixedStrings, BstReturn}; use color_eyre::Result; +use color_eyre::eyre::eyre; use ec_test_lib::Threshold; use ec_test_lib::ucsi::{UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus, UcsiVersion}; use time_alarm_service_interface::{ @@ -45,11 +46,20 @@ pub(crate) trait DynSource: Send + Sync { fn get_expired_timer_wake_policy(&self, timer_id: AcpiTimerId) -> Result; fn get_timer_value(&self, timer_id: AcpiTimerId) -> Result; - // UCSI - fn get_ucsi_version(&self) -> Result; - fn get_ucsi_capability(&self) -> Result; - fn get_ucsi_connector_capability(&self, connector: u8) -> Result; - fn get_ucsi_connector_status(&self, connector: u8) -> Result; + // UCSI — default to unsupported so lightweight UI test doubles need not + // implement them; real sources override these via the blanket impl below. + fn get_ucsi_version(&self) -> Result { + Err(eyre!("UCSI not supported by this source")) + } + fn get_ucsi_capability(&self) -> Result { + Err(eyre!("UCSI not supported by this source")) + } + fn get_ucsi_connector_capability(&self, _connector: u8) -> Result { + Err(eyre!("UCSI not supported by this source")) + } + fn get_ucsi_connector_status(&self, _connector: u8) -> Result { + Err(eyre!("UCSI not supported by this source")) + } } // ── Blanket impl ───────────────────────────────────────────────────────────── diff --git a/ec/test-tui/src/thermal.rs b/ec/test-tui/src/thermal.rs index 8f434c1..b7b8aa2 100644 --- a/ec/test-tui/src/thermal.rs +++ b/ec/test-tui/src/thermal.rs @@ -587,21 +587,6 @@ mod tests { ) -> color_eyre::Result { Err(eyre!("unused")) } - fn get_ucsi_version(&self) -> color_eyre::Result { - Err(eyre!("unused")) - } - fn get_ucsi_capability(&self) -> color_eyre::Result { - Err(eyre!("unused")) - } - fn get_ucsi_connector_capability( - &self, - _: u8, - ) -> color_eyre::Result { - Err(eyre!("unused")) - } - fn get_ucsi_connector_status(&self, _: u8) -> color_eyre::Result { - Err(eyre!("unused")) - } } struct ErrThermal; @@ -657,21 +642,6 @@ mod tests { ) -> color_eyre::Result { Err(eyre!("unused")) } - fn get_ucsi_version(&self) -> color_eyre::Result { - Err(eyre!("unused")) - } - fn get_ucsi_capability(&self) -> color_eyre::Result { - Err(eyre!("unused")) - } - fn get_ucsi_connector_capability( - &self, - _: u8, - ) -> color_eyre::Result { - Err(eyre!("unused")) - } - fn get_ucsi_connector_status(&self, _: u8) -> color_eyre::Result { - Err(eyre!("unused")) - } } // ── SensorData ─────────────────────────────────────────────────────────── diff --git a/ec/test-tui/src/ucsi.rs b/ec/test-tui/src/ucsi.rs index 93e73bb..fc0fba8 100644 --- a/ec/test-tui/src/ucsi.rs +++ b/ec/test-tui/src/ucsi.rs @@ -1,15 +1,14 @@ use crate::common; use crate::common::SYMBOLS; use crate::state::{Fetched, UcsiState}; -use ec_test_lib::ucsi::{UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus, UcsiVersion}; +use ec_test_lib::ucsi::{UcsiConnectorCapability, UcsiConnectorStatus}; use ratatui::{ buffer::Buffer, crossterm::event::Event, - layout::{Constraint, Layout, Rect}, - prelude::*, - style::{Color, Style, Stylize, palette::tailwind}, - text::{Line, Span}, - widgets::{Block, Paragraph}, + layout::Rect, + style::{Color, palette::tailwind}, + text::Line, + widgets::{Block, Paragraph, Widget}, }; const LABEL_COLOR: Color = tailwind::CYAN.c300; @@ -25,247 +24,88 @@ impl Ucsi { pub(crate) fn handle_event(&mut self, _evt: &Event) {} pub(crate) fn render(&self, state: &UcsiState, area: Rect, buf: &mut Buffer) { - use Constraint::{Length, Min}; - - let is_healthy = matches!(state.version, Some(Ok(_))) && matches!(state.connector_status, Some(Ok(_))); - - let [version_area, bottom_area] = Layout::vertical([Length(4), Min(0)]).areas(area); - let [cap_area, conn_area] = - Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(bottom_area); - - let block = Block::bordered() - .title(common::status_title("USB-C (UCSI)", is_healthy)) - .border_style(tailwind::CYAN.c600); - let inner = block.inner(version_area); - block.render(version_area, buf); - Paragraph::new(vec![ - Line::from(Span::styled( - format!("UCSI {}", format_version(&state.version)), - Style::default().fg(Color::White).bold(), - )), - Line::from(format_capability_line(&state.capability)), - ]) - .render(inner, buf); - - self.render_connector_capability(state, cap_area, buf); - self.render_connector_status(state, conn_area, buf); + self.render_titled("USB-C (UCSI)", tailwind::CYAN.c600, state, area, buf); } pub(crate) fn render_card(&self, state: &UcsiState, area: Rect, buf: &mut Buffer) { - use Constraint::{Length, Min}; + self.render_titled("USB-C", tailwind::CYAN.c700, state, area, buf); + } + /// Both the tab and the dashboard card render the same metric rows inside a + /// bordered block; only the title and border colour differ. + fn render_titled(&self, title: &str, border: Color, state: &UcsiState, area: Rect, buf: &mut Buffer) { let is_healthy = matches!(state.connector_status, Some(Ok(_))); let block = Block::bordered() - .title(common::status_title("USB-C", is_healthy)) - .border_style(tailwind::CYAN.c700); + .title(common::status_title(title, is_healthy)) + .border_style(border); let inner = block.inner(area); block.render(area, buf); - - let [version_area, meta_area, status_area] = Layout::vertical([Length(1), Length(2), Min(0)]).areas(inner); - - Line::from(Span::styled( - format!("UCSI {}", format_version(&state.version)), - Style::default().fg(Color::White).bold(), - )) - .render(version_area, buf); - - Paragraph::new(vec![ - common::metric_row("Connectors", format_connectors(&state.capability), LABEL_COLOR), - common::metric_row( - "Conn 1", - format_connector_capability(&state.connector_capability), - LABEL_COLOR, - ), - ]) - .render(meta_area, buf); - - Paragraph::new(vec![common::metric_row( - "Status", - format_status(&state.connector_status), - LABEL_COLOR, - )]) - .render(status_area, buf); - } - - fn render_connector_capability(&self, state: &UcsiState, area: Rect, buf: &mut Buffer) { - let is_ok = matches!(state.connector_capability, Some(Ok(_))); - let lines: Vec> = match &state.connector_capability { - None => vec![Line::raw("Pending...")], - Some(Err(e)) => vec![Line::raw(format!("Error: {e}"))], - Some(Ok(cap)) => vec![ - Line::raw(format!("Modes: {}", format_operation_mode(cap))), - Line::raw(format!("Provider: {}", yes_no(cap.provider))), - Line::raw(format!("Consumer: {}", yes_no(cap.consumer))), - ], - }; - Paragraph::new(lines) - .block(common::title_block( - common::status_title("Connector 1 Capability", is_ok), - 0, - LABEL_COLOR, - )) - .render(area, buf); - } - - fn render_connector_status(&self, state: &UcsiState, area: Rect, buf: &mut Buffer) { - let is_ok = matches!(state.connector_status, Some(Ok(_))); - let lines: Vec> = match &state.connector_status { - None => vec![Line::raw("Pending...")], - Some(Err(e)) => vec![Line::raw(format!("Error: {e}"))], - Some(Ok(status)) => vec![ - Line::raw(format!("Attached: {}", yes_no(status.connected))), - Line::raw(format!("Direction: {}", status.power_direction)), - Line::raw(format!("Partner: {}", if status.partner_usb { "USB" } else { "-" })), - ], - }; - Paragraph::new(lines) - .block(common::title_block( - common::status_title("Connector 1 Status", is_ok), - 0, - LABEL_COLOR, - )) - .render(area, buf); + Paragraph::new(rows(state)).render(inner, buf); } } -// ── Formatting helpers ──────────────────────────────────────────────────────── +// ── Shared row/summary builder ──────────────────────────────────────────────── -fn yes_no(v: bool) -> &'static str { - if v { "Yes" } else { "No" } +fn rows(s: &UcsiState) -> Vec> { + vec![ + common::metric_row("Version", cell(&s.version, |v| v.to_string()), LABEL_COLOR), + common::metric_row("Capability", cell(&s.capability, capability_summary), LABEL_COLOR), + common::metric_row("Conn 1", cell(&s.connector_capability, connector_summary), LABEL_COLOR), + common::metric_row("Status", cell(&s.connector_status, status_summary), LABEL_COLOR), + ] } -fn format_version(version: &Fetched) -> String { - match version { +/// Render a fetched cell as honest pending / error / value text. +fn cell(fetched: &Fetched, f: impl FnOnce(&T) -> String) -> String { + match fetched { None => "Pending...".to_string(), - Some(Err(_)) => "Error".to_string(), - Some(Ok(v)) => v.to_string(), + Some(Err(e)) => format!("Error: {e}"), + Some(Ok(v)) => f(v), } } -fn format_connectors(capability: &Fetched) -> String { - match capability { - None => "Pending...".to_string(), - Some(Err(_)) => "Error".to_string(), - Some(Ok(cap)) => cap.num_connectors.to_string(), - } +fn capability_summary(cap: &ec_test_lib::ucsi::UcsiCapability) -> String { + format!( + "{} conn{} PD {:x}.{:02x}", + cap.num_connectors, + if cap.usb_pd_supported { " USB-PD" } else { "" }, + cap.bcd_pd_version >> 8, + cap.bcd_pd_version & 0xff, + ) } -fn format_capability_line(capability: &Fetched) -> Span<'static> { - let text = match capability { - None => "Pending...".to_string(), - Some(Err(e)) => format!("Error: {e}"), - Some(Ok(cap)) => format!( - "{} connector(s){} PD {:x}.{:02x}", - cap.num_connectors, - if cap.usb_pd_supported { " USB-PD" } else { "" }, - cap.bcd_pd_version >> 8, - cap.bcd_pd_version & 0xff, - ), - }; - Span::styled(text, Style::default().fg(tailwind::SLATE.c400)) -} - -fn format_operation_mode(cap: &UcsiConnectorCapability) -> String { +fn connector_summary(cap: &UcsiConnectorCapability) -> String { let mut modes = Vec::new(); - if cap.operation_mode.drp { + if cap.drp { modes.push("DRP"); } - if cap.operation_mode.usb2 { + if cap.usb2 { modes.push("USB2"); } - if cap.operation_mode.usb3 { + if cap.usb3 { modes.push("USB3"); } - if modes.is_empty() { + let roles = match (cap.provider, cap.consumer) { + (true, true) => "provider/consumer", + (true, false) => "provider", + (false, true) => "consumer", + (false, false) => "-", + }; + let modes = if modes.is_empty() { "none".to_string() } else { - modes.join(&format!(" {} ", SYMBOLS.mid_dot)) - } -} - -fn format_connector_capability(cap: &Fetched) -> String { - match cap { - None => "Pending...".to_string(), - Some(Err(_)) => "Error".to_string(), - Some(Ok(c)) => { - let roles = match (c.provider, c.consumer) { - (true, true) => "provider/consumer", - (true, false) => "provider", - (false, true) => "consumer", - (false, false) => "-", - }; - format!("{} {} {roles}", format_operation_mode(c), SYMBOLS.mid_dot) - } - } -} - -fn format_status(status: &Fetched) -> String { - match status { - None => "Pending...".to_string(), - Some(Err(_)) => "Error".to_string(), - Some(Ok(s)) => { - if !s.connected { - "Disconnected".to_string() - } else { - let partner = if s.partner_usb { "USB" } else { "partner" }; - format!( - "Connected {} {} {} {partner}", - SYMBOLS.mid_dot, s.power_direction, SYMBOLS.mid_dot - ) - } - } - } + modes.join("/") + }; + format!("{modes} {} {roles}", SYMBOLS.mid_dot) } -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use ec_test_lib::ucsi::{OperationMode, PowerDirection}; - - #[test] - fn status_connected_sink_summary() { - let status = Some(Ok(UcsiConnectorStatus { - connected: true, - power_direction: PowerDirection::Sink, - partner_usb: true, - })); - let s = format_status(&status); - assert!(s.contains("Connected"), "{s}"); - assert!(s.contains("Sink"), "{s}"); - assert!(s.contains("USB"), "{s}"); - } - - #[test] - fn status_disconnected_summary() { - let status = Some(Ok(UcsiConnectorStatus { - connected: false, - power_direction: PowerDirection::Source, - partner_usb: false, - })); - assert_eq!(format_status(&status), "Disconnected"); - } - - #[test] - fn status_pending_and_error() { - assert_eq!(format_status(&None), "Pending..."); - assert_eq!(format_status(&Some(Err(color_eyre::eyre::eyre!("x")))), "Error"); - } - - #[test] - fn operation_mode_lists_enabled_flags() { - let cap = UcsiConnectorCapability { - operation_mode: OperationMode { - drp: true, - usb2: true, - usb3: false, - }, - provider: true, - consumer: true, - }; - let s = format_operation_mode(&cap); - assert!(s.contains("DRP") && s.contains("USB2"), "{s}"); - assert!(!s.contains("USB3"), "{s}"); +fn status_summary(status: &UcsiConnectorStatus) -> String { + if !status.connected { + return "Disconnected".to_string(); } + let partner = if status.partner_usb { "USB" } else { "partner" }; + format!( + "Connected {} {} {} {partner}", + SYMBOLS.mid_dot, status.power_direction, SYMBOLS.mid_dot + ) } diff --git a/ec/test-tui/src/updater.rs b/ec/test-tui/src/updater.rs index 7a77514..183aadb 100644 --- a/ec/test-tui/src/updater.rs +++ b/ec/test-tui/src/updater.rs @@ -402,18 +402,13 @@ impl UcsiUpdater { #[tracing::instrument(skip_all)] fn update(&mut self) { + // Reads may be steadily unsupported (e.g. serial has no UCSI peer); the + // Fetched cells carry the error to the UI, so we don't warn every tick. let version = self.source.get_ucsi_version(); let capability = self.source.get_ucsi_capability(); let connector_capability = self.source.get_ucsi_connector_capability(UCSI_CONNECTOR); let connector_status = self.source.get_ucsi_connector_status(UCSI_CONNECTOR); - if let Err(ref e) = version { - warn!(error = %e, "failed to read UCSI version"); - } - if let Err(ref e) = connector_status { - warn!(error = %e, "failed to read UCSI connector status"); - } - let mut s = self.state.write().expect("state RwLock poisoned"); s.version = Some(version); s.capability = Some(capability); From 71893cb0dc405f91faafd64755a034864b2992eb Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Wed, 5 Aug 2026 19:08:01 +0000 Subject: [PATCH 5/6] Address UCSI host review feedback Keep mailbox VERSION reporting forward-compatible while retaining the UCSI 1.2 gate for command-specific payload decoders. Clarify connector and USB-PD capability text in the TUI. Assisted-by: GitHub Copilot:gpt-5.6-sol Copilot-Session: 6cb7060d-6d47-49ad-a710-428fe4c2f3dc --- ec/test-lib/src/ucsi.rs | 29 ++++++++++++++--------------- ec/test-tui/src/ucsi.rs | 27 +++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/ec/test-lib/src/ucsi.rs b/ec/test-lib/src/ucsi.rs index ead2c11..6070079 100644 --- a/ec/test-lib/src/ucsi.rs +++ b/ec/test-lib/src/ucsi.rs @@ -138,16 +138,13 @@ impl fmt::Display for MailboxError { impl std::error::Error for MailboxError {} -/// Validate the 48-byte mailbox header (length, VERSION, CCI status) and return +/// Validate the 48-byte mailbox length and CCI status, returning VERSION and /// the CCI data-length field. -fn validate(bytes: &[u8]) -> Result { +fn validate(bytes: &[u8]) -> Result<(u16, usize), MailboxError> { if bytes.len() != MAILBOX_LEN { return Err(MailboxError::WrongLength(bytes.len())); } let version = u16::from_le_bytes([bytes[0], bytes[1]]); - if version != UCSI_VERSION_1_2 { - return Err(MailboxError::UnsupportedVersion(version)); - } let cci = u32::from_le_bytes(bytes[4..8].try_into().expect("4-byte CCI slice")); if cci & (1 << 31) == 0 { return Err(MailboxError::NotComplete); @@ -158,12 +155,15 @@ fn validate(bytes: &[u8]) -> Result { if cci & (1 << 25) != 0 { return Err(MailboxError::NotSupported); } - Ok(((cci >> 8) & 0xff) as usize) + Ok((version, ((cci >> 8) & 0xff) as usize)) } /// Validate the header and return the first `expected` MESSAGE IN bytes. fn message_in(bytes: &[u8], expected: usize) -> Result<&[u8], MailboxError> { - let actual = validate(bytes)?; + let (version, actual) = validate(bytes)?; + if version != UCSI_VERSION_1_2 { + return Err(MailboxError::UnsupportedVersion(version)); + } if actual != expected { return Err(MailboxError::UnexpectedDataLen { expected, actual }); } @@ -177,8 +177,8 @@ fn bit(bytes: &[u8], index: usize) -> bool { /// Validate a mailbox and return the UCSI version. pub fn decode_version(bytes: &[u8]) -> Result { - validate(bytes)?; - Ok(UcsiVersion(u16::from_le_bytes([bytes[0], bytes[1]]))) + let (version, _) = validate(bytes)?; + Ok(UcsiVersion(version)) } /// Decode a GET_CAPABILITY (16-byte) response. @@ -254,11 +254,11 @@ mod tests { } #[test] - fn rejects_unsupported_version() { + fn command_decode_rejects_unsupported_version() { let mut buf = mailbox(cci_complete(16), &[]); buf[0..2].copy_from_slice(&0x0100u16.to_le_bytes()); assert_eq!( - decode_version(&buf).unwrap_err(), + decode_capability(&buf).unwrap_err(), MailboxError::UnsupportedVersion(0x0100) ); } @@ -294,10 +294,9 @@ mod tests { #[test] fn version_decodes_and_displays() { - assert_eq!( - decode_version(&mailbox(cci_complete(16), &[])).unwrap(), - UcsiVersion(0x0120) - ); + let mut buf = mailbox(cci_complete(16), &[]); + buf[0..2].copy_from_slice(&0x0200u16.to_le_bytes()); + assert_eq!(decode_version(&buf).unwrap(), UcsiVersion(0x0200)); assert_eq!(UcsiVersion(0x0120).to_string(), "1.2"); } diff --git a/ec/test-tui/src/ucsi.rs b/ec/test-tui/src/ucsi.rs index fc0fba8..78cf185 100644 --- a/ec/test-tui/src/ucsi.rs +++ b/ec/test-tui/src/ucsi.rs @@ -65,10 +65,17 @@ fn cell(fetched: &Fetched, f: impl FnOnce(&T) -> String) -> String { } fn capability_summary(cap: &ec_test_lib::ucsi::UcsiCapability) -> String { + let connector = if cap.num_connectors == 1 { + "connector" + } else { + "connectors" + }; + let pd_support = if cap.usb_pd_supported { "USB-PD" } else { "no USB-PD" }; format!( - "{} conn{} PD {:x}.{:02x}", + "{} {connector} {} {pd_support} {} PD {:x}.{:02x}", cap.num_connectors, - if cap.usb_pd_supported { " USB-PD" } else { "" }, + SYMBOLS.mid_dot, + SYMBOLS.mid_dot, cap.bcd_pd_version >> 8, cap.bcd_pd_version & 0xff, ) @@ -109,3 +116,19 @@ fn status_summary(status: &UcsiConnectorStatus) -> String { SYMBOLS.mid_dot, status.power_direction, SYMBOLS.mid_dot ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capability_summary_separates_connector_and_pd_details() { + let capability = ec_test_lib::ucsi::UcsiCapability { + num_connectors: 1, + usb_pd_supported: true, + bcd_pd_version: 0x0300, + }; + + assert_eq!(capability_summary(&capability), "1 connector · USB-PD · PD 3.00"); + } +} From 6ef05cb69d8cd42b923dd74bdde64398ded7dbe6 Mon Sep 17 00:00:00 2001 From: Dylan Knutson Date: Wed, 5 Aug 2026 19:52:51 +0000 Subject: [PATCH 6/6] Decode host UCSI with embedded-usb-pd types Replace the hand-rolled UCSI value types and manual response bit decoding with the upstream embedded-usb-pd v1.2 shapes, so the host slice shares one source of truth with the secure-world PPM stub. - Pin embedded-usb-pd at bc8c582 with the `ucsi-v1_2` feature and add a direct bincode 2.0.1 (default-features=false) dependency to decode the GET_CAPABILITY payload with fixed-int encoding. - ec/test-lib/src/ucsi.rs keeps only the 48-byte envelope validation (length, forward-compatible VERSION, CCI via LocalCci, data length) and the UcsiVersion newtype. UcsiCapability/UcsiConnectorCapability/ UcsiConnectorStatus/PowerDirection are now re-exports/aliases of the upstream response types; decode_* build them via bincode, From, and ResponseData::try_from([u8; 11]). Payload decode failures collapse to a single MailboxError::PayloadDecode variant, keeping bincode out of the public API. - control() takes the upstream CommandType; ACPI callers updated. - Mock, TUI rendering, and the updater test consume the upstream types directly; the TUI keeps its small projection (version, connector, connected sink status) and honest pending/error text. - cargo-vet: exempt the newly introduced bitfield 0.19.5, bitfield-macros 0.19.5, and syn 3.0.3 (bincode/embedded-usb-pd are covered by imported OpenDevicePartnership audits); the bincode RustSec ignore is retained. Assisted-by: GitHub Copilot:claude-opus-4.8 Copilot-Session: 6cb7060d-6d47-49ad-a710-428fe4c2f3dc --- common/supply-chain/config.toml | 12 ++ common/supply-chain/imports.lock | 22 ++- ec/Cargo.lock | 94 +++++++++--- ec/Cargo.toml | 4 + ec/test-lib/Cargo.toml | 4 + ec/test-lib/src/acpi.rs | 8 +- ec/test-lib/src/mock.rs | 42 ++++-- ec/test-lib/src/ucsi.rs | 243 ++++++++++++------------------- ec/test-tui/src/ucsi.rs | 60 +++++--- ec/test-tui/src/updater.rs | 7 +- 10 files changed, 289 insertions(+), 207 deletions(-) diff --git a/common/supply-chain/config.toml b/common/supply-chain/config.toml index 0c8d28e..c9f757c 100644 --- a/common/supply-chain/config.toml +++ b/common/supply-chain/config.toml @@ -54,6 +54,14 @@ criteria = "safe-to-deploy" version = "0.3.76" criteria = "safe-to-deploy" +[[exemptions.bitfield]] +version = "0.19.5" +criteria = "safe-to-deploy" + +[[exemptions.bitfield-macros]] +version = "0.19.5" +criteria = "safe-to-deploy" + [[exemptions.bitflags]] version = "2.11.0" criteria = "safe-to-deploy" @@ -302,6 +310,10 @@ criteria = "safe-to-deploy" version = "2.0.117" criteria = "safe-to-deploy" +[[exemptions.syn]] +version = "3.0.3" +criteria = "safe-to-deploy" + [[exemptions.sysinfo]] version = "0.33.1" criteria = "safe-to-deploy" diff --git a/common/supply-chain/imports.lock b/common/supply-chain/imports.lock index d37f192..a5a1247 100644 --- a/common/supply-chain/imports.lock +++ b/common/supply-chain/imports.lock @@ -28,6 +28,13 @@ criteria = "safe-to-deploy" version = "0.2.5" aggregated-from = "https://raw.githubusercontent.com/OpenDevicePartnership/mcxa-pac/refs/heads/main/supply-chain/audits.toml" +[[audits.OpenDevicePartnership.audits.bincode]] +who = "Jerry Xie " +criteria = "safe-to-deploy" +version = "2.0.1" +notes = "no_std binary serialization library. ~15 unsafe blocks for u8 type-specialization guarded by unty::type_equal and MaybeUninit patterns. No build script, no proc macros. std imports only for Encode/Decode trait impls. Assisted-by: copilot-cli:claude-opus-4.6" +aggregated-from = "https://raw.githubusercontent.com/OpenDevicePartnership/embedded-services/refs/heads/main/supply-chain/audits.toml" + [[audits.OpenDevicePartnership.audits.bitfield]] who = "Felipe Balbi " criteria = "safe-to-deploy" @@ -112,7 +119,10 @@ aggregated-from = "https://raw.githubusercontent.com/OpenDevicePartnership/embed who = "Felipe Balbi " criteria = "safe-to-deploy" version = "0.5.0" -aggregated-from = "https://raw.githubusercontent.com/OpenDevicePartnership/tps6699x/refs/heads/main/supply-chain/audits.toml" +aggregated-from = [ + "https://raw.githubusercontent.com/OpenDevicePartnership/tps6699x/refs/heads/main/supply-chain/audits.toml", + "https://raw.githubusercontent.com/OpenDevicePartnership/tps6699x/refs/heads/main/supply-chain/audits.toml", +] [[audits.OpenDevicePartnership.audits.embassy-time]] who = "Billy Price " @@ -365,8 +375,7 @@ aggregated-from = "https://raw.githubusercontent.com/OpenDevicePartnership/embed who = "Jerry Xie " criteria = "safe-to-deploy" delta = "1.21.3 -> 1.21.4" -notes = "Delta: soundness fix for OnceCell::wait under parking_lot feature. The wait() in imp_pl.rs now wraps parking_lot_core::park in a while loop re-checking COMPLETE state, preventing uninitialized memory observation when concurrent get_or_try_init panics. Remaining changes: doc cfg guards, doc typo fix, new tests. No new unsafe, no build script, no proc macros, no new imports. Assisted-by: copilot-chat:claude-opus-4.6" -aggregated-from = "https://raw.githubusercontent.com/OpenDevicePartnership/embedded-services/refs/heads/main/supply-chain/audits.toml" +aggregated-from = "https://raw.githubusercontent.com/OpenDevicePartnership/bq25723/refs/heads/main/supply-chain/audits.toml" [[audits.OpenDevicePartnership.audits.pin-project-lite]] who = "Jerry Xie " @@ -511,6 +520,13 @@ delta = "1.0.18 -> 1.0.24" notes = "Delta: Unicode 16->17 table data update only. No unsafe, no build.rs, no new deps. Assisted-by: GitHub Copilot:claude-opus-4.6" aggregated-from = "https://raw.githubusercontent.com/OpenDevicePartnership/embedded-services/refs/heads/main/supply-chain/audits.toml" +[[audits.OpenDevicePartnership.audits.unty]] +who = "Jerry Xie " +criteria = "safe-to-deploy" +version = "0.0.4" +notes = "Tiny no_std crate (1 file, ~120 LOC, zero deps). Two unsafe blocks: transmute_copy guarded by TypeId check in unty(), and a dtolnay-pattern transmute in non_static_type_id(). Both documented; no build script, no powerful imports. Assisted-by: copilot-cli:claude-opus-4.6" +aggregated-from = "https://raw.githubusercontent.com/OpenDevicePartnership/embedded-services/refs/heads/main/supply-chain/audits.toml" + [[audits.OpenDevicePartnership.audits.uuid]] who = "Jerry Xie " criteria = "safe-to-deploy" diff --git a/ec/Cargo.lock b/ec/Cargo.lock index e2fb021..0fc310f 100644 --- a/ec/Cargo.lock +++ b/ec/Cargo.lock @@ -130,6 +130,15 @@ dependencies = [ "num_enum", ] +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "unty", +] + [[package]] name = "bit-register" version = "0.1.0" @@ -158,6 +167,26 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f798d2d157e547aa99aab0967df39edd0b70307312b6f8bd2848e6abe40896e0" +[[package]] +name = "bitfield" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45721c9db4c7a20899d05efb7ad9235f50b256e980db30ffb229abf732934c3" +dependencies = [ + "bitfield-macros", +] + +[[package]] +name = "bitfield-macros" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cb6f3d4773a2107b94cbeccaa5b5f0b35a88389b5d522d13d659f64317b22d" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "bitfield-struct" version = "0.12.1" @@ -166,7 +195,7 @@ checksum = "8769c4854c5ada2852ddf6fd09d15cf43d4c2aaeccb4de6432f5402f08a6003b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -239,7 +268,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -414,7 +443,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -425,7 +454,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -465,8 +494,10 @@ version = "0.1.0" dependencies = [ "battery-service-interface", "battery-service-relay", + "bincode", "embedded-mcu-hal", "embedded-services", + "embedded-usb-pd", "mctp-rs", "num_enum", "scopeguard", @@ -711,6 +742,16 @@ dependencies = [ "serde", ] +[[package]] +name = "embedded-usb-pd" +version = "0.1.0" +source = "git+https://github.com/OpenDevicePartnership/embedded-usb-pd?rev=bc8c582395ee4861be95110d4097a0b39de01717#bc8c582395ee4861be95110d4097a0b39de01717" +dependencies = [ + "bincode", + "bitfield 0.19.5", + "embedded-hal-async", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -841,7 +882,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1078,7 +1119,7 @@ checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1334,7 +1375,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1457,7 +1498,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", ] [[package]] @@ -1469,7 +1510,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1481,7 +1522,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1501,6 +1542,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sysinfo" version = "0.33.1" @@ -1553,7 +1605,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1636,7 +1688,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1658,7 +1710,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1765,6 +1817,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1885,7 +1943,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1896,7 +1954,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1907,7 +1965,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1918,7 +1976,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2063,5 +2121,5 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] diff --git a/ec/Cargo.toml b/ec/Cargo.toml index 895e41a..74c3284 100644 --- a/ec/Cargo.toml +++ b/ec/Cargo.toml @@ -27,6 +27,10 @@ battery-service-relay = { git = "https://github.com/OpenDevicePartnership/embedd thermal-service-relay = { git = "https://github.com/OpenDevicePartnership/embedded-services", branch = "main" } embedded-services = { git = "https://github.com/OpenDevicePartnership/embedded-services", branch = "main" } mctp-rs = { git = "https://github.com/OpenDevicePartnership/embedded-services", branch = "main", features = ["serial"] } +embedded-usb-pd = { git = "https://github.com/OpenDevicePartnership/embedded-usb-pd", rev = "bc8c582395ee4861be95110d4097a0b39de01717", default-features = false, features = [ + "ucsi-v1_2", +] } +bincode = { version = "2.0.1", default-features = false } embedded-mcu-hal = "0.3.0" clap = { version = "4.6.0", features = ["derive"] } diff --git a/ec/test-lib/Cargo.toml b/ec/test-lib/Cargo.toml index e3f2dfd..4561a70 100644 --- a/ec/test-lib/Cargo.toml +++ b/ec/test-lib/Cargo.toml @@ -16,6 +16,10 @@ battery-service-relay.workspace = true embedded-mcu-hal = { workspace = true } +# UCSI wire types (host-side decode + fixtures) +embedded-usb-pd.workspace = true +bincode.workspace = true + # ACPI specific num_enum = { version = "0.7.5", default-features = false } windows = { version = "0.58", features = [ diff --git a/ec/test-lib/src/acpi.rs b/ec/test-lib/src/acpi.rs index 67d008a..965c432 100644 --- a/ec/test-lib/src/acpi.rs +++ b/ec/test-lib/src/acpi.rs @@ -724,22 +724,22 @@ impl Acpi { impl UcsiSource for Acpi { fn get_version(&self) -> Result { - let mailbox = self.ucsi_command(ucsi::control(ucsi::opcode::GET_CAPABILITY, 0))?; + let mailbox = self.ucsi_command(ucsi::control(ucsi::CommandType::GetCapability, 0))?; Ok(ucsi::decode_version(&mailbox)?) } fn get_capability(&self) -> Result { - let mailbox = self.ucsi_command(ucsi::control(ucsi::opcode::GET_CAPABILITY, 0))?; + let mailbox = self.ucsi_command(ucsi::control(ucsi::CommandType::GetCapability, 0))?; Ok(ucsi::decode_capability(&mailbox)?) } fn get_connector_capability(&self, connector: u8) -> Result { - let mailbox = self.ucsi_command(ucsi::control(ucsi::opcode::GET_CONNECTOR_CAPABILITY, connector))?; + let mailbox = self.ucsi_command(ucsi::control(ucsi::CommandType::GetConnectorCapability, connector))?; Ok(ucsi::decode_connector_capability(&mailbox)?) } fn get_connector_status(&self, connector: u8) -> Result { - let mailbox = self.ucsi_command(ucsi::control(ucsi::opcode::GET_CONNECTOR_STATUS, connector))?; + let mailbox = self.ucsi_command(ucsi::control(ucsi::CommandType::GetConnectorStatus, connector))?; Ok(ucsi::decode_connector_status(&mailbox)?) } } diff --git a/ec/test-lib/src/mock.rs b/ec/test-lib/src/mock.rs index 133fa04..35576b3 100644 --- a/ec/test-lib/src/mock.rs +++ b/ec/test-lib/src/mock.rs @@ -4,6 +4,9 @@ use battery_service_interface::{ BatteryState, BatterySwapCapability, BatteryTechnology, BixFixedStrings, BstReturn, PowerUnit, }; use embedded_mcu_hal::time::{Datetime, DatetimeFields, Month}; +use embedded_usb_pd::ucsi::v1_2::lpm::get_connector_capability::OperationModeFlags; +use embedded_usb_pd::ucsi::v1_2::lpm::get_connector_status::{ConnectedStatus, ConnectorPartnerFlags}; +use embedded_usb_pd::ucsi::v1_2::ppm::get_capability::Attributes; use std::sync::Mutex; use std::time::Instant; use time_alarm_service_interface::{ @@ -451,26 +454,41 @@ impl UcsiSource for Mock { Ok(UcsiVersion(0x0120)) } fn get_capability(&self) -> Result { + let mut attributes = Attributes::default(); + attributes.set_battery_charging(true).set_usb_power_delivery(true); Ok(UcsiCapability { + attributes, num_connectors: 1, - usb_pd_supported: true, - bcd_pd_version: 0x0300, + optional_features: Default::default(), + num_alt_modes: 0, + bcd_battery_charging_spec: 0x0120, + bcd_usb_pd_spec: 0x0300, + bcd_type_c_spec: 0x0200, }) } fn get_connector_capability(&self, _connector: u8) -> Result { - Ok(UcsiConnectorCapability { - drp: true, - usb2: true, - usb3: true, - provider: true, - consumer: true, - }) + let mut cap = UcsiConnectorCapability::default(); + cap.set_operation_mode( + *OperationModeFlags::default() + .set_drp(true) + .set_usb2(true) + .set_usb3(true), + ) + .set_provider(true) + .set_consumer(true); + Ok(cap) } fn get_connector_status(&self, _connector: u8) -> Result { + let mut partner_flags = ConnectorPartnerFlags::default(); + partner_flags.set_usb(true); Ok(UcsiConnectorStatus { - connected: true, - power_direction: PowerDirection::Sink, - partner_usb: true, + status_change: Default::default(), + connect_status: true, + status: Some(ConnectedStatus { + power_direction: PowerDirection::Sink, + partner_flags, + ..Default::default() + }), }) } } diff --git a/ec/test-lib/src/ucsi.rs b/ec/test-lib/src/ucsi.rs index 6070079..de1ae78 100644 --- a/ec/test-lib/src/ucsi.rs +++ b/ec/test-lib/src/ucsi.rs @@ -1,11 +1,29 @@ -//! Host-side UCSI value types and platform-neutral mailbox decoding. +//! Host-side UCSI mailbox envelope decoding. //! -//! The UCSI shared mailbox is 48 bytes; the PPM fills VERSION, CCI and the -//! MESSAGE IN payload. Decoding lives here — not in the Windows-only [`crate::acpi`] -//! backend — so it can be unit-tested on any host. +//! Only the 48-byte mailbox envelope — length, VERSION, CCI, and data length — +//! is validated here. The command-specific response payloads are decoded with +//! the upstream [`embedded_usb_pd`] UCSI v1.2 types, which are re-exported below +//! so callers consume the upstream shapes directly. Decoding lives here (not in +//! the Windows-only [`crate::acpi`] backend) so it can be unit-tested on any host. use std::fmt; +use embedded_usb_pd::ucsi::v1_2::cci::LocalCci; +use embedded_usb_pd::ucsi::v1_2::lpm::{get_connector_capability, get_connector_status}; +use embedded_usb_pd::ucsi::v1_2::ppm::get_capability; + +/// Power direction of a connected connector. +pub use embedded_usb_pd::PowerRole as PowerDirection; +/// UCSI command opcode selector (re-exported for [`control`] and ACPI callers). +pub use embedded_usb_pd::ucsi::v1_2::CommandType; + +/// PPM capabilities (GET_CAPABILITY response). +pub type UcsiCapability = get_capability::ResponseData; +/// Per-connector capabilities (GET_CONNECTOR_CAPABILITY response). +pub type UcsiConnectorCapability = get_connector_capability::ResponseData; +/// Connector status (GET_CONNECTOR_STATUS response). +pub type UcsiConnectorStatus = get_connector_status::ResponseData; + /// Length of the UCSI CONTROL field the OS writes to issue a command. pub const CONTROL_LEN: usize = 8; @@ -13,23 +31,13 @@ const MAILBOX_LEN: usize = 48; const UCSI_VERSION_1_2: u16 = 0x0120; const MESSAGE_IN_OFFSET: usize = 16; -/// UCSI command opcodes for the host read surface. -pub mod opcode { - /// GET_CAPABILITY (PPM capabilities, 16-byte response). - pub const GET_CAPABILITY: u8 = 0x06; - /// GET_CONNECTOR_CAPABILITY (per-connector, 2-byte response). - pub const GET_CONNECTOR_CAPABILITY: u8 = 0x07; - /// GET_CONNECTOR_STATUS (per-connector, 11-byte response). - pub const GET_CONNECTOR_STATUS: u8 = 0x12; -} - /// Build an 8-byte CONTROL buffer: byte 0 = opcode, byte 2 = connector number. /// /// Matches the UCSI command header (opcode, data-length=0) followed by the /// LPM connector number in the command-specific field. -pub fn control(opcode: u8, connector: u8) -> [u8; CONTROL_LEN] { +pub fn control(command: CommandType, connector: u8) -> [u8; CONTROL_LEN] { let mut buf = [0u8; CONTROL_LEN]; - buf[0] = opcode; + buf[0] = command as u8; buf[2] = connector; buf } @@ -44,61 +52,6 @@ impl fmt::Display for UcsiVersion { } } -/// PPM capabilities (GET_CAPABILITY response, subset used by the host UI). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct UcsiCapability { - /// Number of connectors managed by the PPM. - pub num_connectors: u8, - /// PPM supports the USB Power Delivery specification. - pub usb_pd_supported: bool, - /// BCD-coded USB PD spec version. - pub bcd_pd_version: u16, -} - -/// Per-connector capabilities (GET_CONNECTOR_CAPABILITY response). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct UcsiConnectorCapability { - /// Dual-role port. - pub drp: bool, - /// USB 2.0 capable. - pub usb2: bool, - /// USB 3.x capable. - pub usb3: bool, - /// Connector can act as a power provider (source). - pub provider: bool, - /// Connector can act as a power consumer (sink). - pub consumer: bool, -} - -/// Power direction of a connected connector. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PowerDirection { - /// Consuming power (sink). - Sink, - /// Providing power (source). - Source, -} - -impl fmt::Display for PowerDirection { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Sink => write!(f, "Sink"), - Self::Source => write!(f, "Source"), - } - } -} - -/// Connector status (GET_CONNECTOR_STATUS response, subset used by the host UI). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct UcsiConnectorStatus { - /// A partner is attached. - pub connected: bool, - /// Current power direction. - pub power_direction: PowerDirection, - /// Partner is a USB device. - pub partner_usb: bool, -} - /// Error decoding a UCSI mailbox response. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MailboxError { @@ -119,6 +72,8 @@ pub enum MailboxError { /// Actual data length reported in CCI. actual: usize, }, + /// The command-specific response payload failed to decode. + PayloadDecode, } impl fmt::Display for MailboxError { @@ -132,6 +87,7 @@ impl fmt::Display for MailboxError { Self::UnexpectedDataLen { expected, actual } => { write!(f, "CCI data length {actual}, expected {expected}") } + Self::PayloadDecode => write!(f, "malformed UCSI response payload"), } } } @@ -145,20 +101,21 @@ fn validate(bytes: &[u8]) -> Result<(u16, usize), MailboxError> { return Err(MailboxError::WrongLength(bytes.len())); } let version = u16::from_le_bytes([bytes[0], bytes[1]]); - let cci = u32::from_le_bytes(bytes[4..8].try_into().expect("4-byte CCI slice")); - if cci & (1 << 31) == 0 { + let cci = LocalCci::from(u32::from_le_bytes(bytes[4..8].try_into().expect("4-byte CCI slice"))); + if !cci.cmd_complete() { return Err(MailboxError::NotComplete); } - if cci & (1 << 30) != 0 { + if cci.error() { return Err(MailboxError::CommandError); } - if cci & (1 << 25) != 0 { + if cci.not_supported() { return Err(MailboxError::NotSupported); } - Ok((version, ((cci >> 8) & 0xff) as usize)) + Ok((version, cci.data_len())) } -/// Validate the header and return the first `expected` MESSAGE IN bytes. +/// Validate the header (enforcing UCSI 1.2 for command payloads) and return the +/// first `expected` MESSAGE IN bytes. fn message_in(bytes: &[u8], expected: usize) -> Result<&[u8], MailboxError> { let (version, actual) = validate(bytes)?; if version != UCSI_VERSION_1_2 { @@ -170,53 +127,39 @@ fn message_in(bytes: &[u8], expected: usize) -> Result<&[u8], MailboxError> { Ok(&bytes[MESSAGE_IN_OFFSET..MESSAGE_IN_OFFSET + expected]) } -/// Read bit `index` from a little-endian byte slice (bit 0 = LSB of byte 0). -fn bit(bytes: &[u8], index: usize) -> bool { - (bytes[index / 8] >> (index % 8)) & 1 == 1 -} - -/// Validate a mailbox and return the UCSI version. +/// Validate a mailbox and return the reported UCSI version. +/// +/// Version reporting is forward-compatible: any completed mailbox yields its +/// VERSION word, while the command-specific decoders below still gate on 1.2. pub fn decode_version(bytes: &[u8]) -> Result { let (version, _) = validate(bytes)?; Ok(UcsiVersion(version)) } -/// Decode a GET_CAPABILITY (16-byte) response. +/// Decode a GET_CAPABILITY response. pub fn decode_capability(bytes: &[u8]) -> Result { - let d = message_in(bytes, 16)?; - Ok(UcsiCapability { - num_connectors: d[4], - usb_pd_supported: d[0] & (1 << 2) != 0, - bcd_pd_version: u16::from_le_bytes([d[12], d[13]]), - }) + let payload = message_in(bytes, get_capability::RESPONSE_DATA_LEN)?; + let (data, _): (UcsiCapability, usize) = + bincode::decode_from_slice(payload, bincode::config::standard().with_fixed_int_encoding()) + .map_err(|_| MailboxError::PayloadDecode)?; + Ok(data) } -/// Decode a GET_CONNECTOR_CAPABILITY (2-byte) response. +/// Decode a GET_CONNECTOR_CAPABILITY response. pub fn decode_connector_capability(bytes: &[u8]) -> Result { - let d = message_in(bytes, 2)?; - let raw = u16::from_le_bytes([d[0], d[1]]); - let op = raw as u8; - Ok(UcsiConnectorCapability { - drp: op & (1 << 2) != 0, - usb2: op & (1 << 5) != 0, - usb3: op & (1 << 6) != 0, - provider: raw & (1 << 8) != 0, - consumer: raw & (1 << 9) != 0, - }) + let payload = message_in(bytes, get_connector_capability::RESPONSE_DATA_LEN)?; + Ok(UcsiConnectorCapability::from(u16::from_le_bytes([ + payload[0], payload[1], + ]))) } -/// Decode a GET_CONNECTOR_STATUS (11-byte) response. +/// Decode a GET_CONNECTOR_STATUS response. pub fn decode_connector_status(bytes: &[u8]) -> Result { - let d = message_in(bytes, 11)?; - Ok(UcsiConnectorStatus { - connected: bit(d, 19), - power_direction: if bit(d, 20) { - PowerDirection::Source - } else { - PowerDirection::Sink - }, - partner_usb: bit(d, 21), - }) + let payload = message_in(bytes, get_connector_status::RESPONSE_DATA_LEN)?; + let raw: [u8; get_connector_status::RESPONSE_DATA_LEN] = payload + .try_into() + .expect("message_in returns exactly the requested length"); + UcsiConnectorStatus::try_from(raw).map_err(|_| MailboxError::PayloadDecode) } #[cfg(test)] @@ -239,14 +182,14 @@ mod tests { #[test] fn control_places_opcode_and_connector() { - let c = control(opcode::GET_CONNECTOR_STATUS, 1); + let c = control(CommandType::GetConnectorStatus, 1); assert_eq!(c[0], 0x12); assert_eq!(c[1], 0x00); assert_eq!(c[2], 0x01); assert_eq!(&c[3..], &[0u8; 5]); } - // ── header validation boundaries ────────────────────────────────────────── + // ── envelope validation boundaries ──────────────────────────────────────── #[test] fn rejects_wrong_length() { @@ -293,71 +236,75 @@ mod tests { } #[test] - fn version_decodes_and_displays() { + fn version_decodes_forward_compatibly_and_displays() { let mut buf = mailbox(cci_complete(16), &[]); buf[0..2].copy_from_slice(&0x0200u16.to_le_bytes()); assert_eq!(decode_version(&buf).unwrap(), UcsiVersion(0x0200)); assert_eq!(UcsiVersion(0x0120).to_string(), "1.2"); } - // ── field bit decode ────────────────────────────────────────────────────── + #[test] + fn connector_status_payload_error_maps_to_decode() { + // connect_status set but power-operation-mode = 0 is an invalid variant. + let mut msg = [0u8; 11]; + msg[2] = 1 << 3; // connect_status (bit 19), power_op_mode left 0 + assert_eq!( + decode_connector_status(&mailbox(cci_complete(11), &msg)).unwrap_err(), + MailboxError::PayloadDecode + ); + } + + // ── envelope → upstream payload decode ──────────────────────────────────── #[test] - fn capability_decodes_fixture() { + fn capability_decodes_into_upstream_type() { // attributes bit2 (USB PD), num_connectors=1, bcdPD=0x0300. let mut msg = [0u8; 16]; msg[0] = 0b0000_0100; msg[4] = 1; msg[12..14].copy_from_slice(&0x0300u16.to_le_bytes()); let cap = decode_capability(&mailbox(cci_complete(16), &msg)).unwrap(); - assert_eq!( - cap, - UcsiCapability { - num_connectors: 1, - usb_pd_supported: true, - bcd_pd_version: 0x0300, - } - ); + assert_eq!(cap.num_connectors, 1); + assert!(cap.attributes.usb_power_delivery()); + assert_eq!(cap.bcd_usb_pd_spec, 0x0300); } #[test] - fn connector_capability_decodes_fixture() { + fn connector_capability_decodes_into_upstream_type() { // operation_mode = drp|usb2|usb3, provider + consumer. let op = (1 << 2) | (1 << 5) | (1 << 6); let raw: u16 = op | (1 << 8) | (1 << 9); let cap = decode_connector_capability(&mailbox(cci_complete(2), &raw.to_le_bytes())).unwrap(); - assert_eq!( - cap, - UcsiConnectorCapability { - drp: true, - usb2: true, - usb3: true, - provider: true, - consumer: true, - } - ); + assert!(cap.operation_mode().drp()); + assert!(cap.operation_mode().usb2()); + assert!(cap.operation_mode().usb3()); + assert!(cap.provider()); + assert!(cap.consumer()); } #[test] fn connector_status_decodes_connected_sink() { - // connect_status bit19, power_direction bit20=0 (sink), partner usb bit21. + // bit19 connect, bit20=0 sink, partner usb (bit21); power_op_mode=1, + // partner_type=1 so the upstream decoder accepts the connected payload. let mut msg = [0u8; 11]; - msg[2] = (1 << 3) | (1 << 5); - assert_eq!( - decode_connector_status(&mailbox(cci_complete(11), &msg)).unwrap(), - UcsiConnectorStatus { - connected: true, - power_direction: PowerDirection::Sink, - partner_usb: true, - } - ); + msg[2] = 0x01 | (1 << 3) | (1 << 5); // power_op_mode=1 + connect + partner usb + msg[3] = 1 << 5; // partner_type = 1 (bits 29..31) + let status = decode_connector_status(&mailbox(cci_complete(11), &msg)).unwrap(); + assert!(status.connect_status); + let connected = status.status.expect("connected payload present"); + assert_eq!(connected.power_direction, PowerDirection::Sink); + assert!(connected.partner_flags.usb()); } #[test] fn connector_status_decodes_source_direction() { let mut msg = [0u8; 11]; - msg[2] = (1 << 3) | (1 << 4); // connect + power_direction=source (bit20) + msg[2] = 0x01 | (1 << 3) | (1 << 4); // power_op_mode=1 + connect + source (bit20) + msg[3] = 1 << 5; // partner_type = 1 let status = decode_connector_status(&mailbox(cci_complete(11), &msg)).unwrap(); - assert_eq!(status.power_direction, PowerDirection::Source); + assert_eq!( + status.status.expect("connected payload present").power_direction, + PowerDirection::Source + ); } } diff --git a/ec/test-tui/src/ucsi.rs b/ec/test-tui/src/ucsi.rs index 78cf185..7590a8c 100644 --- a/ec/test-tui/src/ucsi.rs +++ b/ec/test-tui/src/ucsi.rs @@ -1,7 +1,7 @@ use crate::common; use crate::common::SYMBOLS; use crate::state::{Fetched, UcsiState}; -use ec_test_lib::ucsi::{UcsiConnectorCapability, UcsiConnectorStatus}; +use ec_test_lib::ucsi::{PowerDirection, UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus}; use ratatui::{ buffer::Buffer, crossterm::event::Event, @@ -64,35 +64,40 @@ fn cell(fetched: &Fetched, f: impl FnOnce(&T) -> String) -> String { } } -fn capability_summary(cap: &ec_test_lib::ucsi::UcsiCapability) -> String { +fn capability_summary(cap: &UcsiCapability) -> String { let connector = if cap.num_connectors == 1 { "connector" } else { "connectors" }; - let pd_support = if cap.usb_pd_supported { "USB-PD" } else { "no USB-PD" }; + let pd_support = if cap.attributes.usb_power_delivery() { + "USB-PD" + } else { + "no USB-PD" + }; format!( "{} {connector} {} {pd_support} {} PD {:x}.{:02x}", cap.num_connectors, SYMBOLS.mid_dot, SYMBOLS.mid_dot, - cap.bcd_pd_version >> 8, - cap.bcd_pd_version & 0xff, + cap.bcd_usb_pd_spec >> 8, + cap.bcd_usb_pd_spec & 0xff, ) } fn connector_summary(cap: &UcsiConnectorCapability) -> String { + let modes_flags = cap.operation_mode(); let mut modes = Vec::new(); - if cap.drp { + if modes_flags.drp() { modes.push("DRP"); } - if cap.usb2 { + if modes_flags.usb2() { modes.push("USB2"); } - if cap.usb3 { + if modes_flags.usb3() { modes.push("USB3"); } - let roles = match (cap.provider, cap.consumer) { + let roles = match (cap.provider(), cap.consumer()) { (true, true) => "provider/consumer", (true, false) => "provider", (false, true) => "consumer", @@ -107,28 +112,45 @@ fn connector_summary(cap: &UcsiConnectorCapability) -> String { } fn status_summary(status: &UcsiConnectorStatus) -> String { - if !status.connected { + if !status.connect_status { return "Disconnected".to_string(); } - let partner = if status.partner_usb { "USB" } else { "partner" }; + let (direction, partner) = match &status.status { + Some(connected) => { + let direction = match connected.power_direction { + PowerDirection::Sink => "Sink", + PowerDirection::Source => "Source", + }; + let partner = if connected.partner_flags.usb() { + "USB" + } else { + "partner" + }; + (direction, partner) + } + None => ("?", "partner"), + }; format!( - "Connected {} {} {} {partner}", - SYMBOLS.mid_dot, status.power_direction, SYMBOLS.mid_dot + "Connected {} {direction} {} {partner}", + SYMBOLS.mid_dot, SYMBOLS.mid_dot ) } #[cfg(test)] mod tests { use super::*; + use ec_test_lib::UcsiSource; + use ec_test_lib::mock::Mock; #[test] fn capability_summary_separates_connector_and_pd_details() { - let capability = ec_test_lib::ucsi::UcsiCapability { - num_connectors: 1, - usb_pd_supported: true, - bcd_pd_version: 0x0300, - }; + let cap = Mock::default().get_capability().unwrap(); + assert_eq!(capability_summary(&cap), "1 connector · USB-PD · PD 3.00"); + } - assert_eq!(capability_summary(&capability), "1 connector · USB-PD · PD 3.00"); + #[test] + fn status_summary_renders_connected_sink() { + let status = Mock::default().get_connector_status(1).unwrap(); + assert_eq!(status_summary(&status), "Connected · Sink · USB"); } } diff --git a/ec/test-tui/src/updater.rs b/ec/test-tui/src/updater.rs index 183aadb..dde62d2 100644 --- a/ec/test-tui/src/updater.rs +++ b/ec/test-tui/src/updater.rs @@ -443,9 +443,10 @@ mod ucsi_tests { let s = state.read().unwrap(); assert_eq!(s.version.as_ref().unwrap().as_ref().unwrap(), &UcsiVersion(0x0120)); assert_eq!(s.capability.as_ref().unwrap().as_ref().unwrap().num_connectors, 1); - assert!(s.connector_capability.as_ref().unwrap().as_ref().unwrap().provider); + assert!(s.connector_capability.as_ref().unwrap().as_ref().unwrap().provider()); let status = s.connector_status.as_ref().unwrap().as_ref().unwrap(); - assert!(status.connected); - assert_eq!(status.power_direction, PowerDirection::Sink); + assert!(status.connect_status); + let connected = status.status.expect("connected payload present"); + assert_eq!(connected.power_direction, PowerDirection::Sink); } }