nlink 0.18.0

Async netlink library for Linux network configuration
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
//! Typed request + reply structs for the DPLL family.
//!
//! Every struct in this file is declared via
//! `#[derive(GenlMessage)]` from `nlink-macros` — the wire-format
//! plumbing (`to_bytes` / `from_bytes`) is generated, leaving the
//! Rust-side declaration to read like a schema document.
//!
//! Compare with the hand-written GENL families
//! (`crates/nlink/src/netlink/genl/wireguard/`, `macsec/`, etc.)
//! where the equivalent code spans 200+ lines of manual
//! `MessageBuilder` + `AttrIter` walks.

use crate::macros::{GenlMessage, NetlinkAttrs};

use super::types::{
    DpllAttr, DpllClockQualityLevel, DpllCmd, DpllFeatureState, DpllLockStatus,
    DpllLockStatusError, DpllMode, DpllPinAttr, DpllPinCapabilities, DpllPinDirection,
    DpllPinState, DpllPinType, DpllType,
};

// ============================================================
// Device-side messages
// ============================================================

/// `DPLL_CMD_DEVICE_GET` request — single-device get or dump.
///
/// Set `id` to `Some(id)` for a single-device query (sent with
/// `NLM_F_REQUEST | NLM_F_ACK` via `Connection::send_typed`).
/// Set `id` to `None` for a full dump via
/// `Connection::dump_typed_stream` — the dump emits no
/// `DPLL_A_ID` attribute (matches the kernel's "no filter ⇒ list
/// every device" shape).
#[derive(GenlMessage, Debug, Default, Clone)]
#[genl_message(cmd = DpllCmd::DeviceGet)]
pub struct DpllDeviceGetRequest {
    /// Device ID to query. `None` = "no filter; dump all".
    #[genl_attr(DpllAttr::Id)]
    pub id: Option<u32>,
}

impl DpllDeviceGetRequest {
    /// Construct a single-device get request by ID.
    pub fn by_id(id: u32) -> Self {
        Self { id: Some(id) }
    }

    /// Construct a dump request (no ID filter).
    pub fn dump() -> Self {
        Self { id: None }
    }
}

/// `DPLL_CMD_DEVICE_GET` / `_CHANGE_NTF` reply — a device's
/// complete state.
///
/// Every field reflects an attribute the kernel may emit:
/// values present in every reply are non-`Option`; values added
/// in later kernel versions or only emitted in some configurations
/// are `Option<T>`. The macro derive defaults missing fields to
/// their type-defaults, so consumers can match on `None` to detect
/// "the kernel didn't ship this attribute" without surfacing a
/// parse error.
///
/// **Scaling fields:**
/// - `temp_mdeg` is milli-degrees Celsius. Divide by
///   [`super::DPLL_TEMP_DIVIDER`] (= 1000) for °C.
#[derive(GenlMessage, Debug, Default, Clone)]
#[genl_message(cmd = DpllCmd::DeviceGet)]
#[non_exhaustive]
pub struct DpllDeviceReply {
    /// Numeric device ID (stable across the kernel's lifetime).
    #[genl_attr(DpllAttr::Id)]
    pub id: u32,
    /// Driver module name (e.g., `"ice"`, `"mlx5"`).
    #[genl_attr(DpllAttr::ModuleName)]
    pub module_name: String,
    /// Hardware clock identifier (PCIe BDF-derived per UAPI).
    #[genl_attr(DpllAttr::ClockId)]
    pub clock_id: u64,
    /// Current mode. `None` if the kernel didn't ship the
    /// attribute (shouldn't happen on a normal `DEVICE_GET` —
    /// would indicate a kernel/binary mismatch).
    #[genl_attr(DpllAttr::Mode, repr = "u32")]
    pub mode: Option<DpllMode>,
    /// Modes the device supports. Each `DPLL_A_MODE_SUPPORTED`
    /// attr in the response is one element.
    #[genl_attr(DpllAttr::ModeSupported, repr = "u32")]
    pub mode_supported: Vec<DpllMode>,
    /// Current lock state. `None` if absent — see `mode` note.
    #[genl_attr(DpllAttr::LockStatus, repr = "u32")]
    pub lock_status: Option<DpllLockStatus>,
    /// Device temperature, milli-degrees Celsius. `None` if the
    /// driver doesn't expose temperature.
    #[genl_attr(DpllAttr::Temp)]
    pub temp_mdeg: Option<i32>,
    /// DPLL kind (PPS / EEC). `None` if absent.
    #[genl_attr(DpllAttr::Type, repr = "u32")]
    pub kind: Option<DpllType>,
    /// Reason for lock failure (kernel 6.10+). `None` on older
    /// kernels or when no error is active.
    #[genl_attr(DpllAttr::LockStatusError, repr = "u32")]
    pub lock_status_error: Option<DpllLockStatusError>,
    /// Currently-asserted ITU-T G.8264 quality levels (kernel
    /// 6.10+; may be multiple, hence Vec).
    #[genl_attr(DpllAttr::ClockQualityLevel, repr = "u32")]
    pub clock_quality_level: Vec<DpllClockQualityLevel>,
    /// Phase-offset monitor on/off (kernel 6.12+). `None` on
    /// older kernels.
    #[genl_attr(DpllAttr::PhaseOffsetMonitor, repr = "u32")]
    pub phase_offset_monitor: Option<DpllFeatureState>,
    /// Moving-average factor for phase-offset reporting
    /// (kernel 6.12+).
    #[genl_attr(DpllAttr::PhaseOffsetAvgFactor)]
    pub phase_offset_avg_factor: Option<u32>,
    /// Frequency monitor on/off (kernel 6.12+).
    #[genl_attr(DpllAttr::FrequencyMonitor, repr = "u32")]
    pub frequency_monitor: Option<DpllFeatureState>,
}

