nlink 0.22.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
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
//! 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).
    ///
    /// Wire type: kernel `s64` per
    /// `Documentation/netlink/specs/dpll.yaml`. Plan 206 (0.19)
    /// corrected this from `Option<i32>` to `Option<i64>` — the
    /// pre-0.19 type silently truncated the high 4 bytes on LE
    /// platforms, producing nonsense readings for any offset
    /// above ~2.147 seconds in attoseconds × 1000 units
    /// (essentially always; a 1 ns offset is 1e9 in those units,
    /// well past `i32::MAX`).
    #[genl_attr(DpllPinAttr::PhaseOffset)]
    pub phase_offset: Option<i64>,
    /// 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+).
    ///
    /// The kernel emits this field as `sint` (variable-length signed
    /// integer per `nla_put_sint`) — 4 bytes if the value fits in
    /// `s32`, 8 bytes otherwise. The macro routes through
    /// `parse_sint_attr_as_i64`, so both widths parse correctly.
    ///
    /// **0.21 widening (BREAKING)**: type widened from `Option<i32>`
    /// to `Option<i64>`. Pre-0.21 the field silently failed the whole
    /// `DpllPinReply::from_bytes` parse when the kernel emitted an
    /// 8-byte sint that overflowed `i32`; SyncE bring-up typically
    /// emits such values. The widening removes that failure mode.
    /// Callers using `as i32` casts or `i32` arithmetic on the field
    /// must widen to `i64`.
    #[genl_attr(DpllPinAttr::FractionalFrequencyOffsetPpt, sint)]
    pub fractional_frequency_offset_ppt: Option<i64>,
    /// 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> {
        // Plan 206: phase_offset is now already i64; no truncation
        // cast needed.
        self.phase_offset.map(|p| p / 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)
    }

    /// Fractional frequency offset as `i64`, parsed from a raw
    /// payload byte slice without going through
    /// [`Self::from_bytes`].
    ///
    /// As of 0.21 the struct field
    /// [`Self::fractional_frequency_offset_ppt`] is already `i64`,
    /// so this helper is informational — call it when you have the
    /// raw attribute bytes but not a parsed `DpllPinReply` (e.g.
    /// when extracting a single attribute from a dump frame).
    ///
    /// Returns:
    /// - `Some(value)` when the payload contains a 4- or 8-byte
    ///   `DPLL_A_PIN_FRACTIONAL_FREQUENCY_OFFSET_PPT` attribute.
    /// - `None` when the attribute is absent.
    pub fn ffo_ppt_i64_from_payload(payload: &[u8]) -> Option<i64> {
        for (attr_type, attr_payload)
            in crate::macros::__rt::attr_iter(payload)
        {
            if attr_type as u32
                == super::types::DpllPinAttr::FractionalFrequencyOffsetPpt as u32
            {
                return crate::macros::__rt::parse_sint_attr_as_i64(attr_payload).ok();
            }
        }
        None
    }
}

