Skip to main content

eth_phy_lan87xx/
lib.rs

1// SPDX-License-Identifier: GPL-2.0-or-later OR Apache-2.0
2// Copyright (c) Viacheslav Bocharov <v@baodeep.com> and JetHome (r)
3
4//! `#![no_std]` MDIO driver for the Microchip LAN87xx family of 10/100
5//! Ethernet PHYs:
6//!
7//! - LAN8710A
8//! - LAN8720A
9//! - LAN8740A
10//! - LAN8741A
11//! - LAN8742A
12//!
13//! Implements [`eth_mdio_phy::PhyDriver`], so any MAC that exposes
14//! [`eth_mdio_phy::MdioBus`] can drive the chip — typical case is the
15//! ESP32 built-in EMAC SMI controller via
16//! [`esp_emac::mdio::EspMdio`](https://docs.rs/esp-emac).
17//!
18//! See the crate-level README (rendered on docs.rs and shipped via
19//! `Cargo.toml`'s `readme` field) for installation, the full
20//! embassy-net example via `esp-emac`, and a troubleshooting checklist
21//! covering the cold-boot ANAR quirk, MDIO bus failures, and strap-pin
22//! pitfalls.
23//!
24//! # Quick start
25//!
26//! ```no_run
27//! use eth_mdio_phy::{MdioBus, PhyDriver};
28//! use eth_phy_lan87xx::PhyLan87xx;
29//!
30//! # fn example<M: MdioBus>(mdio: &mut M)
31//! # -> Result<(), eth_mdio_phy::PhyError<M::Error>>
32//! # {
33//! let mut phy = PhyLan87xx::new(/* PHY MDIO address */ 1);
34//! phy.init(mdio)?;
35//! loop {
36//!     if let Some(status) = phy.poll_link(mdio)? {
37//!         // status.speed, status.duplex
38//!         break;
39//!     }
40//! }
41//! # Ok(())
42//! # }
43//! ```
44//!
45//! # Crate features
46//!
47//! | Feature | Default | When to enable |
48//! | --- | --- | --- |
49//! | `defmt` | off | Adds `defmt::Format` derives via `eth-mdio-phy/defmt`. |
50//!
51//! # Cold-boot ANAR quirk
52//!
53//! On a cold boot, `BMCR.RESET` does NOT restore `ANAR` to
54//! `0x01E1` on the LAN87xx family. The driver writes `ANAR = 0x01E1`
55//! explicitly before kicking auto-negotiation; if you reimplement
56//! this elsewhere, do the same — see the crate README's troubleshooting
57//! section.
58
59#![no_std]
60
61mod regs;
62
63use eth_mdio_phy::ieee802_3;
64use eth_mdio_phy::{Duplex, LinkStatus, MdioBus, PhyCapabilities, PhyDriver, PhyError, Speed};
65
66/// LAN87xx PHY driver (software-only, no reset pin).
67pub struct PhyLan87xx {
68    addr: u8,
69    link_up: bool,
70}
71
72impl PhyLan87xx {
73    /// Create a new driver for the PHY at the given MDIO address.
74    pub fn new(addr: u8) -> Self {
75        Self {
76            addr,
77            link_up: false,
78        }
79    }
80
81    /// Decode the PSCSR speed/duplex indication field into a [`LinkStatus`].
82    ///
83    /// Returns `None` if the field contains a reserved or unrecognised value.
84    fn parse_pscsr(pscsr_val: u16) -> Option<LinkStatus> {
85        match pscsr_val & regs::pscsr::SPEED_DUPLEX_MASK {
86            regs::pscsr::SPEED_10_HD => Some(LinkStatus::new(Speed::Mbps10, Duplex::Half)),
87            regs::pscsr::SPEED_10_FD => Some(LinkStatus::new(Speed::Mbps10, Duplex::Full)),
88            regs::pscsr::SPEED_100_HD => Some(LinkStatus::new(Speed::Mbps100, Duplex::Half)),
89            regs::pscsr::SPEED_100_FD => Some(LinkStatus::new(Speed::Mbps100, Duplex::Full)),
90            _ => None,
91        }
92    }
93}
94
95impl PhyDriver for PhyLan87xx {
96    fn phy_addr(&self) -> u8 {
97        self.addr
98    }
99
100    fn init<M: MdioBus>(&mut self, mdio: &mut M) -> Result<(), PhyError<M::Error>> {
101        // 1. Soft reset — check timeout
102        let cleared = ieee802_3::soft_reset(mdio, self.addr, 500).map_err(PhyError::Mdio)?;
103        if !cleared {
104            return Err(PhyError::ResetTimeout);
105        }
106
107        // 2. Verify PHY ID matches LAN87xx family
108        let id = ieee802_3::read_phy_id(mdio, self.addr).map_err(PhyError::Mdio)?;
109        if id & regs::PHY_OUI_MASK != regs::PHY_OUI {
110            return Err(PhyError::UnsupportedChip { id });
111        }
112
113        // 3. Disable Energy Detect Power-Down (EDPD) for reliable link detection
114        let mcsr = mdio
115            .read(self.addr, regs::mcsr::ADDR)
116            .map_err(PhyError::Mdio)?;
117        mdio.write(self.addr, regs::mcsr::ADDR, mcsr & !regs::mcsr::EDPD_EN)
118            .map_err(PhyError::Mdio)?;
119
120        // 4. Advertise standard 10/100 capabilities.
121        //
122        // After a cold boot, a soft-reset via BMCR.RESET does not always
123        // restore ANAR to its documented default of 0x01E1 — the
124        // register can retain a partial advertisement seeded from the
125        // silicon's reset-strap latch instead of the spec default.
126        // Without an explicit write, auto-negotiation starts with a
127        // truncated advertisement: the partner negotiates 100/Full,
128        // BMSR.LINK_STATUS goes up, but unicast RX is dead at the PHY
129        // layer. Write the standard 10/100 selector explicitly to
130        // sidestep the strap state.
131        let anar = ieee802_3::anar::TX_FD
132            | ieee802_3::anar::TX_HD
133            | ieee802_3::anar::T10_FD
134            | ieee802_3::anar::T10_HD
135            | ieee802_3::anar::SELECTOR_IEEE802_3;
136        mdio.write(self.addr, ieee802_3::regs::ANAR, anar)
137            .map_err(PhyError::Mdio)?;
138
139        // 5. Enable auto-negotiation
140        ieee802_3::enable_auto_negotiation(mdio, self.addr).map_err(PhyError::Mdio)?;
141
142        self.link_up = false;
143        Ok(())
144    }
145
146    fn poll_link<M: MdioBus>(
147        &mut self,
148        mdio: &mut M,
149    ) -> Result<Option<LinkStatus>, PhyError<M::Error>> {
150        let up = ieee802_3::is_link_up(mdio, self.addr).map_err(PhyError::Mdio)?;
151        if !up {
152            self.link_up = false;
153            return Ok(None);
154        }
155
156        // Auto-negotiation vs. forced link is decided by BMCR.AN_ENABLE.
157        // The two paths have different validity rules and use different
158        // registers to read back speed/duplex.
159        let bmcr = mdio
160            .read(self.addr, ieee802_3::regs::BMCR)
161            .map_err(PhyError::Mdio)?;
162
163        let status = if bmcr & ieee802_3::bmcr::AN_ENABLE != 0 {
164            // Auto-neg path: PSCSR speed/duplex bits are only valid
165            // after AUTODONE is set. On parallel-detection links
166            // BMSR.LINK_STATUS can latch high while auto-negotiation
167            // is still converging, and reading PSCSR in that window
168            // returns indeterminate speed bits — exactly the class of
169            // bug the explicit ANAR write is meant to prevent.
170            let pscsr = mdio
171                .read(self.addr, regs::pscsr::ADDR)
172                .map_err(PhyError::Mdio)?;
173            if pscsr & regs::pscsr::AUTODONE == 0 {
174                self.link_up = false;
175                return Ok(None);
176            }
177            Self::parse_pscsr(pscsr)
178        } else {
179            // Forced-link path (`ieee802_3::force_link` clears
180            // AN_ENABLE and programs SPEED_100 / DUPLEX_FULL directly
181            // in BMCR). PSCSR may never set AUTODONE in this mode, so
182            // read speed/duplex straight from BMCR. Link is reported
183            // as soon as BMSR.LINK_STATUS goes up.
184            let speed = if bmcr & ieee802_3::bmcr::SPEED_100 != 0 {
185                Speed::Mbps100
186            } else {
187                Speed::Mbps10
188            };
189            let duplex = if bmcr & ieee802_3::bmcr::DUPLEX_FULL != 0 {
190                Duplex::Full
191            } else {
192                Duplex::Half
193            };
194            Some(LinkStatus::new(speed, duplex))
195        };
196
197        self.link_up = status.is_some();
198        Ok(status)
199    }
200
201    fn capabilities<M: MdioBus>(
202        &self,
203        mdio: &mut M,
204    ) -> Result<PhyCapabilities, PhyError<M::Error>> {
205        ieee802_3::read_capabilities(mdio, self.addr).map_err(PhyError::Mdio)
206    }
207
208    fn phy_id<M: MdioBus>(&self, mdio: &mut M) -> Result<u32, PhyError<M::Error>> {
209        ieee802_3::read_phy_id(mdio, self.addr).map_err(PhyError::Mdio)
210    }
211}
212
213/// LAN87xx PHY driver with a hardware reset pin.
214///
215/// Wraps [`PhyLan87xx`] and adds [`hardware_reset`](Self::hardware_reset)
216/// for toggling the PHY nRST line before initialisation.
217pub struct PhyLan87xxWithReset<P: embedded_hal::digital::OutputPin> {
218    inner: PhyLan87xx,
219    reset_pin: P,
220}
221
222impl<P: embedded_hal::digital::OutputPin> PhyLan87xxWithReset<P> {
223    /// Create a new driver with the given MDIO address and reset pin.
224    pub fn new(addr: u8, pin: P) -> Self {
225        Self {
226            inner: PhyLan87xx::new(addr),
227            reset_pin: pin,
228        }
229    }
230
231    /// Perform a hardware reset via the nRST pin.
232    ///
233    /// Drives the pin low for 2 ms (min 100 us per datasheet), then
234    /// releases and waits 25 ms for PHY internal init to complete
235    /// before MDIO is accessible (LAN8720A datasheet Table 4-2).
236    pub fn hardware_reset<D: embedded_hal::delay::DelayNs>(
237        &mut self,
238        delay: &mut D,
239    ) -> Result<(), P::Error> {
240        self.reset_pin.set_low()?;
241        delay.delay_ms(2);
242        self.reset_pin.set_high()?;
243        delay.delay_ms(25);
244        Ok(())
245    }
246}
247
248impl<P: embedded_hal::digital::OutputPin> PhyDriver for PhyLan87xxWithReset<P> {
249    fn phy_addr(&self) -> u8 {
250        self.inner.phy_addr()
251    }
252
253    fn init<M: MdioBus>(&mut self, mdio: &mut M) -> Result<(), PhyError<M::Error>> {
254        self.inner.init(mdio)
255    }
256
257    fn poll_link<M: MdioBus>(
258        &mut self,
259        mdio: &mut M,
260    ) -> Result<Option<LinkStatus>, PhyError<M::Error>> {
261        self.inner.poll_link(mdio)
262    }
263
264    fn capabilities<M: MdioBus>(
265        &self,
266        mdio: &mut M,
267    ) -> Result<PhyCapabilities, PhyError<M::Error>> {
268        self.inner.capabilities(mdio)
269    }
270
271    fn phy_id<M: MdioBus>(&self, mdio: &mut M) -> Result<u32, PhyError<M::Error>> {
272        self.inner.phy_id(mdio)
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    extern crate alloc;
279
280    use super::*;
281    use alloc::vec;
282    use alloc::vec::Vec;
283    use eth_mdio_phy::ieee802_3::{bmcr, bmsr};
284
285    // ── Mock MDIO bus ──────────────────────────────────────────────────
286
287    #[derive(Debug, PartialEq)]
288    struct MockError;
289
290    struct MockMdio {
291        reads: Vec<u16>,
292        read_idx: usize,
293        writes: Vec<(u8, u8, u16)>,
294        fail_at: Option<usize>,
295        call_count: usize,
296    }
297
298    impl MockMdio {
299        fn new(reads: Vec<u16>) -> Self {
300            Self {
301                reads,
302                read_idx: 0,
303                writes: Vec::new(),
304                fail_at: None,
305                call_count: 0,
306            }
307        }
308
309        fn with_failure(reads: Vec<u16>, fail_at: usize) -> Self {
310            Self {
311                reads,
312                read_idx: 0,
313                writes: Vec::new(),
314                fail_at: Some(fail_at),
315                call_count: 0,
316            }
317        }
318    }
319
320    impl MdioBus for MockMdio {
321        type Error = MockError;
322
323        fn read(&mut self, _phy_addr: u8, _reg_addr: u8) -> Result<u16, Self::Error> {
324            if self.fail_at == Some(self.call_count) {
325                self.call_count += 1;
326                return Err(MockError);
327            }
328            self.call_count += 1;
329            let val = *self
330                .reads
331                .get(self.read_idx)
332                .expect("MockMdio: reads vector exhausted — test needs more entries");
333            self.read_idx += 1;
334            Ok(val)
335        }
336
337        fn write(&mut self, phy_addr: u8, reg_addr: u8, value: u16) -> Result<(), Self::Error> {
338            if self.fail_at == Some(self.call_count) {
339                self.call_count += 1;
340                return Err(MockError);
341            }
342            self.call_count += 1;
343            self.writes.push((phy_addr, reg_addr, value));
344            Ok(())
345        }
346    }
347
348    // ── Constructor tests ──────────────────────────────────────────────
349
350    #[test]
351    fn new_sets_address() {
352        let phy = PhyLan87xx::new(3);
353        assert_eq!(phy.phy_addr(), 3);
354    }
355
356    #[test]
357    fn new_link_starts_down() {
358        let phy = PhyLan87xx::new(0);
359        assert!(!phy.link_up);
360    }
361
362    // ── init tests ─────────────────────────────────────────────────────
363
364    #[test]
365    fn init_success() {
366        // Mock reads sequence:
367        //   [0] BMCR read (0x0000 — reset cleared immediately)
368        //   [1] PHYIDR1 (0x0007)
369        //   [2] PHYIDR2 (0xC0F0) — LAN8720A
370        //   [3] MCSR read (EDPD_EN set)
371        //   [4] BMCR read for enable_auto_negotiation
372        let mut mdio = MockMdio::new(vec![
373            0x0000,              // soft_reset poll → cleared
374            0x0007,              // PHYIDR1
375            0xC0F0,              // PHYIDR2
376            regs::mcsr::EDPD_EN, // MCSR with EDPD set
377            0x0000,              // BMCR for enable_auto_negotiation
378        ]);
379        let mut phy = PhyLan87xx::new(1);
380        phy.init(&mut mdio).unwrap();
381    }
382
383    #[test]
384    fn init_rejects_wrong_phy_id() {
385        // soft_reset succeeds, then PHY ID does not match LAN87xx OUI
386        let mut mdio = MockMdio::new(vec![
387            0x0000, // soft_reset poll → cleared
388            0x0022, // PHYIDR1 (wrong)
389            0x1619, // PHYIDR2 (wrong)
390        ]);
391        let mut phy = PhyLan87xx::new(1);
392        let err = phy.init(&mut mdio).unwrap_err();
393        match err {
394            PhyError::UnsupportedChip { id } => assert_eq!(id, 0x0022_1619),
395            _ => panic!("expected UnsupportedChip, got {:?}", err),
396        }
397    }
398
399    #[test]
400    fn init_reset_timeout() {
401        // soft_reset: all reads return RESET set → returns false → ResetTimeout
402        // Buffer larger than max_attempts (500) to avoid brittle coupling
403        let mut mdio = MockMdio::new(vec![bmcr::RESET; 1000]);
404        let mut phy = PhyLan87xx::new(1);
405        let err = phy.init(&mut mdio).unwrap_err();
406        match err {
407            PhyError::ResetTimeout => {}
408            _ => panic!("expected ResetTimeout, got {:?}", err),
409        }
410    }
411
412    #[test]
413    fn init_writes_anar_standard_advertisement() {
414        // Cold-boot soft-reset does not always restore ANAR to its
415        // default value, so init must write the standard 10/100
416        // advertisement explicitly.
417        let mut mdio = MockMdio::new(vec![
418            0x0000,              // soft_reset poll
419            0x0007,              // PHYIDR1
420            0xC0F0,              // PHYIDR2
421            regs::mcsr::EDPD_EN, // MCSR
422            0x0000,              // BMCR for enable_auto_negotiation
423        ]);
424        let mut phy = PhyLan87xx::new(1);
425        phy.init(&mut mdio).unwrap();
426
427        let anar_idx = mdio
428            .writes
429            .iter()
430            .position(|&(_, reg, _)| reg == eth_mdio_phy::ieee802_3::regs::ANAR)
431            .expect("expected a write to ANAR");
432        let expected = eth_mdio_phy::ieee802_3::anar::TX_FD
433            | eth_mdio_phy::ieee802_3::anar::TX_HD
434            | eth_mdio_phy::ieee802_3::anar::T10_FD
435            | eth_mdio_phy::ieee802_3::anar::T10_HD
436            | eth_mdio_phy::ieee802_3::anar::SELECTOR_IEEE802_3;
437        assert_eq!(
438            mdio.writes[anar_idx].2, expected,
439            "ANAR must advertise standard 10/100 full+half + 802.3 selector"
440        );
441
442        // The whole point of writing ANAR explicitly is to seed the
443        // advertisement BEFORE auto-neg restarts. Use `rposition` rather
444        // than `position` so the assertion catches a regression where a
445        // future refactor inserts an extra BMCR.AN write *before* ANAR
446        // — `position` would find the earliest match and silently pass.
447        let bmcr_an_idx = mdio
448            .writes
449            .iter()
450            .rposition(|&(_, reg, val)| {
451                reg == eth_mdio_phy::ieee802_3::regs::BMCR
452                    && (val
453                        & (eth_mdio_phy::ieee802_3::bmcr::AN_ENABLE
454                            | eth_mdio_phy::ieee802_3::bmcr::AN_RESTART))
455                        != 0
456            })
457            .expect("expected a BMCR write that enables/restarts auto-neg");
458        assert!(
459            anar_idx < bmcr_an_idx,
460            "ANAR (write #{anar_idx}) must be programmed BEFORE BMCR.AN_ENABLE/AN_RESTART (write #{bmcr_an_idx})",
461        );
462
463        // Behavioural invariant (not a write-count one): no BMCR write
464        // that enables/restarts auto-negotiation must occur BEFORE the
465        // ANAR write — that would kick negotiation against the stale
466        // advertisement, defeating the whole point of writing ANAR
467        // explicitly. Anything else (vendor setup, status acks, LED
468        // tweaks) is fair game: only the AN_RESTART that actually
469        // triggers negotiation needs to see the explicit ANAR value.
470        let pre_anar_an_restart = mdio.writes[..anar_idx].iter().any(|&(_, reg, val)| {
471            reg == eth_mdio_phy::ieee802_3::regs::BMCR
472                && (val
473                    & (eth_mdio_phy::ieee802_3::bmcr::AN_ENABLE
474                        | eth_mdio_phy::ieee802_3::bmcr::AN_RESTART))
475                    != 0
476        });
477        assert!(
478            !pre_anar_an_restart,
479            "BMCR.AN_ENABLE/AN_RESTART must not be issued before the ANAR write",
480        );
481    }
482
483    #[test]
484    fn init_disables_edpd() {
485        // Same as init_success; verify the MCSR write clears EDPD_EN
486        let mcsr_initial: u16 = regs::mcsr::EDPD_EN | regs::mcsr::ENERGYON;
487        let mut mdio = MockMdio::new(vec![
488            0x0000,       // soft_reset poll
489            0x0007,       // PHYIDR1
490            0xC0F0,       // PHYIDR2
491            mcsr_initial, // MCSR read
492            0x0000,       // BMCR for enable_auto_negotiation
493        ]);
494        let mut phy = PhyLan87xx::new(1);
495        phy.init(&mut mdio).unwrap();
496
497        // Find the MCSR write — it targets register 17
498        let mcsr_write = mdio
499            .writes
500            .iter()
501            .find(|&&(_, reg, _)| reg == regs::mcsr::ADDR)
502            .expect("expected a write to MCSR");
503        // EDPD_EN must be cleared, ENERGYON must be preserved
504        assert_eq!(
505            mcsr_write.2 & regs::mcsr::EDPD_EN,
506            0,
507            "EDPD_EN should be cleared"
508        );
509        assert_ne!(
510            mcsr_write.2 & regs::mcsr::ENERGYON,
511            0,
512            "other MCSR bits should be preserved"
513        );
514    }
515
516    #[test]
517    fn init_mdio_error_propagates() {
518        // Fail on call 0 (the BMCR write inside soft_reset)
519        let mut mdio = MockMdio::with_failure(vec![], 0);
520        let mut phy = PhyLan87xx::new(1);
521        let err = phy.init(&mut mdio).unwrap_err();
522        match err {
523            PhyError::Mdio(MockError) => {}
524            _ => panic!("expected Mdio error, got {:?}", err),
525        }
526    }
527
528    // ── poll_link tests ────────────────────────────────────────────────
529
530    #[test]
531    fn poll_link_down() {
532        // BMSR without LINK_STATUS → link down
533        let mut mdio = MockMdio::new(vec![0x0000]);
534        let mut phy = PhyLan87xx::new(1);
535        let result = phy.poll_link(&mut mdio).unwrap();
536        assert!(result.is_none());
537        assert!(!phy.link_up);
538    }
539
540    #[test]
541    fn poll_link_100_full() {
542        let mut mdio = MockMdio::new(vec![
543            bmsr::LINK_STATUS,                                 // is_link_up → true
544            ieee802_3::bmcr::AN_ENABLE,                        // BMCR — auto-neg path
545            regs::pscsr::AUTODONE | regs::pscsr::SPEED_100_FD, // PSCSR → 100 Mbps full duplex
546        ]);
547        let mut phy = PhyLan87xx::new(1);
548        let result = phy.poll_link(&mut mdio).unwrap();
549        assert_eq!(result, Some(LinkStatus::new(Speed::Mbps100, Duplex::Full)));
550        assert!(phy.link_up);
551    }
552
553    #[test]
554    fn poll_link_10_half() {
555        let mut mdio = MockMdio::new(vec![
556            bmsr::LINK_STATUS,
557            ieee802_3::bmcr::AN_ENABLE,
558            regs::pscsr::AUTODONE | regs::pscsr::SPEED_10_HD,
559        ]);
560        let mut phy = PhyLan87xx::new(1);
561        let result = phy.poll_link(&mut mdio).unwrap();
562        assert_eq!(result, Some(LinkStatus::new(Speed::Mbps10, Duplex::Half)));
563    }
564
565    #[test]
566    fn poll_link_100_half() {
567        let mut mdio = MockMdio::new(vec![
568            bmsr::LINK_STATUS,
569            ieee802_3::bmcr::AN_ENABLE,
570            regs::pscsr::AUTODONE | regs::pscsr::SPEED_100_HD,
571        ]);
572        let mut phy = PhyLan87xx::new(1);
573        let result = phy.poll_link(&mut mdio).unwrap();
574        assert_eq!(result, Some(LinkStatus::new(Speed::Mbps100, Duplex::Half)));
575    }
576
577    #[test]
578    fn poll_link_10_full() {
579        let mut mdio = MockMdio::new(vec![
580            bmsr::LINK_STATUS,
581            ieee802_3::bmcr::AN_ENABLE,
582            regs::pscsr::AUTODONE | regs::pscsr::SPEED_10_FD,
583        ]);
584        let mut phy = PhyLan87xx::new(1);
585        let result = phy.poll_link(&mut mdio).unwrap();
586        assert_eq!(result, Some(LinkStatus::new(Speed::Mbps10, Duplex::Full)));
587    }
588
589    #[test]
590    fn poll_link_unknown_speed_returns_none() {
591        // PSCSR with 0b000 in speed/duplex field → unrecognised
592        let mut mdio = MockMdio::new(vec![
593            bmsr::LINK_STATUS,
594            ieee802_3::bmcr::AN_ENABLE,
595            regs::pscsr::AUTODONE, // AUTODONE set, speed bits = 0b000
596        ]);
597        let mut phy = PhyLan87xx::new(1);
598        let result = phy.poll_link(&mut mdio).unwrap();
599        assert!(result.is_none());
600        assert!(!phy.link_up);
601    }
602
603    #[test]
604    fn poll_link_returns_none_when_autodone_clear() {
605        // Parallel-detection race: BMSR.LINK_STATUS latches high while
606        // auto-negotiation is still converging. PSCSR speed bits are
607        // indeterminate in that window; poll_link must report "no link
608        // yet" so the caller keeps polling instead of acting on garbage.
609        let mut mdio = MockMdio::new(vec![
610            bmsr::LINK_STATUS,
611            ieee802_3::bmcr::AN_ENABLE,
612            regs::pscsr::SPEED_100_FD, // valid-looking, but AUTODONE not set
613        ]);
614        let mut phy = PhyLan87xx::new(1);
615        let result = phy.poll_link(&mut mdio).unwrap();
616        assert!(
617            result.is_none(),
618            "must wait for AUTODONE before decoding speed"
619        );
620        assert!(!phy.link_up);
621    }
622
623    #[test]
624    fn poll_link_forced_100_full() {
625        // ieee802_3::force_link clears AN_ENABLE and writes
626        // SPEED_100 | DUPLEX_FULL into BMCR. AUTODONE may never set
627        // in this mode, so poll_link must decode straight from BMCR.
628        let mut mdio = MockMdio::new(vec![
629            bmsr::LINK_STATUS,
630            ieee802_3::bmcr::SPEED_100 | ieee802_3::bmcr::DUPLEX_FULL,
631        ]);
632        let mut phy = PhyLan87xx::new(1);
633        let result = phy.poll_link(&mut mdio).unwrap();
634        assert_eq!(result, Some(LinkStatus::new(Speed::Mbps100, Duplex::Full)));
635        assert!(phy.link_up);
636    }
637
638    #[test]
639    fn poll_link_forced_10_half() {
640        // forced 10HD: AN_ENABLE/SPEED_100/DUPLEX_FULL all clear.
641        let mut mdio = MockMdio::new(vec![bmsr::LINK_STATUS, 0x0000]);
642        let mut phy = PhyLan87xx::new(1);
643        let result = phy.poll_link(&mut mdio).unwrap();
644        assert_eq!(result, Some(LinkStatus::new(Speed::Mbps10, Duplex::Half)));
645    }
646
647    #[test]
648    fn poll_link_forced_skips_pscsr_read() {
649        // In forced-link mode poll_link must NOT read PSCSR — providing
650        // only two read responses (BMSR + BMCR) would panic in the
651        // mock if a third read happened.
652        let mut mdio = MockMdio::new(vec![
653            bmsr::LINK_STATUS,
654            ieee802_3::bmcr::SPEED_100, // SPEED_100, DUPLEX clear → 100/Half
655        ]);
656        let mut phy = PhyLan87xx::new(1);
657        let result = phy.poll_link(&mut mdio).unwrap();
658        assert_eq!(result, Some(LinkStatus::new(Speed::Mbps100, Duplex::Half)));
659    }
660
661    #[test]
662    fn poll_link_mdio_error() {
663        // Fail on the first call (BMSR read inside is_link_up)
664        let mut mdio = MockMdio::with_failure(vec![], 0);
665        let mut phy = PhyLan87xx::new(1);
666        let err = phy.poll_link(&mut mdio).unwrap_err();
667        match err {
668            PhyError::Mdio(MockError) => {}
669            _ => panic!("expected Mdio error"),
670        }
671    }
672
673    // ── capabilities tests ─────────────────────────────────────────────
674
675    #[test]
676    fn capabilities_reads_bmsr() {
677        let bmsr_val = bmsr::TX_FD_CAPABLE
678            | bmsr::TX_HD_CAPABLE
679            | bmsr::T10_FD_CAPABLE
680            | bmsr::T10_HD_CAPABLE
681            | bmsr::AN_ABILITY;
682        let mut mdio = MockMdio::new(vec![bmsr_val]);
683        let phy = PhyLan87xx::new(1);
684        let caps = phy.capabilities(&mut mdio).unwrap();
685        assert!(caps.speed_100_fd);
686        assert!(caps.speed_100_hd);
687        assert!(caps.speed_10_fd);
688        assert!(caps.speed_10_hd);
689        assert!(caps.auto_negotiation);
690    }
691
692    // ── phy_id tests ───────────────────────────────────────────────────
693
694    #[test]
695    fn phy_id_reads_registers() {
696        let mut mdio = MockMdio::new(vec![0x0007, 0xC0F0]);
697        let phy = PhyLan87xx::new(1);
698        let id = phy.phy_id(&mut mdio).unwrap();
699        assert_eq!(id, 0x0007_C0F0);
700    }
701
702    // ── parse_pscsr tests ──────────────────────────────────────────────
703
704    #[test]
705    fn parse_pscsr_all_modes() {
706        assert_eq!(
707            PhyLan87xx::parse_pscsr(regs::pscsr::SPEED_10_HD),
708            Some(LinkStatus::new(Speed::Mbps10, Duplex::Half))
709        );
710        assert_eq!(
711            PhyLan87xx::parse_pscsr(regs::pscsr::SPEED_10_FD),
712            Some(LinkStatus::new(Speed::Mbps10, Duplex::Full))
713        );
714        assert_eq!(
715            PhyLan87xx::parse_pscsr(regs::pscsr::SPEED_100_HD),
716            Some(LinkStatus::new(Speed::Mbps100, Duplex::Half))
717        );
718        assert_eq!(
719            PhyLan87xx::parse_pscsr(regs::pscsr::SPEED_100_FD),
720            Some(LinkStatus::new(Speed::Mbps100, Duplex::Full))
721        );
722        // Unknown value (0b000 << 2 = 0x00)
723        assert_eq!(PhyLan87xx::parse_pscsr(0x0000), None);
724    }
725
726    #[test]
727    fn parse_pscsr_ignores_other_bits() {
728        // Set noise bits outside the speed/duplex field
729        let val = regs::pscsr::SPEED_100_FD | regs::pscsr::AUTODONE | 0x0003 | 0x8000;
730        assert_eq!(
731            PhyLan87xx::parse_pscsr(val),
732            Some(LinkStatus::new(Speed::Mbps100, Duplex::Full))
733        );
734    }
735}