/// `DPLL_CMD_DEVICE_SET` request — mutate a device's state.
///
/// Construct with [`Self::new`] then chain setter methods for
/// the fields you want to change. Unset fields stay `None` and
/// are omitted from the wire request (`Option`-typed attrs).
#[derive(GenlMessage, Debug, Default, Clone)]
#[genl_message(cmd = DpllCmd::DeviceSet)]
pub struct DpllDeviceSetRequest {
    /// Target device ID. Always present.
    #[genl_attr(DpllAttr::Id)]
    pub id: u32,
    /// New mode, if changing.
    #[genl_attr(DpllAttr::Mode, repr = "u32")]
    pub mode: Option<DpllMode>,
    /// Toggle the kernel 6.12+ phase-offset monitor.
    #[genl_attr(DpllAttr::PhaseOffsetMonitor, repr = "u32")]
    pub phase_offset_monitor: Option<DpllFeatureState>,
    /// Set the moving-average factor for phase-offset reporting
    /// (kernel 6.12+).
    #[genl_attr(DpllAttr::PhaseOffsetAvgFactor)]
    pub phase_offset_avg_factor: Option<u32>,
    /// Toggle the kernel 6.12+ frequency monitor.
    #[genl_attr(DpllAttr::FrequencyMonitor, repr = "u32")]
    pub frequency_monitor: Option<DpllFeatureState>,
}

impl DpllDeviceSetRequest {
    /// Start a set request targeting `id`. Chain `mode(...)`,
    /// `phase_offset_monitor(...)`, etc. before sending.
    pub fn new(id: u32) -> Self {
        Self {
            id,
            ..Self::default()
        }
    }

    /// Switch the device into the given mode.
    #[must_use]
    pub fn mode(mut self, mode: DpllMode) -> Self {
        self.mode = Some(mode);
        self
    }

    /// Enable or disable the kernel 6.12+ phase-offset monitor.
    #[must_use]
    pub fn phase_offset_monitor(mut self, state: DpllFeatureState) -> Self {
        self.phase_offset_monitor = Some(state);
        self
    }

    /// Set the moving-average factor for phase-offset reporting
    /// (kernel 6.12+).
    #[must_use]
    pub fn phase_offset_avg_factor(mut self, factor: u32) -> Self {
        self.phase_offset_avg_factor = Some(factor);
        self
    }

