eth-phy-lan87xx 0.2.0

LAN8720A/8710A/8740A/8742A Ethernet PHY driver for no_std over MDIO
Documentation
# eth-phy-lan87xx

[![License: GPL-2.0-or-later OR Apache-2.0](https://img.shields.io/badge/license-GPL--2.0--or--later%20OR%20Apache--2.0-blue.svg)](#license)
[![Crates.io](https://img.shields.io/crates/v/eth-phy-lan87xx.svg)](https://crates.io/crates/eth-phy-lan87xx)
[![Documentation](https://docs.rs/eth-phy-lan87xx/badge.svg)](https://docs.rs/eth-phy-lan87xx)

`#![no_std]` MDIO driver for the Microchip LAN87xx family of 10/100
Ethernet PHYs:

* LAN8710A
* LAN8720A
* LAN8740A
* LAN8741A
* LAN8742A

Implements [`eth_mdio_phy::PhyDriver`](https://docs.rs/eth-mdio-phy),
so any MAC that exposes `eth_mdio_phy::MdioBus` can drive the chip —
typical case is the ESP32 built-in EMAC SMI controller via
[`esp_emac::mdio::EspMdio`](https://docs.rs/esp-emac).

---

## Installation

```toml
[dependencies]
eth-mdio-phy    = "0.2"
eth-phy-lan87xx = "0.2"
```

| Feature | Default | Pulls in |
| --- | --- | --- |
| `defmt` | off | `defmt::Format` derives via `eth-mdio-phy/defmt` |

**MSRV: 1.75.** Pure `#![no_std]`. Works on any target — picking the
target is the MAC layer's problem, not this crate's.

> **Pre-1.0 SemVer note.** Cargo's caret on `^0.1` will *not* pick up
> `0.2.x` — both digits behave as the major axis below 1.0. Bump the
> minor in your manifest explicitly when a new release lands.

## Compatibility

| Crate | Version |
| --- | --- |
| [`eth-mdio-phy`]https://crates.io/crates/eth-mdio-phy | 0.2.x |
| For ESP32: [`esp-emac`]https://crates.io/crates/esp-emac | 0.2.x |

---

## Quick start

Driving a LAN8720A on an ESP32 board (PHY at MDIO addr 1):

```rust no_run
use esp_emac::mdio::EspMdio;
use eth_phy_lan87xx::PhyLan87xx;
use eth_mdio_phy::{MdioBus, PhyDriver};

# fn example<E>() -> Result<(), eth_mdio_phy::PhyError<E>>
# where EspMdio: MdioBus<Error = E> {
let mut mdio = EspMdio::new();
let mut phy = PhyLan87xx::new(1);

// Probe + soft reset + ANAR + kick auto-neg.
phy.init(&mut mdio)?;

// Poll until link is up. Returns `Some(LinkStatus)` when the link
// comes up; `None` while still negotiating.
loop {
    if let Some(status) = phy.poll_link(&mut mdio)? {
        // status.speed: Mbps10 / Mbps100
        // status.duplex: Half / Full
        break;
    }
}
# Ok(())
# }
```

For the full embassy-net + DHCP example see
[`esp-emac/examples/embassy_net_lan8720a.rs`](https://github.com/jethub-iot/esp-emac-rs/blob/main/examples/embassy_net_lan8720a.rs).

## Boards with a PHY reset pin

If your board exposes a GPIO-driven PHY `nRST` line, use
`PhyLan87xxWithReset<P>` instead of `PhyLan87xx`. It wraps the same
driver and adds a `hardware_reset()` method that drives `nRST` low
for 2 ms, then deasserts and waits 25 ms before MDIO becomes
accessible (LAN8720A datasheet Table 4-2). Most JXD modules do
**not** route `nRST` to the MCU; for those use plain `PhyLan87xx`.

```rust no_run
use embedded_hal::{delay::DelayNs, digital::OutputPin};
use eth_mdio_phy::{MdioBus, PhyDriver, PhyError};
use eth_phy_lan87xx::PhyLan87xxWithReset;

# fn example<P, D, M>(reset: P, delay: &mut D, mdio: &mut M)
#     -> Result<(), MyError<P::Error, M::Error>>
# where
#     P: OutputPin,
#     D: DelayNs,
#     M: MdioBus,
# {
let mut phy = PhyLan87xxWithReset::new(/* MDIO addr */ 1, reset);
phy.hardware_reset(delay).map_err(MyError::Pin)?;
phy.init(mdio).map_err(MyError::Phy)?;
# Ok(())
# }
# enum MyError<P, M> { Pin(P), Phy(PhyError<M>) }
```

## Bypassing auto-negotiation

Auto-neg covers the common case. If a board needs a forced link (e.g.
a fixed-speed back-to-back connection) call
[`eth_mdio_phy::ieee802_3::force_link`](https://docs.rs/eth-mdio-phy/latest/eth_mdio_phy/ieee802_3/fn.force_link.html)
directly with the chosen `Speed` / `Duplex` after `PhyLan87xx::init`
returns — that helper clears `AN_ENABLE` and sets the `SPEED_100` /
`DUPLEX_FULL` bits in BMCR for you.

`poll_link` automatically detects the BMCR mode (auto-neg vs forced)
and decodes the link state appropriately.

---

## What `init` does

1. Issues `BMCR.RESET` (soft reset) and waits for the bit to
   self-clear.
2. Reads `PHYIDR1/2` and rejects anything that doesn't decode to a
   known LAN87xx OUI / model.
3. Disables Energy-Detect Power-Down by clearing `MCSR.EDPD_EN`.
   With EDPD on, the LAN87xx silently drops 10 Mbps frames during
   the wake-up window after auto-neg — turning it off keeps the RX
   path active at all times.
4. **Writes `ANAR = 0x01E1`** explicitly — both the
   10BASE-T / 10BASE-T-FD / 100BASE-TX / 100BASE-TX-FD ability bits
   and the IEEE 802.3 selector field. This step is crucial; see the
   troubleshooting note below.
5. Sets `BMCR.AN_ENABLE | BMCR.AN_RESTART` to kick auto-negotiation.

## What `poll_link` does

* Reads `BMSR` for the link bit.
* If the PHY is in auto-neg mode (`BMCR.AN_ENABLE = 1`), waits for
  `PSCSR.AUTODONE` then decodes the negotiated speed / duplex from
  the LAN87xx-specific PSCSR register (faster and more reliable than
  reading `ANLPAR` because it reflects the actual result rather than
  the partner's advertisement).
* If auto-neg is disabled (forced mode), decodes speed / duplex
  directly from `BMCR.SPEED_100` / `BMCR.DUPLEX_FULL`.

---

## Troubleshooting

### Link comes up but unicast RX is dead (cold boot only)

Symptoms: ARP requests get replies, but ICMP/TCP times out. Reproduces
on cold boot; goes away after a re-flash without power-cycle.

Root cause: on a **cold boot** of the LAN8720A (and confirmed on its
siblings), issuing `BMCR.RESET` does NOT restore `ANAR` to the default
`0x01E1`. Whatever the PHY has in non-volatile state survives, and
that's typically a subset of the full 10/100 + half/full advertisement.
Auto-neg then converges on the partial subset and the link comes up
at the lowest common denominator — or, worse, succeeds on a speed
that the MAC isn't ready for, so unicast RX wedges and only
broadcast / multicast survive.

This driver handles the case by writing `ANAR = 0x01E1` explicitly
between the soft reset and `AN_RESTART`. **If you reimplement this
PHY init elsewhere, do the same** — it is the single most common cold-
boot Ethernet failure on LAN87xx.

### `PhyError::UnsupportedChip { id: 0 }` on `init`

The PHY is on a different MDIO address than the one passed to `new()`.
Typical strap-pin variants put LAN8720A at addr 0 or 1. Try both. On
ESP32 modules, the strap-pin assignment depends on PCB-level pull-up
configuration — check the schematic.

### `PhyError::UnsupportedChip { id: 0xFFFF_FFFF }` on `init`

MDIO bus reads are floating high — typical signs:

* MDIO line not pulled up (LAN87xx datasheet requires 1.5 kΩ pull-up
  on MDIO).
* RMII reference clock is not running. The MDIO state machine inside
  the PHY needs the 25 MHz clock to be alive even though MDIO is
  electrically asynchronous; on LAN8720A, no REF_CLK = no MDIO ACK.
  Verify `Emac::init` (or your equivalent) brings up the clock before
  the first MDIO transaction.

### Link reports 10 Mbps despite a 100 Mbps switch

ANAR didn't get programmed correctly — see the cold-boot section
above. Other possibilities:

* MDIO writes aren't actually reaching the PHY (verify with a
  scope or a software MDIO trace).
* The PHY's `BMCR.SPEED_100` strap pin is tied low and overrides the
  software value at reset (LAN8720A datasheet table 7-1).

---

## Hardware verified on

* JXD-PM3-80-E1ETH and JXD-R6-E1ETH-LCD (LAN8720A on RMII; ESP32 APLL
  drives the 50 MHz reference into the PHY through GPIO17). Cold boot,
  soft reset and USB power-cycle all converge on link up
  100 Mbps full duplex within a few hundred milliseconds.

## License

Licensed under either of:

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

at your option.