/// `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);
    }

    /// Plan 206 regression — phase_offset is now `Option<i64>` so
    /// kernel values exceeding `i32::MAX` round-trip correctly.
    /// Pre-fix the value was silently truncated to the low 4 bytes
    /// on parse (`5_000_000_000` as `i32` → `705_032_704`),
    /// producing nonsense readings. A 5ns offset
    /// (5_000_000_000_000 attoseconds × 1000) is a realistic
    /// telco/PTP/SyncE value.
    #[test]
    fn pin_phase_offset_round_trips_value_above_i32_max() {
        let big_offset: i64 = 5_000_000_000_000; // 5 ns × DIVIDER
        assert!(
            big_offset > i32::MAX as i64,
            "test value must exceed i32::MAX to expose pre-fix truncation"
        );
        let original = DpllPinReply {
            id: 7,
            phase_offset: Some(big_offset),
            ..DpllPinReply::default()
        };
        let mut b = MessageBuilder::new(0, 0);
        let start = b.len();
        original.to_bytes(&mut b).expect("emit");
        let bytes = &b.as_bytes()[start..];

        let parsed = DpllPinReply::from_bytes(bytes).expect("parse");
        assert_eq!(
            parsed.phase_offset,
            Some(big_offset),
            "Plan 206 — i64 round-trip must preserve high bits"
        );
        // phase_offset_ns divides by 1000.
        assert_eq!(parsed.phase_offset_ns(), Some(5_000_000_000));
    }

    #[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"
        );
    }

    // -- Plan 226 DPLL FFO sint coverage ----------------------------

    /// Plan 226 (0.20.1) — small FFO values (in i32 range) still
    /// parse correctly through the new sint path.
    #[test]
    fn plan_226_ffo_ppt_small_value_round_trips_via_sint() {
        let mut b = MessageBuilder::new(0, 0);
        let start = b.len();
        // Emit as 4-byte sint (mimics what the kernel does for
        // values that fit in s32).
        __rt::emit_sint_attr(
            &mut b,
            DpllPinAttr::FractionalFrequencyOffsetPpt as u16,
            -42_i64,
        );
        let bytes = &b.as_bytes()[start..];

        // The macro-derived parse arm uses parse_sint_attr_as_i32;
        // a 4-byte payload becomes Some(-42).
        let parsed = DpllPinReply::from_bytes(bytes).expect("parse");
        assert_eq!(parsed.fractional_frequency_offset_ppt, Some(-42));
    }

    /// Plan 226 (0.20.1) — 8-byte FFO that fits in i32 parses cleanly
    /// (pre-Plan-226 would have silently truncated to the low 32 bits).
    #[test]
    fn plan_226_ffo_ppt_8_byte_in_range_parses_correctly() {
        let mut b = MessageBuilder::new(0, 0);
        let start = b.len();
        // Force 8-byte emit by routing through the u64-attr path
        // (mimics a kernel that ships a small value via the wider
        // sint width — e.g. a future kernel taking the always-8-byte
        // path for sint emission).
        b.append_attr_u64(
            DpllPinAttr::FractionalFrequencyOffsetPpt as u16,
            1_000_000_i64 as u64,
        );
        let bytes = &b.as_bytes()[start..];

        let parsed = DpllPinReply::from_bytes(bytes).expect("parse");
        assert_eq!(
            parsed.fractional_frequency_offset_ppt,
            Some(1_000_000),
            "8-byte sint payload that fits in i32 must parse exactly \
             (Plan 226 fix; pre-fix this would silently truncate)"
        );
    }

    /// 0.21 widening — 8-byte FFO that overflows `i32` now parses
    /// cleanly into the widened `i64` field. Pre-0.21 this would have
    /// failed the entire `from_bytes` call and forced callers through
    /// `ffo_ppt_i64_from_payload`; post-0.21 the struct field holds
    /// the full-width value directly.
    #[test]
    fn ffo_ppt_8_byte_overflow_parses_into_i64_field() {
        let mut b = MessageBuilder::new(0, 0);
        let start = b.len();
        let overflow = i32::MAX as i64 + 1_000_000;
        b.append_attr_u64(
            DpllPinAttr::FractionalFrequencyOffsetPpt as u16,
            overflow as u64,
        );
        let bytes = &b.as_bytes()[start..];

        // 0.21: parse succeeds, field holds the full-width value.
        let parsed = DpllPinReply::from_bytes(bytes).expect("parse");
        assert_eq!(
            parsed.fractional_frequency_offset_ppt,
            Some(overflow),
            "0.21 widening: i32-overflowing FFO now parses cleanly into i64 field"
        );

        // The raw-payload helper still returns the same value (kept for
        // callers that have the bytes but not a parsed struct).
        let recovered = DpllPinReply::ffo_ppt_i64_from_payload(bytes);
        assert_eq!(recovered, Some(overflow));
    }

    /// Plan 226 (0.20.1) — the helper returns None when the FFO
    /// attribute is absent (rather than spuriously synthesizing a
    /// value).
    #[test]
    fn plan_226_ffo_helper_returns_none_when_attribute_absent() {
        let mut b = MessageBuilder::new(0, 0);
        let start = b.len();
        b.append_attr_u32(DpllPinAttr::Id as u16, 7);
        let bytes = &b.as_bytes()[start..];

        assert_eq!(
            DpllPinReply::ffo_ppt_i64_from_payload(bytes),
            None,
            "absent FFO attr → None"
        );
    }
}