    /// Enable or disable the kernel 6.12+ frequency monitor.
    #[must_use]
    pub fn frequency_monitor(mut self, state: DpllFeatureState) -> Self {
        self.frequency_monitor = Some(state);
        self
    }
}

impl DpllDeviceReply {
    /// Device temperature in degrees Celsius, if reported.
    /// Convenience wrapper around the raw `temp_mdeg` field.
    pub fn temp_celsius(&self) -> Option<f32> {
        self.temp_mdeg.map(|m| m as f32 / super::DPLL_TEMP_DIVIDER as f32)
    }
}

// ============================================================
// Pin-side nested attribute groups
// ============================================================

/// Inner block of `DPLL_A_PIN_PARENT_DEVICE` — links a pin to
/// its parent device with a per-link connection state.
///
/// Wire shape: nested attribute group inside the pin reply.
#[derive(NetlinkAttrs, Debug, Default, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct DpllPinParentDevice {
    /// Parent device's numeric ID.
    #[genl_attr(DpllPinAttr::ParentId)]
    pub parent_id: u32,
    /// Connection state from this pin to the parent device.
    /// `Option<>` because the kernel may report a parenting link
    /// without an active state (e.g., disabled parent).
    #[genl_attr(DpllPinAttr::State, repr = "u32")]
    pub state: Option<DpllPinState>,
}

/// Inner block of `DPLL_A_PIN_PARENT_PIN` — chains a pin to
/// another pin (mux selection).
///
/// Wire shape: nested attribute group inside the pin reply.
#[derive(NetlinkAttrs, Debug, Default, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct DpllPinParentPin {
    /// Parent pin's numeric ID.
    #[genl_attr(DpllPinAttr::ParentId)]
    pub parent_id: u32,
    /// Connection state from this pin to the parent pin.
    #[genl_attr(DpllPinAttr::State, repr = "u32")]
    pub state: Option<DpllPinState>,
}

// ============================================================
// Pin-side messages
// ============================================================

/// `DPLL_CMD_PIN_GET` request — single-pin get or full dump.
#[derive(GenlMessage, Debug, Default, Clone)]
#[genl_message(cmd = DpllCmd::PinGet)]
pub struct DpllPinGetRequest {
    /// Pin ID to query. `None` = "no filter; dump all".
    #[genl_attr(DpllPinAttr::Id)]
    pub id: Option<u32>,
}

impl DpllPinGetRequest {
    /// Construct a single-pin get request by ID.
    pub fn by_id(id: u32) -> Self {
        Self { id: Some(id) }
    }

    /// Construct a dump request (no ID filter).
    pub fn dump() -> Self {
        Self { id: None }
    }
}

