eth-mdio-phy 0.2.0

MDIO-based Ethernet PHY traits and IEEE 802.3 helpers for no_std
Documentation
  • Coverage
  • 100%
    82 out of 82 items documented1 out of 7 items with examples
  • Size
  • Source code size: 82.56 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 756.01 kB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 33s Average build duration of successful builds.
  • all releases: 33s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • jethub-iot/eth-phy-rs
    1 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • adeepn

eth-mdio-phy

License: GPL-2.0-or-later OR Apache-2.0 Crates.io Documentation

Trait crate that decouples MDIO bus implementations from the PHYs they talk to. #![no_std], no allocations, no platform dependency.


Installation

[dependencies]
eth-mdio-phy = "0.2"
Feature Default Pulls in
defmt off defmt::Format derives on LinkStatus, PhyCapabilities, PhyError

MSRV: 1.75. Pure #![no_std] — works on any target.

Pre-1.0 SemVer note. Cargo's caret on ^0.1 will not pick up 0.2.x — both digits are treated as the major axis below 1.0. Consumers tracking this crate must bump the minor explicitly when a new minor release lands.

Compatibility

This crate exposes only traits and constants — no transitive runtime dependencies. It is consumed by:

Consumer Role
MAC implementations (e.g. esp-emac, STM32 ETH peripherals, FPGA SMI controllers) Provide MdioBus
PHY drivers (e.g. eth-phy-lan87xx) Implement PhyDriver
Higher-level Ethernet stacks (e.g. embassy-net adaptors) Compose the two and stay PHY-agnostic

What's exposed

  • MdioBus — the wire-level read/write trait. Any controller that can issue MDIO Clause 22 transactions (ESP32 EMAC SMI, STM32 ETH MDIO, FPGA SMI peripheral, a GPIO bit-bang implementation, a mock for tests) implements this trait.
  • PhyDriver — what every PHY driver in this stack provides: init, poll_link, capabilities. PHY-agnostic code (a DHCP test, a link-state watchdog, an embassy-net driver) can target this trait instead of a specific chip.
  • ieee802_3 — Clause 22 standard register addresses (BMCR, BMSR, ANAR, ANLPAR, PHYIDR1/2) and the bit constants inside them, plus convenience helpers soft_reset, enable_auto_negotiation, is_link_up, read_phy_id, read_capabilities, force_link. Reuse these in any chip-specific driver instead of re-deriving the bit numbers.
  • Shared typesSpeed { Mbps10, Mbps100 }, Duplex { Half, Full }, LinkStatus { speed, duplex }, PhyCapabilities (chip identification + advertised abilities).

Implementing your own PHY driver

A PHY driver is just a struct + impl PhyDriver. Each method takes the bus generically — there is no associated Bus type, so the same driver instance can talk to a real EspMdio and a MockMdioBus within one session.

use eth_mdio_phy::{
    ieee802_3, LinkStatus, MdioBus, PhyCapabilities, PhyDriver, PhyError,
};

pub struct MyPhy {
    addr: u8,
    link_up: bool,
}

impl MyPhy {
    pub fn new(addr: u8) -> Self {
        Self { addr, link_up: false }
    }
}

impl PhyDriver for MyPhy {
    fn phy_addr(&self) -> u8 {
        self.addr
    }

    fn phy_id<M: MdioBus>(&self, mdio: &mut M)
        -> Result<u32, PhyError<M::Error>>
    {
        ieee802_3::read_phy_id(mdio, self.addr).map_err(PhyError::Mdio)
    }

    fn init<M: MdioBus>(&mut self, mdio: &mut M)
        -> Result<(), PhyError<M::Error>>
    {
        // 1. Soft reset. `soft_reset` returns Ok(true) when the
        //    BMCR.RESET bit self-cleared within `max_attempts`,
        //    Ok(false) on timeout. The caller decides how to map
        //    a timeout to a higher-level error.
        let cleared = ieee802_3::soft_reset(mdio, self.addr, /* max_attempts */ 100)
            .map_err(PhyError::Mdio)?;
        if !cleared {
            return Err(PhyError::ResetTimeout);
        }
        // 2. Programme ANAR — *write the value you actually want*,
        //    do not rely on hardware default coming back from reset.
        mdio.write(self.addr, ieee802_3::regs::ANAR, 0x01E1)
            .map_err(PhyError::Mdio)?;
        // 3. Kick auto-negotiation.
        ieee802_3::enable_auto_negotiation(mdio, self.addr)
            .map_err(PhyError::Mdio)
    }

    fn poll_link<M: MdioBus>(&mut self, mdio: &mut M)
        -> Result<Option<LinkStatus>, PhyError<M::Error>>
    {
        let up = ieee802_3::is_link_up(mdio, self.addr)
            .map_err(PhyError::Mdio)?;
        if !up { return Ok(None); }
        // Decode speed/duplex from a chip-specific register here, or
        // fall back to ANLPAR via ieee802_3 helpers.
        # Ok(None)
    }

    fn capabilities<M: MdioBus>(&self, mdio: &mut M)
        -> Result<PhyCapabilities, PhyError<M::Error>>
    {
        ieee802_3::read_capabilities(mdio, self.addr)
            .map_err(PhyError::Mdio)
    }
}

Application code then drives the PHY through the trait without caring which chip is on the board:

