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]] 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", ] diff --git a/ec/test-lib/src/acpi.rs b/ec/test-lib/src/acpi.rs index 3b52bdf..67d008a 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, 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 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); + } + let arg = output.arg(0)?; + if arg.type_ != AcpiArgumentType::Buffer as u16 { + return Err(Error::UnexpectedArgumentType(arg.type_)); + } + 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(ucsi::decode_version(&mailbox)?) + } + + fn get_capability(&self) -> Result { + let mailbox = self.ucsi_command(ucsi::control(ucsi::opcode::GET_CAPABILITY, 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))?; + 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(ucsi::decode_connector_status(&mailbox)?) + } +} 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..133fa04 100644 --- a/ec/test-lib/src/mock.rs +++ b/ec/test-lib/src/mock.rs @@ -1,4 +1,5 @@ -use crate::{BatterySource, ErrorType, RtcSource, ThermalSource, Threshold}; +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, }; @@ -444,3 +445,32 @@ 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, + }) + } + fn get_connector_capability(&self, _connector: u8) -> Result { + Ok(UcsiConnectorCapability { + 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, + }) + } +} 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..ead2c11 --- /dev/null +++ b/ec/test-lib/src/ucsi.rs @@ -0,0 +1,364 @@ +//! 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; + +/// Length of the UCSI CONTROL field the OS writes to issue a command. +pub const CONTROL_LEN: usize = 8; + +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] { + 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 fmt::Display for UcsiVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}", self.0 >> 8, (self.0 >> 4) & 0xf) + } +} + +/// 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 { + /// 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. + 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(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"), + 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 {} + +/// 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())); + } + 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); + } + if cci & (1 << 30) != 0 { + return Err(MailboxError::CommandError); + } + if cci & (1 << 25) != 0 { + return Err(MailboxError::NotSupported); + } + Ok(((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)?; + 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). +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::*; + + /// 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[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 + } + + #[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]); + } + + // ── header validation boundaries ────────────────────────────────────────── + + #[test] + fn rejects_wrong_length() { + assert_eq!(decode_version(&[0u8; 47]).unwrap_err(), MailboxError::WrongLength(47)); + } + + #[test] + fn 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(), + MailboxError::UnsupportedVersion(0x0100) + ); + } + + #[test] + fn rejects_incomplete_cci() { + assert_eq!(decode_version(&mailbox(0, &[])).unwrap_err(), MailboxError::NotComplete); + } + + #[test] + fn rejects_error_cci() { + let buf = mailbox((1 << 31) | (1 << 30), &[]); + assert_eq!(decode_version(&buf).unwrap_err(), MailboxError::CommandError); + } + + #[test] + fn rejects_not_supported_cci() { + let buf = mailbox((1 << 31) | (1 << 25), &[]); + 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 version_decodes_and_displays() { + assert_eq!( + decode_version(&mailbox(cci_complete(16), &[])).unwrap(), + UcsiVersion(0x0120) + ); + assert_eq!(UcsiVersion(0x0120).to_string(), "1.2"); + } + + // ── field bit decode ────────────────────────────────────────────────────── + + #[test] + fn capability_decodes_fixture() { + // 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, + } + ); + } + + #[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 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, + } + ); + } + + #[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); + assert_eq!( + decode_connector_status(&mailbox(cci_complete(11), &msg)).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 status = decode_connector_status(&mailbox(cci_complete(11), &msg)).unwrap(); + assert_eq!(status.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/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..6aed095 100644 --- a/ec/test-tui/src/source.rs +++ b/ec/test-tui/src/source.rs @@ -13,7 +13,9 @@ 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::{ AcpiTimerId, AcpiTimestamp, AlarmExpiredWakePolicy, AlarmTimerSeconds, TimeAlarmDeviceCapabilities, TimerStatus, }; @@ -43,6 +45,21 @@ 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 — 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 ───────────────────────────────────────────────────────────── @@ -96,6 +113,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/ucsi.rs b/ec/test-tui/src/ucsi.rs new file mode 100644 index 0000000..fc0fba8 --- /dev/null +++ b/ec/test-tui/src/ucsi.rs @@ -0,0 +1,111 @@ +use crate::common; +use crate::common::SYMBOLS; +use crate::state::{Fetched, UcsiState}; +use ec_test_lib::ucsi::{UcsiConnectorCapability, UcsiConnectorStatus}; +use ratatui::{ + buffer::Buffer, + crossterm::event::Event, + layout::Rect, + style::{Color, palette::tailwind}, + text::Line, + widgets::{Block, Paragraph, Widget}, +}; + +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) { + 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) { + 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(title, is_healthy)) + .border_style(border); + let inner = block.inner(area); + block.render(area, buf); + Paragraph::new(rows(state)).render(inner, buf); + } +} + +// ── Shared row/summary builder ──────────────────────────────────────────────── + +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), + ] +} + +/// 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(e)) => format!("Error: {e}"), + Some(Ok(v)) => f(v), + } +} + +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 connector_summary(cap: &UcsiConnectorCapability) -> String { + let mut modes = Vec::new(); + if cap.drp { + modes.push("DRP"); + } + if cap.usb2 { + modes.push("USB2"); + } + if cap.usb3 { + modes.push("USB3"); + } + 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!("{modes} {} {roles}", SYMBOLS.mid_dot) +} + +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 a8814a1..183aadb 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,66 @@ 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) { + // 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); + + 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); + } +}