/// `DPLL_CMD_PIN_GET` / `_CHANGE_NTF` reply — a pin's complete
/// state. Every supported attribute is represented; version-gated
/// fields are `Option<T>`. Repeated attributes (frequency
/// supported ranges, ESYNC supported frequencies) are `Vec<u64>`.
///
/// **Scaling fields:**
/// - `phase_offset` is attoseconds × 1000. Divide by
///   [`super::DPLL_PHASE_OFFSET_DIVIDER`] (= 1000) for ns —
///   [`Self::phase_offset_ns`] does this.
/// - `measured_frequency` is mHz × 1000. Divide by
///   [`super::DPLL_PIN_MEASURED_FREQUENCY_DIVIDER`] (= 1000) for
///   Hz — [`Self::measured_frequency_hz`] does this.
#[derive(GenlMessage, Debug, Default, Clone)]
#[genl_message(cmd = DpllCmd::PinGet)]
#[non_exhaustive]
pub struct DpllPinReply {
    /// Pin ID.
    #[genl_attr(DpllPinAttr::Id)]
    pub id: u32,
    /// Driver module name.
    #[genl_attr(DpllPinAttr::ModuleName)]
    pub module_name: String,
    /// Hardware clock ID this pin belongs to.
    #[genl_attr(DpllPinAttr::ClockId)]
    pub clock_id: u64,
    /// Vendor-supplied board label (silkscreen).
    #[genl_attr(DpllPinAttr::BoardLabel)]
    pub board_label: Option<String>,
    /// Front-panel label.
    #[genl_attr(DpllPinAttr::PanelLabel)]
    pub panel_label: Option<String>,
    /// Package-level label.
    #[genl_attr(DpllPinAttr::PackageLabel)]
    pub package_label: Option<String>,
    /// Pin kind (mux, ext, SyncE eth port, …).
    #[genl_attr(DpllPinAttr::Type, repr = "u32")]
    pub kind: Option<DpllPinType>,
    /// Direction (input / output).
    #[genl_attr(DpllPinAttr::Direction, repr = "u32")]
    pub direction: Option<DpllPinDirection>,
    /// Current frequency in Hz.
    #[genl_attr(DpllPinAttr::Frequency)]
    pub frequency: Option<u64>,
    /// Supported frequencies (repeated `DPLL_A_PIN_FREQUENCY_SUPPORTED`).
    #[genl_attr(DpllPinAttr::FrequencyMin)]
    pub frequency_min: Option<u64>,
    /// Maximum supported frequency.
    #[genl_attr(DpllPinAttr::FrequencyMax)]
    pub frequency_max: Option<u64>,
    /// Selection priority (lower wins).
    #[genl_attr(DpllPinAttr::Prio)]
    pub prio: Option<u32>,
    /// Current state.
    #[genl_attr(DpllPinAttr::State, repr = "u32")]
    pub state: Option<DpllPinState>,
    /// Capability bitmask — decides which `set_pin_*` ops the
    /// kernel will accept.
    #[genl_attr(DpllPinAttr::Capabilities, bitflags = "u32")]
    pub capabilities: DpllPinCapabilities,
    /// Nested: parent-device link block.
    #[genl_attr(DpllPinAttr::ParentDevice, nested)]
    pub parent_device: Option<DpllPinParentDevice>,
    /// Nested: parent-pin link block (only set on mux pins).
    #[genl_attr(DpllPinAttr::ParentPin, nested)]
    pub parent_pin: Option<DpllPinParentPin>,
    /// Phase-adjust minimum (picoseconds).
    #[genl_attr(DpllPinAttr::PhaseAdjustMin)]
    pub phase_adjust_min: Option<i32>,
    /// Phase-adjust maximum.
    #[genl_attr(DpllPinAttr::PhaseAdjustMax)]
    pub phase_adjust_max: Option<i32>,
    /// Current phase adjustment.
    #[genl_attr(DpllPinAttr::PhaseAdjust)]
    pub phase_adjust: Option<i32>,
    /// Measured phase offset (attoseconds × 1000 — use
    /// [`Self::phase_offset_ns`] for nanoseconds).
    #[genl_attr(DpllPinAttr::PhaseOffset)]
    pub phase_offset: Option<i32>,
    /// ESYNC carrier frequency (kernel 6.10+).
    #[genl_attr(DpllPinAttr::EsyncFrequency)]
    pub esync_frequency: Option<u64>,
    /// ESYNC pulse-width configuration (kernel 6.10+).
    #[genl_attr(DpllPinAttr::EsyncPulse)]
    pub esync_pulse: Option<u32>,
    /// Phase-adjust granularity (picoseconds, kernel 6.11+).
    #[genl_attr(DpllPinAttr::PhaseAdjustGran)]
    pub phase_adjust_gran: Option<u32>,
    /// Fractional frequency offset in parts-per-trillion
    /// (kernel 6.11+).
    #[genl_attr(DpllPinAttr::FractionalFrequencyOffsetPpt)]
    pub fractional_frequency_offset_ppt: Option<i32>,
    /// Measured frequency in mHz × 1000 (kernel 6.11+) — use
    /// [`Self::measured_frequency_hz`] for Hz.
    #[genl_attr(DpllPinAttr::MeasuredFrequency)]
    pub measured_frequency: Option<u64>,
}

impl DpllPinReply {
    /// Pin phase offset in nanoseconds, if reported.
    /// Applies the kernel's `DPLL_PHASE_OFFSET_DIVIDER = 1000`.
    pub fn phase_offset_ns(&self) -> Option<i64> {
        self.phase_offset
            .map(|p| p as i64 / super::DPLL_PHASE_OFFSET_DIVIDER)
    }