# fn doc<M: eth_mdio_phy::MdioBus, P: eth_mdio_phy::PhyDriver>
# (mdio: &mut M, phy: &mut P) -> Result<(), eth_mdio_phy::PhyError<M::Error>>
# {
phy.init(mdio)?;
while phy.poll_link(mdio)?.is_none() { /* idle */ }
# Ok(())
# }

Implementing your own MdioBus

If your MAC has an SMI peripheral, just wrap its read/write registers in a struct:

use eth_mdio_phy::MdioBus;

pub struct MyMacMdio { /* registers / handles */ }

#[derive(Debug)]
pub enum MdioError { Timeout, BusError }

impl MdioBus for MyMacMdio {
    type Error = MdioError;

    fn read(&mut self, phy_addr: u8, reg_addr: u8)
        -> Result<u16, Self::Error>
    {
        // poke the SMI controller, return the 16-bit register value
        # Ok(0)
    }

    fn write(&mut self, phy_addr: u8, reg_addr: u8, value: u16)
        -> Result<(), Self::Error>
    {
        // poke the SMI controller; wait for completion
        # Ok(())
    }
}

If your board doesn't have a hardware SMI peripheral (some Cortex-M0 parts, FPGA bring-ups, exotic MCUs) a simple Clause 22 GPIO bit-bang sits in well under 100 lines of embedded-hal::digital::OutputPin manipulation. The Clause 22 frame is:

preamble    (32 ones)
ST          (01)        — start of frame
OP          (10 = read, 01 = write)
PHY addr    (5 bits)
REG addr    (5 bits)
TA          (z0 read / 10 write)  — turnaround
DATA        (16 bits)

The ieee802_3 register constants in this crate save you from re-deriving the bit numbers; the wire-level shifting is the only chip-specific code.

Mocking for tests

Two patterns work well; pick whichever maps better to what your driver actually does.

Scripted-response mock — pre-load the exact reads in order, record writes for assertions, optionally inject a failure at a specific call index. This is what eth-phy-lan87xx uses to verify its init/poll sequences:

use eth_mdio_phy::MdioBus;

#[derive(Debug, PartialEq)]
struct MockError;

struct MockMdio {
    reads: Vec<u16>,
    read_idx: usize,
    writes: Vec<(u8, u8, u16)>,
    fail_at: Option<usize>,
    call_count: usize,
}

impl MdioBus for MockMdio {
    type Error = MockError;

    fn read(&mut self, _phy_addr: u8, _reg_addr: u8)
        -> Result<u16, Self::Error>
    {
        if self.fail_at == Some(self.call_count) {
            self.call_count += 1;
            return Err(MockError);
        }
        self.call_count += 1;
        let val = self.reads[self.read_idx];
        self.read_idx += 1;
        Ok(val)
    }

    fn write(&mut self, phy_addr: u8, reg_addr: u8, value: u16)
        -> Result<(), Self::Error>
    {
        if self.fail_at == Some(self.call_count) {
            self.call_count += 1;
            return Err(MockError);
        }
        self.call_count += 1;
        self.writes.push((phy_addr, reg_addr, value));
        Ok(())
    }
}

Map-based mock — random-access registers, useful when the driver's behaviour depends on arbitrary read order or many cross-register side-effects:

use eth_mdio_phy::MdioBus;
use std::collections::HashMap;
use core::convert::Infallible;

pub struct MockMdioMap {
    pub regs: HashMap<(u8, u8), u16>,
    pub writes: Vec<(u8, u8, u16)>,
}

impl MdioBus for MockMdioMap {
    type Error = Infallible;

    fn read(&mut self, phy_addr: u8, reg_addr: u8)
        -> Result<u16, Self::Error>
    {
        Ok(*self.regs.get(&(phy_addr, reg_addr)).unwrap_or(&0))
    }

    fn write(&mut self, phy_addr: u8, reg_addr: u8, value: u16)
        -> Result<(), Self::Error>
    {
        self.writes.push((phy_addr, reg_addr, value));
        self.regs.insert((phy_addr, reg_addr), value);
        Ok(())
    }
}

Both keep tests on the host, no hardware-in-the-loop fixture required.


Why split MdioBus and PhyDriver

Three concrete reasons we hit on real hardware:

  1. Cross-MAC reuse. A LAN87xx driver written against MdioBus works equally well behind the ESP32 EMAC SMI controller, an STM32 ETH MDIO peripheral, or a host-side bit-bang implementation used by tests.
  2. Faster bring-up. Auto-negotiation diagnostics, link-state watchdogs, and DHCP smoke tests can target PhyDriver and run against any concrete PHY without recompilation.
  3. Testability. A MockMdioBus in dev-dependencies lets the PHY driver be unit-tested with deterministic register state — no QEMU, no hardware-in-the-loop fixture needed for the wire-level logic.

Relation to the rest of the stack

  • MAC implementations provide a MdioBus. For ESP32 see esp_emac::mdio::EspMdio.
  • PHY drivers implement PhyDriver. For LAN87xx see eth-phy-lan87xx.
  • Higher-level Ethernet stacks (e.g. embassy-net adaptors) compose the two and stay PHY-agnostic.

License

Licensed under either of:

  • GNU General Public License, Version 2.0 or later
  • Apache License, Version 2.0

at your option.