usbc-cable-caps 0.1.0

Decode what a USB-C cable can actually carry — power, data rate and video — from its e-marker class rather than its label.
Documentation
//! What a USB-C cable can carry, derived from its class rather than its packaging.
//!
//! Every USB-C plug is the same shape, and almost nothing else about USB-C cables
//! is the same. A cable may carry 240 W and 480 Mbps, or 60 W and 40 Gbps, or any
//! other combination — the connector tells you nothing, and the printed marketing
//! is frequently wrong or silent.
//!
//! The two facts that actually decide capability:
//!
//! 1. **Current above 3 A requires an e-marker chip** in the plug that declares the
//!    rating. No chip means the source is obliged to stay at 3 A regardless of what
//!    either end could do.
//! 2. **Data rate and power are independent.** The cheapest charging cable and a
//!    Thunderbolt cable both charge a laptop; only one carries a display.

/// Data capability of a cable, in the order the standards actually arrived.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DataClass {
    /// USB 2.0 only — 480 Mbps. The majority of bundled charging cables.
    Usb2,
    /// 5 Gbps (USB 3.2 Gen 1, once called USB 3.0).
    Gen1,
    /// 10 Gbps (USB 3.2 Gen 2).
    Gen2,
    /// 20 Gbps (USB 3.2 Gen 2x2).
    Gen2x2,
    /// 40 Gbps (USB4 / Thunderbolt 3 and 4).
    Usb4,
}

impl DataClass {
    /// Advertised throughput in megabits per second.
    pub fn mbps(self) -> u32 {
        match self {
            DataClass::Usb2 => 480,
            DataClass::Gen1 => 5_000,
            DataClass::Gen2 => 10_000,
            DataClass::Gen2x2 => 20_000,
            DataClass::Usb4 => 40_000,
        }
    }

    /// Whether the cable can carry a DisplayPort signal to an external monitor.
    ///
    /// This is the single most common disappointment: a cable that charges a
    /// laptop perfectly well and shows nothing on the monitor. USB 2.0 cables
    /// lack the high-speed pairs the alternate mode needs.
    pub fn carries_video(self) -> bool {
        !matches!(self, DataClass::Usb2)
    }
}

/// A cable as it physically exists, not as it is marketed.
#[derive(Debug, Clone, Copy)]
pub struct Cable {
    pub data: DataClass,
    /// Whether an e-marker chip is present in the plug.
    pub emarker: bool,
    /// Current the e-marker declares, in amps. Ignored when `emarker` is false.
    pub declared_amps: f64,
    pub length_m: f64,
}

impl Cable {
    /// Current the cable is permitted to carry, in amps.
    ///
    /// Without an e-marker the ceiling is 3 A no matter how thick the copper is.
    /// This is a protocol limit, not a thermal one — the source has no way to
    /// learn the cable is capable, so it must assume it is not.
    pub fn allowed_amps(&self) -> f64 {
        if self.emarker { self.declared_amps.min(5.0) } else { 3.0 }
    }

    /// Maximum power the cable can pass at a given negotiated voltage, in watts.
    pub fn max_watts_at(&self, volts: f64) -> f64 {
        volts * self.allowed_amps()
    }

    /// Whether the cable can reach a target wattage at a given voltage.
    pub fn can_deliver(&self, target_watts: f64, volts: f64) -> bool {
        self.max_watts_at(volts) >= target_watts
    }

    /// Whether passive operation is plausible at this length for the data class.
    ///
    /// Signal loss scales with length and with frequency, so the faster the class
    /// the shorter the passive cable can be. A 3 m 40 Gbps cable exists, but it
    /// contains active signal conditioning and costs accordingly — which is why
    /// long "Thunderbolt" cables are expensive and short ones are not.
    pub fn passive_length_is_plausible(&self) -> bool {
        let limit_m = match self.data {
            DataClass::Usb2 => 4.0,
            DataClass::Gen1 => 3.0,
            DataClass::Gen2 | DataClass::Gen2x2 => 1.0,
            DataClass::Usb4 => 0.8,
        };
        self.length_m <= limit_m
    }
}

/// The cable most people already own: bundled, unmarked, USB 2.0, no e-marker.
pub fn typical_bundled() -> Cable {
    Cable { data: DataClass::Usb2, emarker: false, declared_amps: 0.0, length_m: 1.0 }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_bundled_cable_caps_a_laptop_charger() {
        let c = typical_bundled();
        // A 100 W laptop charger negotiates 20 V; 3 A caps it at 60 W.
        assert!(!c.can_deliver(100.0, 20.0));
        assert_eq!(c.max_watts_at(20.0), 60.0);
    }

    #[test]
    fn the_emarker_not_the_copper_lifts_the_ceiling() {
        let thick_but_dumb = Cable { emarker: false, declared_amps: 5.0, ..typical_bundled() };
        let marked = Cable { emarker: true, declared_amps: 5.0, ..typical_bundled() };
        assert_eq!(thick_but_dumb.allowed_amps(), 3.0, "declared amps are ignored without a chip");
        assert_eq!(marked.allowed_amps(), 5.0);
    }

    #[test]
    fn charging_well_and_carrying_video_are_unrelated() {
        let charger = Cable { data: DataClass::Usb2, emarker: true, declared_amps: 5.0, length_m: 2.0 };
        assert!(charger.can_deliver(100.0, 20.0), "it charges a laptop fine");
        assert!(!charger.data.carries_video(), "and still shows nothing on a monitor");
    }

    #[test]
    fn speed_shortens_the_plausible_passive_cable() {
        let long_slow = Cable { data: DataClass::Usb2, length_m: 3.0, ..typical_bundled() };
        let long_fast = Cable { data: DataClass::Usb4, length_m: 3.0, ..typical_bundled() };
        assert!(long_slow.passive_length_is_plausible());
        assert!(!long_fast.passive_length_is_plausible(),
            "a passive 3 m 40 Gbps cable should be treated as implausible");
    }
}