    /// Measured pin frequency in Hz, if reported (kernel 6.11+).
    /// Applies the kernel's
    /// `DPLL_PIN_MEASURED_FREQUENCY_DIVIDER = 1000`.
    pub fn measured_frequency_hz(&self) -> Option<u64> {
        self.measured_frequency
            .map(|m| m / super::DPLL_PIN_MEASURED_FREQUENCY_DIVIDER)
    }
}

/// `DPLL_CMD_PIN_SET` request — mutate a pin's state.
///
/// Construct with [`Self::new`] then chain setter methods.
/// Unset fields stay `None` and are omitted from the wire request.
#[derive(GenlMessage, Debug, Default, Clone)]
#[genl_message(cmd = DpllCmd::PinSet)]
pub struct DpllPinSetRequest {
    /// Target pin ID. Always present.
    #[genl_attr(DpllPinAttr::Id)]
    pub id: u32,
    /// New selection priority.
    #[genl_attr(DpllPinAttr::Prio)]
    pub prio: Option<u32>,
    /// New state (Connected / Disconnected / Selectable).
    #[genl_attr(DpllPinAttr::State, repr = "u32")]
    pub state: Option<DpllPinState>,
    /// New frequency in Hz.
    #[genl_attr(DpllPinAttr::Frequency)]
    pub frequency: Option<u64>,
    /// New direction (if the pin's capabilities allow changing).
    #[genl_attr(DpllPinAttr::Direction, repr = "u32")]
    pub direction: Option<DpllPinDirection>,
    /// New phase adjustment in picoseconds.
    #[genl_attr(DpllPinAttr::PhaseAdjust)]
    pub phase_adjust: Option<i32>,
}

impl DpllPinSetRequest {
    /// Start a set request targeting `id`.
    pub fn new(id: u32) -> Self {
        Self {
            id,
            ..Self::default()
        }
    }

    /// Set the pin's selection priority (lower = higher priority).
    #[must_use]
    pub fn prio(mut self, prio: u32) -> Self {
        self.prio = Some(prio);
        self
    }

    /// Set the pin's connection state.
    #[must_use]
    pub fn state(mut self, state: DpllPinState) -> Self {
        self.state = Some(state);
        self
    }

    /// Set the pin's frequency in Hz.
    #[must_use]
    pub fn frequency(mut self, hz: u64) -> Self {
        self.frequency = Some(hz);
        self
    }

    /// Set the pin's direction (input ↔ output) — only legal if
    /// the pin's `DPLL_PIN_CAPABILITIES_DIRECTION_CAN_CHANGE` bit
    /// is set in its capabilities; the kernel rejects otherwise.
    #[must_use]
    pub fn direction(mut self, direction: DpllPinDirection) -> Self {
        self.direction = Some(direction);
        self
    }

