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 33ace0d..5710b6b 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 8e27a07..01c29f5 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(ucsi::normalize_acpi_response(&arg.data)?.to_vec()) + } +} + +impl UcsiSource for Acpi { + fn get_version(&self) -> Result { + 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::CommandType::GetCapability, 0))?; + Ok(ucsi::decode_capability(&mailbox)?) + } + + fn get_connector_capability(&self, connector: u8) -> Result { + 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::CommandType::GetConnectorStatus, connector))?; + Ok(ucsi::decode_connector_status(&mailbox)?) + } +} diff --git a/ec/test-lib/src/lib.rs b/ec/test-lib/src/lib.rs index 90122ff..2d79832 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")] @@ -15,6 +17,7 @@ pub mod windows; pub mod mock; pub mod serial; +pub mod ucsi; /// EC data source error. /// @@ -156,9 +159,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. @@ -238,6 +256,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..35576b3 100644 --- a/ec/test-lib/src/mock.rs +++ b/ec/test-lib/src/mock.rs @@ -1,8 +1,12 @@ -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, }; 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::{ @@ -444,3 +448,47 @@ impl RtcSource for Mock { Ok(()) } } + +impl UcsiSource for Mock { + fn get_version(&self) -> Result { + 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, + 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 { + 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 { + 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/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..ecba7d9 --- /dev/null +++ b/ec/test-lib/src/ucsi.rs @@ -0,0 +1,356 @@ +//! Host-side UCSI mailbox envelope decoding. +//! +//! 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; + +const MAILBOX_LEN: usize = 48; +#[cfg(any(target_os = "windows", test))] +const FFA_ENVELOPE_LEN: usize = 144; +#[cfg(any(target_os = "windows", test))] +const FFA_PAYLOAD_OFFSET: usize = 32; +const UCSI_VERSION_1_2: u16 = 0x0120; +const MESSAGE_IN_OFFSET: usize = 16; + +/// 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(command: CommandType, connector: u8) -> [u8; CONTROL_LEN] { + let mut buf = [0u8; CONTROL_LEN]; + buf[0] = command as u8; + buf[2] = connector; + buf +} + +/// Normalize either a direct mailbox or the full FF-A envelope returned by +/// the Windows fixed-hardware operation region. +#[cfg(any(target_os = "windows", test))] +pub(crate) fn normalize_acpi_response(bytes: &[u8]) -> Result<&[u8], MailboxError> { + match bytes.len() { + MAILBOX_LEN => Ok(bytes), + FFA_ENVELOPE_LEN => { + let status = u64::from_le_bytes(bytes[..8].try_into().expect("8-byte FF-A status")); + if status != 0 { + return Err(MailboxError::FfaStatus(status)); + } + Ok(&bytes[FFA_PAYLOAD_OFFSET..FFA_PAYLOAD_OFFSET + MAILBOX_LEN]) + } + length => Err(MailboxError::WrongLength(length)), + } +} + +/// 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) + } +} + +/// 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 FF-A fixed-hardware envelope reported a transport error. + FfaStatus(u64), + /// 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, + }, + /// The command-specific response payload failed to decode. + PayloadDecode, +} + +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::FfaStatus(status) => write!(f, "FF-A transport status {status:#018x}"), + 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}") + } + Self::PayloadDecode => write!(f, "malformed UCSI response payload"), + } + } +} + +impl std::error::Error for MailboxError {} + +/// Validate the 48-byte mailbox length and CCI status, returning VERSION and +/// the CCI data-length field. +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]]); + 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.error() { + return Err(MailboxError::CommandError); + } + if cci.not_supported() { + return Err(MailboxError::NotSupported); + } + Ok((version, cci.data_len())) +} + +/// 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 { + return Err(MailboxError::UnsupportedVersion(version)); + } + if actual != expected { + return Err(MailboxError::UnexpectedDataLen { expected, actual }); + } + Ok(&bytes[MESSAGE_IN_OFFSET..MESSAGE_IN_OFFSET + expected]) +} + +/// 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 response. +pub fn decode_capability(bytes: &[u8]) -> Result { + 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 response. +pub fn decode_connector_capability(bytes: &[u8]) -> Result { + 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 response. +pub fn decode_connector_status(bytes: &[u8]) -> Result { + 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)] +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(CommandType::GetConnectorStatus, 1); + assert_eq!(c[0], 0x12); + assert_eq!(c[1], 0x00); + assert_eq!(c[2], 0x01); + assert_eq!(&c[3..], &[0u8; 5]); + } + + // ── envelope validation boundaries ──────────────────────────────────────── + + #[test] + fn rejects_wrong_length() { + assert_eq!(decode_version(&[0u8; 47]).unwrap_err(), MailboxError::WrongLength(47)); + } + + #[test] + fn normalizes_full_ffa_envelope() { + const FFA_ENVELOPE_LEN: usize = 144; + const FFA_PAYLOAD_OFFSET: usize = 32; + let mailbox = mailbox(cci_complete(16), &[]); + let mut envelope = [0u8; FFA_ENVELOPE_LEN]; + envelope[FFA_PAYLOAD_OFFSET..FFA_PAYLOAD_OFFSET + MAILBOX_LEN].copy_from_slice(&mailbox); + + assert_eq!(normalize_acpi_response(&envelope).unwrap(), mailbox); + } + + #[test] + fn rejects_failed_ffa_envelope() { + let mut envelope = [0u8; FFA_ENVELOPE_LEN]; + envelope[..8].copy_from_slice(&5u64.to_le_bytes()); + + assert_eq!( + normalize_acpi_response(&envelope).unwrap_err(), + MailboxError::FfaStatus(5) + ); + } + + #[test] + 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_capability(&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_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"); + } + + #[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_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.num_connectors, 1); + assert!(cap.attributes.usb_power_delivery()); + assert_eq!(cap.bcd_usb_pd_spec, 0x0300); + } + + #[test] + 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!(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() { + // 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] = 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] = 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.status.expect("connected payload present").power_direction, + PowerDirection::Source + ); + } +} diff --git a/ec/test-lib/src/windows.rs b/ec/test-lib/src/windows.rs index 3a5bc28..cd30c13 100644 --- a/ec/test-lib/src/windows.rs +++ b/ec/test-lib/src/windows.rs @@ -1,6 +1,7 @@ //! Windows data source. use crate::mock::Mock; -use crate::{BatterySource, ErrorType, RtcSource, ThermalSource, Threshold}; +use crate::ucsi::{UcsiCapability, UcsiConnectorCapability, UcsiConnectorStatus, UcsiVersion}; +use crate::{BatterySource, ErrorType, RtcSource, ThermalSource, Threshold, UcsiSource}; use battery_service_interface::{BixFixedStrings, BstReturn}; use scopeguard::defer; use time_alarm_service_interface::{ @@ -83,6 +84,8 @@ pub enum Error { Io(i32), /// The device returned a malformed or unexpected buffer. InvalidData, + /// The Windows class-driver source does not expose this operation. + Unsupported(&'static str), } impl std::fmt::Display for Error { @@ -91,6 +94,7 @@ impl std::fmt::Display for Error { Self::DeviceNotFound => write!(f, "Device not found"), Self::Io(code) => write!(f, "HRESULT {code:#x}"), Self::InvalidData => write!(f, "Invalid data"), + Self::Unsupported(what) => write!(f, "unsupported by Windows source: {what}"), } } } @@ -103,6 +107,7 @@ impl crate::Error for Error { Self::DeviceNotFound => crate::ErrorKind::Io, Self::Io(_) => crate::ErrorKind::Io, Self::InvalidData => crate::ErrorKind::InvalidData, + Self::Unsupported(_) => crate::ErrorKind::Other, } } } @@ -333,3 +338,21 @@ impl BatterySource for Windows { BatterySource::set_btp(&self.mock, trippoint).map_err(Into::into) } } + +impl UcsiSource for Windows { + 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")) + } +} 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 9a0f8fd..e197d34 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; @@ -96,6 +97,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 @@ -134,6 +136,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::(); @@ -154,12 +157,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 51518a3..ff05aeb 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..7590a8c --- /dev/null +++ b/ec/test-tui/src/ucsi.rs @@ -0,0 +1,156 @@ +use crate::common; +use crate::common::SYMBOLS; +use crate::state::{Fetched, UcsiState}; +use ec_test_lib::ucsi::{PowerDirection, UcsiCapability, 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: &UcsiCapability) -> String { + let connector = if cap.num_connectors == 1 { + "connector" + } else { + "connectors" + }; + 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_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 modes_flags.drp() { + modes.push("DRP"); + } + if modes_flags.usb2() { + modes.push("USB2"); + } + if modes_flags.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.connect_status { + return "Disconnected".to_string(); + } + 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 {} {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 cap = Mock::default().get_capability().unwrap(); + assert_eq!(capability_summary(&cap), "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 a8814a1..dde62d2 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,67 @@ 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.connect_status); + let connected = status.status.expect("connected payload present"); + assert_eq!(connected.power_direction, PowerDirection::Sink); + } +}