    /// Set the pin's phase adjustment in picoseconds.
    #[must_use]
    pub fn phase_adjust(mut self, ps: i32) -> Self {
        self.phase_adjust = Some(ps);
        self
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::macros::__rt;
    use crate::netlink::MessageBuilder;

    #[test]
    fn device_get_request_with_id_emits_one_attr() {
        let req = DpllDeviceGetRequest::by_id(7);
        let mut b = MessageBuilder::new(0, 0);
        let start = b.len();
        req.to_bytes(&mut b).expect("emit");
        let bytes = &b.as_bytes()[start..];

        let mut attrs: Vec<u16> = Vec::new();
        for (ty, _) in __rt::attr_iter(bytes) {
            attrs.push(ty);
        }
        assert_eq!(attrs, vec![DpllAttr::Id as u16]);
    }

    #[test]
    fn device_get_dump_emits_no_attrs() {
        let req = DpllDeviceGetRequest::dump();
        let mut b = MessageBuilder::new(0, 0);
        let start = b.len();
        req.to_bytes(&mut b).expect("emit");
        let bytes = &b.as_bytes()[start..];

        let count = __rt::attr_iter(bytes).count();
        assert_eq!(count, 0, "dump request should emit zero attrs");
    }

    #[test]
    fn device_reply_round_trips_a_full_attr_set() {
        let original = DpllDeviceReply {
            id: 42,
            module_name: "ice".to_string(),
            clock_id: 0x0011_2233_4455_6677,
            mode: Some(DpllMode::Automatic),
            mode_supported: vec![DpllMode::Manual, DpllMode::Automatic],
            lock_status: Some(DpllLockStatus::LockedHoAcq),
            temp_mdeg: Some(47_500),
            kind: Some(DpllType::Eec),
            lock_status_error: None,
            clock_quality_level: vec![DpllClockQualityLevel::ItuOpt1Prc],
            phase_offset_monitor: Some(DpllFeatureState::Enable),
            phase_offset_avg_factor: Some(8),
            frequency_monitor: Some(DpllFeatureState::Disable),
        };

        let mut b = MessageBuilder::new(0, 0);
        let start = b.len();
        original.to_bytes(&mut b).expect("emit");
        let parsed = DpllDeviceReply::from_bytes(&b.as_bytes()[start..]).expect("parse");
        // Vec fields preserve order; everything else compares directly.
        assert_eq!(parsed.id, original.id);
        assert_eq!(parsed.module_name, original.module_name);
        assert_eq!(parsed.clock_id, original.clock_id);
        assert_eq!(parsed.mode, original.mode);
        assert_eq!(parsed.mode_supported, original.mode_supported);
        assert_eq!(parsed.lock_status, original.lock_status);
        assert_eq!(parsed.temp_mdeg, original.temp_mdeg);
        assert_eq!(parsed.kind, original.kind);
        assert_eq!(parsed.lock_status_error, original.lock_status_error);
        assert_eq!(parsed.clock_quality_level, original.clock_quality_level);
        assert_eq!(parsed.phase_offset_monitor, original.phase_offset_monitor);
        assert_eq!(parsed.phase_offset_avg_factor, original.phase_offset_avg_factor);
        assert_eq!(parsed.frequency_monitor, original.frequency_monitor);
    }

    #[test]
    fn device_reply_missing_attrs_yield_defaults_and_nones() {
        let parsed = DpllDeviceReply::from_bytes(&[]).expect("parse");
        assert_eq!(parsed.id, 0);
        assert_eq!(parsed.module_name, "");
        assert_eq!(parsed.clock_id, 0);
        assert_eq!(parsed.mode, None);
        assert!(parsed.mode_supported.is_empty());
        assert_eq!(parsed.lock_status, None);
        assert_eq!(parsed.temp_mdeg, None);
        assert_eq!(parsed.kind, None);
        assert_eq!(parsed.lock_status_error, None);
        assert!(parsed.clock_quality_level.is_empty());
        assert_eq!(parsed.phase_offset_monitor, None);
        assert_eq!(parsed.frequency_monitor, None);
    }

    #[test]
    fn device_set_builder_emits_only_set_fields() {
        let req = DpllDeviceSetRequest::new(7)
            .mode(DpllMode::Manual)
            .frequency_monitor(DpllFeatureState::Enable);

        let mut b = MessageBuilder::new(0, 0);
        let start = b.len();
        req.to_bytes(&mut b).expect("emit");
        let bytes = &b.as_bytes()[start..];

        let mut attrs: Vec<u16> = Vec::new();
        for (ty, _) in __rt::attr_iter(bytes) {
            attrs.push(ty);
        }
        // id is always emitted; mode + frequency_monitor were set;
        // phase_offset_monitor + phase_offset_avg_factor were left None.
        assert_eq!(
            attrs,
            vec![
                DpllAttr::Id as u16,
                DpllAttr::Mode as u16,
                DpllAttr::FrequencyMonitor as u16,
            ]
        );
    }

    // ---- Pin-side tests --------------------------------------

    #[test]
    fn pin_get_request_with_id_emits_one_attr() {
        let req = DpllPinGetRequest::by_id(12);
        let mut b = MessageBuilder::new(0, 0);
        let start = b.len();
        req.to_bytes(&mut b).expect("emit");
        let bytes = &b.as_bytes()[start..];

        let mut attrs: Vec<u16> = Vec::new();
        for (ty, _) in __rt::attr_iter(bytes) {
            attrs.push(ty);
        }
        assert_eq!(attrs, vec![DpllPinAttr::Id as u16]);
    }

    #[test]
    fn pin_reply_round_trips_with_nested_parent_device() {
        let original = DpllPinReply {
            id: 5,
            module_name: "ice".to_string(),
            clock_id: 0xC1F0_D000,
            board_label: Some("REF0".to_string()),
            kind: Some(DpllPinType::SynceEthPort),
            direction: Some(DpllPinDirection::Input),
            frequency: Some(10_000_000),
            prio: Some(1),
            state: Some(DpllPinState::Connected),
            capabilities: DpllPinCapabilities::PRIORITY_CAN_CHANGE
                | DpllPinCapabilities::STATE_CAN_CHANGE,
            parent_device: Some(DpllPinParentDevice {
                parent_id: 42,
                state: Some(DpllPinState::Connected),
            }),
            phase_offset: Some(123_000),
            measured_frequency: Some(10_000_000_000),
            ..DpllPinReply::default()
        };

        let mut b = MessageBuilder::new(0, 0);
        let start = b.len();
        original.to_bytes(&mut b).expect("emit");
        let parsed = DpllPinReply::from_bytes(&b.as_bytes()[start..]).expect("parse");

        assert_eq!(parsed.id, 5);
        assert_eq!(parsed.module_name, "ice");
        assert_eq!(parsed.clock_id, 0xC1F0_D000);
        assert_eq!(parsed.board_label.as_deref(), Some("REF0"));
        assert_eq!(parsed.kind, Some(DpllPinType::SynceEthPort));
        assert_eq!(parsed.direction, Some(DpllPinDirection::Input));
        assert_eq!(parsed.frequency, Some(10_000_000));
        assert_eq!(parsed.prio, Some(1));
        assert_eq!(parsed.state, Some(DpllPinState::Connected));
        assert_eq!(
            parsed.capabilities,
            DpllPinCapabilities::PRIORITY_CAN_CHANGE | DpllPinCapabilities::STATE_CAN_CHANGE
        );
        let parent = parsed.parent_device.expect("parent_device present");
        assert_eq!(parent.parent_id, 42);
        assert_eq!(parent.state, Some(DpllPinState::Connected));
        assert_eq!(parsed.phase_offset, Some(123_000));
        assert_eq!(parsed.measured_frequency, Some(10_000_000_000));
    }

    #[test]
    fn pin_reply_helpers_apply_dividers() {
        let reply = DpllPinReply {
            phase_offset: Some(123_456_000),
            measured_frequency: Some(10_000_000_000),
            ..DpllPinReply::default()
        };
        assert_eq!(reply.phase_offset_ns(), Some(123_456));
        assert_eq!(reply.measured_frequency_hz(), Some(10_000_000));
        assert_eq!(DpllPinReply::default().phase_offset_ns(), None);
        assert_eq!(DpllPinReply::default().measured_frequency_hz(), None);
    }

    #[test]
    fn pin_set_builder_chains_priority_and_state() {
        let req = DpllPinSetRequest::new(7)
            .prio(2)
            .state(DpllPinState::Selectable);
        let mut b = MessageBuilder::new(0, 0);
        let start = b.len();
        req.to_bytes(&mut b).expect("emit");
        let bytes = &b.as_bytes()[start..];

        let mut attrs: Vec<u16> = Vec::new();
        for (ty, _) in __rt::attr_iter(bytes) {
            attrs.push(ty);
        }
        assert_eq!(
            attrs,
            vec![
                DpllPinAttr::Id as u16,
                DpllPinAttr::Prio as u16,
                DpllPinAttr::State as u16,
            ]
        );
    }

    #[test]
    fn temp_celsius_helper_applies_divider() {
        let reply = DpllDeviceReply {
            temp_mdeg: Some(47_500),
            ..DpllDeviceReply::default()
        };
        let c = reply.temp_celsius().expect("temp present");
        assert!((c - 47.5).abs() < f32::EPSILON, "expected 47.5°C, got {c}");
        assert_eq!(
            DpllDeviceReply::default().temp_celsius(),
            None,
            "missing temp_mdeg → None"
        );
    }
}