Skip to main content

sbf_tools/blocks/
time.rs

1//! Time blocks (ReceiverTime, xPPSOffset, ExtEvent, ExtEventPVT, EndOfPVT)
2
3use crate::error::{SbfError, SbfResult};
4use crate::header::SbfHeader;
5use crate::types::{PvtError, PvtMode};
6
7use super::block_ids;
8use super::dnu::{f32_or_none, f64_or_none, u8_or_none, U16_DNU};
9use super::position::BaseVectorGeodBlock;
10use super::SbfBlockParse;
11
12#[cfg(test)]
13use super::dnu::{F32_DNU, F64_DNU};
14
15// ============================================================================
16// Constants
17// ============================================================================
18
19// ============================================================================
20// ReceiverTime Block
21// ============================================================================
22
23/// ReceiverTime block (Block ID 5914)
24///
25/// UTC time from the receiver.
26#[derive(Debug, Clone)]
27pub struct ReceiverTimeBlock {
28    tow_ms: u32,
29    wnc: u16,
30    /// UTC year
31    pub utc_year: i16,
32    /// UTC month (1-12)
33    pub utc_month: u8,
34    /// UTC day (1-31)
35    pub utc_day: u8,
36    /// UTC hour (0-23)
37    pub utc_hour: u8,
38    /// UTC minute (0-59)
39    pub utc_minute: u8,
40    /// UTC second (0-60, 60 for leap second)
41    pub utc_second: u8,
42    /// Leap seconds (TAI - UTC)
43    pub delta_ls: i8,
44    /// Synchronization level
45    pub sync_level: u8,
46}
47
48impl ReceiverTimeBlock {
49    pub fn tow_seconds(&self) -> f64 {
50        self.tow_ms as f64 * 0.001
51    }
52    pub fn tow_ms(&self) -> u32 {
53        self.tow_ms
54    }
55    pub fn wnc(&self) -> u16 {
56        self.wnc
57    }
58
59    /// Get UTC time as a formatted string (ISO 8601)
60    pub fn utc_string(&self) -> String {
61        format!(
62            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
63            self.utc_year,
64            self.utc_month,
65            self.utc_day,
66            self.utc_hour,
67            self.utc_minute,
68            self.utc_second
69        )
70    }
71
72    /// Check if time is valid
73    pub fn is_valid(&self) -> bool {
74        self.utc_year >= 2000
75            && self.utc_month >= 1
76            && self.utc_month <= 12
77            && self.utc_day >= 1
78            && self.utc_day <= 31
79            && self.utc_hour <= 23
80            && self.utc_minute <= 59
81            && self.utc_second <= 60
82    }
83
84    /// Get sync level description
85    pub fn sync_level_desc(&self) -> &'static str {
86        match self.sync_level {
87            0 => "Not synchronized",
88            1 => "Approximate time",
89            2 => "Coarse time",
90            3 => "Fine time (PVT)",
91            4 => "Fine time (PPS)",
92            _ => "Unknown",
93        }
94    }
95
96    /// Check if time is fully synchronized
97    pub fn is_synchronized(&self) -> bool {
98        self.sync_level >= 3
99    }
100}
101
102impl SbfBlockParse for ReceiverTimeBlock {
103    const BLOCK_ID: u16 = block_ids::RECEIVER_TIME;
104
105    fn parse(header: &SbfHeader, data: &[u8]) -> SbfResult<Self> {
106        if data.len() < 20 {
107            return Err(SbfError::ParseError("ReceiverTime too short".into()));
108        }
109
110        // Offsets:
111        // 12: UTCYear (i1, 2-digit year 0..99, or -128 if unavailable)
112        // 13: UTCMonth
113        // 14: UTCDay
114        // 15: UTCHour
115        // 16: UTCMin
116        // 17: UTCSec
117        // 18: DeltaLS
118        // 19: SyncLevel
119
120        let utc_year_raw = data[12] as i8;
121        let utc_year = if utc_year_raw == i8::MIN {
122            i8::MIN as i16
123        } else {
124            2000 + utc_year_raw as i16
125        };
126        let utc_month = data[13];
127        let utc_day = data[14];
128        let utc_hour = data[15];
129        let utc_minute = data[16];
130        let utc_second = data[17];
131        let delta_ls = data[18] as i8;
132        let sync_level = data[19];
133
134        Ok(Self {
135            tow_ms: header.tow_ms,
136            wnc: header.wnc,
137            utc_year,
138            utc_month,
139            utc_day,
140            utc_hour,
141            utc_minute,
142            utc_second,
143            delta_ls,
144            sync_level,
145        })
146    }
147}
148
149// ============================================================================
150// xPPSOffset Block
151// ============================================================================
152
153/// xPPSOffset block (Block ID 5911)
154///
155/// PPS offset and synchronization details.
156#[derive(Debug, Clone)]
157pub struct PpsOffsetBlock {
158    tow_ms: u32,
159    wnc: u16,
160    /// Age of synchronization (s)
161    pub sync_age: u8,
162    /// Time scale code
163    pub timescale: u8,
164    /// PPS offset (ns)
165    offset_ns: f32,
166}
167
168impl PpsOffsetBlock {
169    pub fn tow_seconds(&self) -> f64 {
170        self.tow_ms as f64 * 0.001
171    }
172    pub fn tow_ms(&self) -> u32 {
173        self.tow_ms
174    }
175    pub fn wnc(&self) -> u16 {
176        self.wnc
177    }
178
179    /// Offset in nanoseconds (None if DNU)
180    pub fn offset_ns(&self) -> Option<f32> {
181        f32_or_none(self.offset_ns)
182    }
183
184    /// Offset in seconds (None if DNU)
185    pub fn offset_seconds(&self) -> Option<f64> {
186        self.offset_ns().map(|value| value as f64 * 1e-9)
187    }
188}
189
190impl SbfBlockParse for PpsOffsetBlock {
191    const BLOCK_ID: u16 = block_ids::PPS_OFFSET;
192
193    fn parse(header: &SbfHeader, data: &[u8]) -> SbfResult<Self> {
194        if data.len() < 18 {
195            return Err(SbfError::ParseError("xPPSOffset too short".into()));
196        }
197
198        let sync_age = data[12];
199        let timescale = data[13];
200        let offset_ns = f32::from_le_bytes(data[14..18].try_into().unwrap());
201
202        Ok(Self {
203            tow_ms: header.tow_ms,
204            wnc: header.wnc,
205            sync_age,
206            timescale,
207            offset_ns,
208        })
209    }
210}
211
212// ============================================================================
213// ExtEvent Block
214// ============================================================================
215
216/// ExtEvent block (Block ID 5924)
217///
218/// External event timing information.
219#[derive(Debug, Clone)]
220pub struct ExtEventBlock {
221    tow_ms: u32,
222    wnc: u16,
223    /// Event source
224    pub source: u8,
225    /// Edge polarity
226    pub polarity: u8,
227    /// Event offset from TOW in seconds (ExtEvent 5924 uses 1 s units).
228    offset_s: f32,
229    /// Clock bias in seconds (ExtEvent 5924 uses 1 s units).
230    rx_clk_bias_s: f64,
231    /// PVT solution age
232    pub pvt_age: u16,
233}
234
235impl ExtEventBlock {
236    pub fn tow_seconds(&self) -> f64 {
237        self.tow_ms as f64 * 0.001
238    }
239    pub fn tow_ms(&self) -> u32 {
240        self.tow_ms
241    }
242    pub fn wnc(&self) -> u16 {
243        self.wnc
244    }
245
246    /// Event offset in seconds (None if DNU)
247    pub fn offset_seconds(&self) -> Option<f64> {
248        f32_or_none(self.offset_s).map(|value| value as f64)
249    }
250
251    /// Event offset in nanoseconds (legacy convenience conversion)
252    pub fn offset_ns(&self) -> Option<f32> {
253        self.offset_seconds().map(|value| (value * 1e9) as f32)
254    }
255
256    /// Receiver clock bias in seconds (None if DNU)
257    pub fn rx_clk_bias_seconds(&self) -> Option<f64> {
258        f64_or_none(self.rx_clk_bias_s)
259    }
260
261    /// Receiver clock bias in ms (None if DNU)
262    ///
263    /// Kept for compatibility with existing ms-based callers.
264    pub fn rx_clk_bias_ms(&self) -> Option<f64> {
265        self.rx_clk_bias_seconds().map(|value| value * 1e3)
266    }
267}
268
269impl SbfBlockParse for ExtEventBlock {
270    const BLOCK_ID: u16 = block_ids::EXT_EVENT;
271
272    fn parse(header: &SbfHeader, data: &[u8]) -> SbfResult<Self> {
273        if data.len() < 28 {
274            return Err(SbfError::ParseError("ExtEvent too short".into()));
275        }
276
277        let source = data[12];
278        let polarity = data[13];
279        let offset_s = f32::from_le_bytes(data[14..18].try_into().unwrap());
280        let rx_clk_bias_s = f64::from_le_bytes(data[18..26].try_into().unwrap());
281        let pvt_age = u16::from_le_bytes([data[26], data[27]]);
282
283        Ok(Self {
284            tow_ms: header.tow_ms,
285            wnc: header.wnc,
286            source,
287            polarity,
288            offset_s,
289            rx_clk_bias_s,
290            pvt_age,
291        })
292    }
293}
294
295// ============================================================================
296// ExtEventPVT Blocks
297// ============================================================================
298
299/// ExtEventPVTCartesian block (Block ID 4037)
300///
301/// External event timing with PVT solution in ECEF coordinates.
302#[derive(Debug, Clone)]
303pub struct ExtEventPvtCartesianBlock {
304    tow_ms: u32,
305    wnc: u16,
306    mode: u8,
307    error: u8,
308    x_m: f64,
309    y_m: f64,
310    z_m: f64,
311    undulation_m: f32,
312    vx_mps: f32,
313    vy_mps: f32,
314    vz_mps: f32,
315    cog_deg: f32,
316    rx_clk_bias_ms: f64,
317    rx_clk_drift_ppm: f32,
318    pub time_system: u8,
319    pub datum: u8,
320    nr_sv: u8,
321    pub wa_corr_info: u8,
322    pub reference_id: u16,
323    mean_corr_age_raw: u16,
324    pub signal_info: u32,
325    pub alert_flag: u8,
326    pub nr_bases: u8,
327}
328
329impl ExtEventPvtCartesianBlock {
330    pub fn tow_seconds(&self) -> f64 {
331        self.tow_ms as f64 * 0.001
332    }
333    pub fn tow_ms(&self) -> u32 {
334        self.tow_ms
335    }
336    pub fn wnc(&self) -> u16 {
337        self.wnc
338    }
339
340    pub fn mode(&self) -> PvtMode {
341        PvtMode::from_mode_byte(self.mode)
342    }
343    pub fn mode_raw(&self) -> u8 {
344        self.mode
345    }
346    pub fn error(&self) -> PvtError {
347        PvtError::from_error_byte(self.error)
348    }
349    pub fn error_raw(&self) -> u8 {
350        self.error
351    }
352    pub fn has_fix(&self) -> bool {
353        self.mode().has_fix() && self.error().is_ok()
354    }
355
356    // ECEF position
357    pub fn x_m(&self) -> Option<f64> {
358        f64_or_none(self.x_m)
359    }
360    pub fn y_m(&self) -> Option<f64> {
361        f64_or_none(self.y_m)
362    }
363    pub fn z_m(&self) -> Option<f64> {
364        f64_or_none(self.z_m)
365    }
366    pub fn undulation_m(&self) -> Option<f32> {
367        f32_or_none(self.undulation_m)
368    }
369
370    // Velocity
371    pub fn vx_mps(&self) -> Option<f32> {
372        f32_or_none(self.vx_mps)
373    }
374    pub fn vy_mps(&self) -> Option<f32> {
375        f32_or_none(self.vy_mps)
376    }
377    pub fn vz_mps(&self) -> Option<f32> {
378        f32_or_none(self.vz_mps)
379    }
380    pub fn course_over_ground_deg(&self) -> Option<f32> {
381        f32_or_none(self.cog_deg)
382    }
383
384    // Clock
385    pub fn clock_bias_ms(&self) -> Option<f64> {
386        f64_or_none(self.rx_clk_bias_ms)
387    }
388    pub fn clock_drift_ppm(&self) -> Option<f32> {
389        f32_or_none(self.rx_clk_drift_ppm)
390    }
391
392    // Correction age
393    pub fn mean_corr_age_seconds(&self) -> Option<f32> {
394        if self.mean_corr_age_raw == U16_DNU {
395            None
396        } else {
397            Some(self.mean_corr_age_raw as f32 * 0.01)
398        }
399    }
400    pub fn mean_corr_age_raw(&self) -> u16 {
401        self.mean_corr_age_raw
402    }
403
404    /// Number of satellites used in the PVT computation.
405    ///
406    /// Returns `0` when the SBF `NrSV` field is not available (`255`). Use
407    /// [`Self::num_satellites_opt`] to distinguish unavailable from a real zero.
408    pub fn num_satellites(&self) -> u8 {
409        u8_or_none(self.nr_sv).unwrap_or(0)
410    }
411    /// Number of satellites used in the PVT computation, or `None` when unavailable.
412    pub fn num_satellites_opt(&self) -> Option<u8> {
413        u8_or_none(self.nr_sv)
414    }
415    /// Raw `NrSV` field from the SBF block.
416    pub fn num_satellites_raw(&self) -> u8 {
417        self.nr_sv
418    }
419}
420
421impl SbfBlockParse for ExtEventPvtCartesianBlock {
422    const BLOCK_ID: u16 = block_ids::EXT_EVENT_PVT_CARTESIAN;
423
424    fn parse(header: &SbfHeader, data: &[u8]) -> SbfResult<Self> {
425        if data.len() < 84 {
426            return Err(SbfError::ParseError(
427                "ExtEventPVTCartesian too short".into(),
428            ));
429        }
430
431        let mode = data[12];
432        let error = data[13];
433        let x_m = f64::from_le_bytes(data[14..22].try_into().unwrap());
434        let y_m = f64::from_le_bytes(data[22..30].try_into().unwrap());
435        let z_m = f64::from_le_bytes(data[30..38].try_into().unwrap());
436        let undulation_m = f32::from_le_bytes(data[38..42].try_into().unwrap());
437        let vx_mps = f32::from_le_bytes(data[42..46].try_into().unwrap());
438        let vy_mps = f32::from_le_bytes(data[46..50].try_into().unwrap());
439        let vz_mps = f32::from_le_bytes(data[50..54].try_into().unwrap());
440        let cog_deg = f32::from_le_bytes(data[54..58].try_into().unwrap());
441        let rx_clk_bias_ms = f64::from_le_bytes(data[58..66].try_into().unwrap());
442        let rx_clk_drift_ppm = f32::from_le_bytes(data[66..70].try_into().unwrap());
443        let time_system = data[70];
444        let datum = data[71];
445        let nr_sv = data[72];
446        let wa_corr_info = data[73];
447        let reference_id = u16::from_le_bytes([data[74], data[75]]);
448        let mean_corr_age_raw = u16::from_le_bytes([data[76], data[77]]);
449        let signal_info = u32::from_le_bytes(data[78..82].try_into().unwrap());
450        let alert_flag = data[82];
451        let nr_bases = data[83];
452
453        Ok(Self {
454            tow_ms: header.tow_ms,
455            wnc: header.wnc,
456            mode,
457            error,
458            x_m,
459            y_m,
460            z_m,
461            undulation_m,
462            vx_mps,
463            vy_mps,
464            vz_mps,
465            cog_deg,
466            rx_clk_bias_ms,
467            rx_clk_drift_ppm,
468            time_system,
469            datum,
470            nr_sv,
471            wa_corr_info,
472            reference_id,
473            mean_corr_age_raw,
474            signal_info,
475            alert_flag,
476            nr_bases,
477        })
478    }
479}
480
481/// ExtEventPVTGeodetic block (Block ID 4038)
482///
483/// External event timing with PVT solution in geodetic coordinates.
484#[derive(Debug, Clone)]
485pub struct ExtEventPvtGeodeticBlock {
486    tow_ms: u32,
487    wnc: u16,
488    mode: u8,
489    error: u8,
490    latitude_rad: f64,
491    longitude_rad: f64,
492    height_m: f64,
493    undulation_m: f32,
494    vn_mps: f32,
495    ve_mps: f32,
496    vu_mps: f32,
497    cog_deg: f32,
498    rx_clk_bias_ms: f64,
499    rx_clk_drift_ppm: f32,
500    pub time_system: u8,
501    pub datum: u8,
502    nr_sv: u8,
503    pub wa_corr_info: u8,
504    pub reference_id: u16,
505    mean_corr_age_raw: u16,
506    pub signal_info: u32,
507    pub alert_flag: u8,
508    pub nr_bases: u8,
509}
510
511impl ExtEventPvtGeodeticBlock {
512    pub fn tow_seconds(&self) -> f64 {
513        self.tow_ms as f64 * 0.001
514    }
515    pub fn tow_ms(&self) -> u32 {
516        self.tow_ms
517    }
518    pub fn wnc(&self) -> u16 {
519        self.wnc
520    }
521
522    pub fn mode(&self) -> PvtMode {
523        PvtMode::from_mode_byte(self.mode)
524    }
525    pub fn mode_raw(&self) -> u8 {
526        self.mode
527    }
528    pub fn error(&self) -> PvtError {
529        PvtError::from_error_byte(self.error)
530    }
531    pub fn error_raw(&self) -> u8 {
532        self.error
533    }
534    pub fn has_fix(&self) -> bool {
535        self.mode().has_fix() && self.error().is_ok()
536    }
537
538    // Position
539    pub fn latitude_deg(&self) -> Option<f64> {
540        f64_or_none(self.latitude_rad).map(|value| value.to_degrees())
541    }
542    pub fn longitude_deg(&self) -> Option<f64> {
543        f64_or_none(self.longitude_rad).map(|value| value.to_degrees())
544    }
545    pub fn latitude_rad(&self) -> f64 {
546        self.latitude_rad
547    }
548    pub fn longitude_rad(&self) -> f64 {
549        self.longitude_rad
550    }
551    pub fn height_m(&self) -> Option<f64> {
552        f64_or_none(self.height_m)
553    }
554    pub fn undulation_m(&self) -> Option<f32> {
555        f32_or_none(self.undulation_m)
556    }
557
558    // Velocity
559    pub fn velocity_north_mps(&self) -> Option<f32> {
560        f32_or_none(self.vn_mps)
561    }
562    pub fn velocity_east_mps(&self) -> Option<f32> {
563        f32_or_none(self.ve_mps)
564    }
565    pub fn velocity_up_mps(&self) -> Option<f32> {
566        f32_or_none(self.vu_mps)
567    }
568    pub fn course_over_ground_deg(&self) -> Option<f32> {
569        f32_or_none(self.cog_deg)
570    }
571
572    // Clock
573    pub fn clock_bias_ms(&self) -> Option<f64> {
574        f64_or_none(self.rx_clk_bias_ms)
575    }
576    pub fn clock_drift_ppm(&self) -> Option<f32> {
577        f32_or_none(self.rx_clk_drift_ppm)
578    }
579
580    // Correction age
581    pub fn mean_corr_age_seconds(&self) -> Option<f32> {
582        if self.mean_corr_age_raw == U16_DNU {
583            None
584        } else {
585            Some(self.mean_corr_age_raw as f32 * 0.01)
586        }
587    }
588    pub fn mean_corr_age_raw(&self) -> u16 {
589        self.mean_corr_age_raw
590    }
591
592    /// Number of satellites used in the PVT computation.
593    ///
594    /// Returns `0` when the SBF `NrSV` field is not available (`255`). Use
595    /// [`Self::num_satellites_opt`] to distinguish unavailable from a real zero.
596    pub fn num_satellites(&self) -> u8 {
597        u8_or_none(self.nr_sv).unwrap_or(0)
598    }
599    /// Number of satellites used in the PVT computation, or `None` when unavailable.
600    pub fn num_satellites_opt(&self) -> Option<u8> {
601        u8_or_none(self.nr_sv)
602    }
603    /// Raw `NrSV` field from the SBF block.
604    pub fn num_satellites_raw(&self) -> u8 {
605        self.nr_sv
606    }
607}
608
609impl SbfBlockParse for ExtEventPvtGeodeticBlock {
610    const BLOCK_ID: u16 = block_ids::EXT_EVENT_PVT_GEODETIC;
611
612    fn parse(header: &SbfHeader, data: &[u8]) -> SbfResult<Self> {
613        if data.len() < 84 {
614            return Err(SbfError::ParseError("ExtEventPVTGeodetic too short".into()));
615        }
616
617        let mode = data[12];
618        let error = data[13];
619        let latitude_rad = f64::from_le_bytes(data[14..22].try_into().unwrap());
620        let longitude_rad = f64::from_le_bytes(data[22..30].try_into().unwrap());
621        let height_m = f64::from_le_bytes(data[30..38].try_into().unwrap());
622        let undulation_m = f32::from_le_bytes(data[38..42].try_into().unwrap());
623        let vn_mps = f32::from_le_bytes(data[42..46].try_into().unwrap());
624        let ve_mps = f32::from_le_bytes(data[46..50].try_into().unwrap());
625        let vu_mps = f32::from_le_bytes(data[50..54].try_into().unwrap());
626        let cog_deg = f32::from_le_bytes(data[54..58].try_into().unwrap());
627        let rx_clk_bias_ms = f64::from_le_bytes(data[58..66].try_into().unwrap());
628        let rx_clk_drift_ppm = f32::from_le_bytes(data[66..70].try_into().unwrap());
629        let time_system = data[70];
630        let datum = data[71];
631        let nr_sv = data[72];
632        let wa_corr_info = data[73];
633        let reference_id = u16::from_le_bytes([data[74], data[75]]);
634        let mean_corr_age_raw = u16::from_le_bytes([data[76], data[77]]);
635        let signal_info = u32::from_le_bytes(data[78..82].try_into().unwrap());
636        let alert_flag = data[82];
637        let nr_bases = data[83];
638
639        Ok(Self {
640            tow_ms: header.tow_ms,
641            wnc: header.wnc,
642            mode,
643            error,
644            latitude_rad,
645            longitude_rad,
646            height_m,
647            undulation_m,
648            vn_mps,
649            ve_mps,
650            vu_mps,
651            cog_deg,
652            rx_clk_bias_ms,
653            rx_clk_drift_ppm,
654            time_system,
655            datum,
656            nr_sv,
657            wa_corr_info,
658            reference_id,
659            mean_corr_age_raw,
660            signal_info,
661            alert_flag,
662            nr_bases,
663        })
664    }
665}
666
667// ============================================================================
668// ExtEventBaseVectGeod / ExtEventAttEuler
669// ============================================================================
670
671/// ExtEventBaseVectGeod (4217) — base vectors at event time.
672#[derive(Debug, Clone)]
673pub struct ExtEventBaseVectGeodBlock(pub BaseVectorGeodBlock);
674
675impl std::ops::Deref for ExtEventBaseVectGeodBlock {
676    type Target = BaseVectorGeodBlock;
677
678    fn deref(&self) -> &Self::Target {
679        &self.0
680    }
681}
682
683impl SbfBlockParse for ExtEventBaseVectGeodBlock {
684    const BLOCK_ID: u16 = block_ids::EXT_EVENT_BASE_VECT_GEOD;
685
686    fn parse(header: &SbfHeader, data: &[u8]) -> SbfResult<Self> {
687        Ok(Self(BaseVectorGeodBlock::parse(header, data)?))
688    }
689}
690
691/// ExtEventAttEuler (4237) — attitude at event time.
692#[derive(Debug, Clone)]
693pub struct ExtEventAttEulerBlock {
694    tow_ms: u32,
695    wnc: u16,
696    nr_sv: u8,
697    error: u8,
698    mode: u16,
699    heading_deg: f32,
700    pitch_deg: f32,
701    roll_deg: f32,
702    pitch_rate_dps: f32,
703    roll_rate_dps: f32,
704    heading_rate_dps: f32,
705}
706
707impl ExtEventAttEulerBlock {
708    pub fn tow_seconds(&self) -> f64 {
709        self.tow_ms as f64 * 0.001
710    }
711    pub fn tow_ms(&self) -> u32 {
712        self.tow_ms
713    }
714    pub fn wnc(&self) -> u16 {
715        self.wnc
716    }
717    /// Number of satellites included in attitude calculations.
718    ///
719    /// Returns `0` when the SBF `NrSV` field is not available (`255`). Use
720    /// [`Self::num_satellites_opt`] to distinguish unavailable from a real zero.
721    pub fn num_satellites(&self) -> u8 {
722        u8_or_none(self.nr_sv).unwrap_or(0)
723    }
724    /// Number of satellites included in attitude calculations, or `None` when unavailable.
725    pub fn num_satellites_opt(&self) -> Option<u8> {
726        u8_or_none(self.nr_sv)
727    }
728    /// Raw `NrSV` field from the SBF block.
729    pub fn num_satellites_raw(&self) -> u8 {
730        self.nr_sv
731    }
732    pub fn error_raw(&self) -> u8 {
733        self.error
734    }
735    pub fn mode_raw(&self) -> u16 {
736        self.mode
737    }
738    pub fn heading_deg(&self) -> Option<f32> {
739        f32_or_none(self.heading_deg)
740    }
741    pub fn pitch_deg(&self) -> Option<f32> {
742        f32_or_none(self.pitch_deg)
743    }
744    pub fn roll_deg(&self) -> Option<f32> {
745        f32_or_none(self.roll_deg)
746    }
747    pub fn pitch_rate_dps(&self) -> Option<f32> {
748        f32_or_none(self.pitch_rate_dps)
749    }
750    pub fn roll_rate_dps(&self) -> Option<f32> {
751        f32_or_none(self.roll_rate_dps)
752    }
753    pub fn heading_rate_dps(&self) -> Option<f32> {
754        f32_or_none(self.heading_rate_dps)
755    }
756}
757
758impl SbfBlockParse for ExtEventAttEulerBlock {
759    const BLOCK_ID: u16 = block_ids::EXT_EVENT_ATT_EULER;
760
761    fn parse(header: &SbfHeader, data: &[u8]) -> SbfResult<Self> {
762        if data.len() < 42 {
763            return Err(SbfError::ParseError("ExtEventAttEuler too short".into()));
764        }
765        Ok(Self {
766            tow_ms: header.tow_ms,
767            wnc: header.wnc,
768            nr_sv: data[12],
769            error: data[13],
770            mode: u16::from_le_bytes(data[14..16].try_into().unwrap()),
771            heading_deg: f32::from_le_bytes(data[18..22].try_into().unwrap()),
772            pitch_deg: f32::from_le_bytes(data[22..26].try_into().unwrap()),
773            roll_deg: f32::from_le_bytes(data[26..30].try_into().unwrap()),
774            pitch_rate_dps: f32::from_le_bytes(data[30..34].try_into().unwrap()),
775            roll_rate_dps: f32::from_le_bytes(data[34..38].try_into().unwrap()),
776            heading_rate_dps: f32::from_le_bytes(data[38..42].try_into().unwrap()),
777        })
778    }
779}
780
781// ============================================================================
782// EndOfPVT Block
783// ============================================================================
784
785/// EndOfPVT block (Block ID 5921)
786///
787/// Marker indicating end of PVT-related blocks for current epoch.
788#[derive(Debug, Clone)]
789pub struct EndOfPvtBlock {
790    tow_ms: u32,
791    wnc: u16,
792}
793
794impl EndOfPvtBlock {
795    pub fn tow_seconds(&self) -> f64 {
796        self.tow_ms as f64 * 0.001
797    }
798    pub fn tow_ms(&self) -> u32 {
799        self.tow_ms
800    }
801    pub fn wnc(&self) -> u16 {
802        self.wnc
803    }
804}
805
806impl SbfBlockParse for EndOfPvtBlock {
807    const BLOCK_ID: u16 = block_ids::END_OF_PVT;
808
809    fn parse(header: &SbfHeader, data: &[u8]) -> SbfResult<Self> {
810        // Minimal block - just TOW and WNc from header
811        if data.len() < 12 {
812            return Err(SbfError::ParseError("EndOfPVT too short".into()));
813        }
814
815        Ok(Self {
816            tow_ms: header.tow_ms,
817            wnc: header.wnc,
818        })
819    }
820}
821
822#[cfg(test)]
823mod tests {
824    use super::*;
825    use crate::header::SbfHeader;
826
827    fn header_for(block_id: u16, data_len: usize, tow_ms: u32, wnc: u16) -> SbfHeader {
828        SbfHeader {
829            crc: 0,
830            block_id,
831            block_rev: 0,
832            length: (data_len + 2) as u16,
833            tow_ms,
834            wnc,
835        }
836    }
837
838    #[test]
839    fn test_receiver_time_string() {
840        let time = ReceiverTimeBlock {
841            tow_ms: 0,
842            wnc: 0,
843            utc_year: 2024,
844            utc_month: 3,
845            utc_day: 15,
846            utc_hour: 12,
847            utc_minute: 30,
848            utc_second: 45,
849            delta_ls: 18,
850            sync_level: 4,
851        };
852
853        assert_eq!(time.utc_string(), "2024-03-15T12:30:45Z");
854        assert!(time.is_valid());
855        assert!(time.is_synchronized());
856    }
857
858    #[test]
859    fn test_receiver_time_validation() {
860        let invalid_time = ReceiverTimeBlock {
861            tow_ms: 0,
862            wnc: 0,
863            utc_year: 1999, // Invalid
864            utc_month: 13,  // Invalid
865            utc_day: 1,
866            utc_hour: 0,
867            utc_minute: 0,
868            utc_second: 0,
869            delta_ls: 0,
870            sync_level: 0,
871        };
872
873        assert!(!invalid_time.is_valid());
874        assert!(!invalid_time.is_synchronized());
875    }
876
877    #[test]
878    fn test_sync_levels() {
879        let time = ReceiverTimeBlock {
880            tow_ms: 0,
881            wnc: 0,
882            utc_year: 2024,
883            utc_month: 1,
884            utc_day: 1,
885            utc_hour: 0,
886            utc_minute: 0,
887            utc_second: 0,
888            delta_ls: 18,
889            sync_level: 0,
890        };
891        assert_eq!(time.sync_level_desc(), "Not synchronized");
892
893        let time_sync = ReceiverTimeBlock {
894            sync_level: 4,
895            ..time
896        };
897        assert_eq!(time_sync.sync_level_desc(), "Fine time (PPS)");
898    }
899
900    #[test]
901    fn test_pps_offset_accessors() {
902        let block = PpsOffsetBlock {
903            tow_ms: 1500,
904            wnc: 2100,
905            sync_age: 3,
906            timescale: 1,
907            offset_ns: F32_DNU,
908        };
909
910        assert!((block.tow_seconds() - 1.5).abs() < 1e-6);
911        assert!(block.offset_ns().is_none());
912        assert!(block.offset_seconds().is_none());
913    }
914
915    #[test]
916    fn test_pps_offset_parse() {
917        let mut data = vec![0u8; 18];
918        data[12] = 2;
919        data[13] = 1;
920        data[14..18].copy_from_slice(&2500.0_f32.to_le_bytes());
921
922        let header = header_for(block_ids::PPS_OFFSET, data.len(), 5000, 2200);
923        let block = PpsOffsetBlock::parse(&header, &data).unwrap();
924
925        assert_eq!(block.sync_age, 2);
926        assert_eq!(block.timescale, 1);
927        assert!((block.offset_ns().unwrap() - 2500.0).abs() < 1e-6);
928    }
929
930    #[test]
931    fn test_ext_event_accessors() {
932        let block = ExtEventBlock {
933            tow_ms: 2500,
934            wnc: 2300,
935            source: 1,
936            polarity: 0,
937            offset_s: F32_DNU,
938            rx_clk_bias_s: F64_DNU,
939            pvt_age: 15,
940        };
941
942        assert!((block.tow_seconds() - 2.5).abs() < 1e-6);
943        assert!(block.offset_seconds().is_none());
944        assert!(block.offset_ns().is_none());
945        assert!(block.rx_clk_bias_seconds().is_none());
946        assert!(block.rx_clk_bias_ms().is_none());
947    }
948
949    #[test]
950    fn test_ext_event_parse() {
951        let mut data = vec![0u8; 28];
952        data[12] = 2;
953        data[13] = 1;
954        data[14..18].copy_from_slice(&0.125_f32.to_le_bytes());
955        data[18..26].copy_from_slice(&(-0.25_f64).to_le_bytes());
956        data[26..28].copy_from_slice(&20_u16.to_le_bytes());
957
958        let header = header_for(block_ids::EXT_EVENT, data.len(), 6000, 2400);
959        let block = ExtEventBlock::parse(&header, &data).unwrap();
960
961        assert_eq!(block.source, 2);
962        assert_eq!(block.polarity, 1);
963        assert_eq!(block.pvt_age, 20);
964        assert!((block.offset_seconds().unwrap() - 0.125).abs() < 1e-9);
965        assert!((block.offset_ns().unwrap() - 125000000.0).abs() < 1.0);
966        assert!((block.rx_clk_bias_seconds().unwrap() + 0.25).abs() < 1e-12);
967        assert!((block.rx_clk_bias_ms().unwrap() + 250.0).abs() < 1e-9);
968    }
969
970    #[test]
971    fn test_ext_event_pvt_cartesian_scaled() {
972        let block = ExtEventPvtCartesianBlock {
973            tow_ms: 1000,
974            wnc: 2000,
975            mode: 4,
976            error: 0,
977            x_m: 1.0,
978            y_m: 2.0,
979            z_m: 3.0,
980            undulation_m: 4.0,
981            vx_mps: 0.1,
982            vy_mps: 0.2,
983            vz_mps: 0.3,
984            cog_deg: 45.0,
985            rx_clk_bias_ms: 0.0,
986            rx_clk_drift_ppm: 0.0,
987            time_system: 1,
988            datum: 0,
989            nr_sv: 8,
990            wa_corr_info: 0,
991            reference_id: 12,
992            mean_corr_age_raw: 250,
993            signal_info: 0,
994            alert_flag: 0,
995            nr_bases: 0,
996        };
997
998        assert!((block.mean_corr_age_seconds().unwrap() - 2.5).abs() < 1e-6);
999    }
1000
1001    #[test]
1002    fn test_ext_event_pvt_cartesian_dnu() {
1003        let block = ExtEventPvtCartesianBlock {
1004            tow_ms: 0,
1005            wnc: 0,
1006            mode: 0,
1007            error: 0,
1008            x_m: F64_DNU,
1009            y_m: 0.0,
1010            z_m: 0.0,
1011            undulation_m: F32_DNU,
1012            vx_mps: 0.0,
1013            vy_mps: 0.0,
1014            vz_mps: 0.0,
1015            cog_deg: 0.0,
1016            rx_clk_bias_ms: 0.0,
1017            rx_clk_drift_ppm: 0.0,
1018            time_system: 0,
1019            datum: 0,
1020            nr_sv: 255,
1021            wa_corr_info: 0,
1022            reference_id: 0,
1023            mean_corr_age_raw: U16_DNU,
1024            signal_info: 0,
1025            alert_flag: 0,
1026            nr_bases: 0,
1027        };
1028
1029        assert!(block.x_m().is_none());
1030        assert!(block.undulation_m().is_none());
1031        assert_eq!(block.num_satellites_raw(), 255);
1032        assert_eq!(block.num_satellites_opt(), None);
1033        assert_eq!(block.num_satellites(), 0);
1034        assert!(block.mean_corr_age_seconds().is_none());
1035    }
1036
1037    #[test]
1038    fn test_ext_event_pvt_cartesian_parse() {
1039        let mut data = vec![0u8; 84];
1040        data[12] = 3;
1041        data[13] = 1;
1042        data[14..22].copy_from_slice(&1.5_f64.to_le_bytes());
1043        data[22..30].copy_from_slice(&2.5_f64.to_le_bytes());
1044        data[30..38].copy_from_slice(&3.5_f64.to_le_bytes());
1045        data[38..42].copy_from_slice(&(-1.25_f32).to_le_bytes());
1046        data[42..46].copy_from_slice(&0.1_f32.to_le_bytes());
1047        data[46..50].copy_from_slice(&0.2_f32.to_le_bytes());
1048        data[50..54].copy_from_slice(&0.3_f32.to_le_bytes());
1049        data[54..58].copy_from_slice(&90.0_f32.to_le_bytes());
1050        data[58..66].copy_from_slice(&(-0.25_f64).to_le_bytes());
1051        data[66..70].copy_from_slice(&0.5_f32.to_le_bytes());
1052        data[70] = 2;
1053        data[71] = 1;
1054        data[72] = 7;
1055        data[73] = 3;
1056        data[74..76].copy_from_slice(&123_u16.to_le_bytes());
1057        data[76..78].copy_from_slice(&200_u16.to_le_bytes());
1058        data[78..82].copy_from_slice(&0xAABBCCDD_u32.to_le_bytes());
1059        data[82] = 1;
1060        data[83] = 2;
1061
1062        let header = header_for(block_ids::EXT_EVENT_PVT_CARTESIAN, data.len(), 9000, 2200);
1063        let block = ExtEventPvtCartesianBlock::parse(&header, &data).unwrap();
1064
1065        assert_eq!(block.mode_raw(), 3);
1066        assert_eq!(block.error_raw(), 1);
1067        assert_eq!(block.reference_id, 123);
1068        assert_eq!(block.num_satellites(), 7);
1069        assert_eq!(block.num_satellites_opt(), Some(7));
1070        assert_eq!(block.num_satellites_raw(), 7);
1071        assert!((block.x_m().unwrap() - 1.5).abs() < 1e-6);
1072        assert!((block.mean_corr_age_seconds().unwrap() - 2.0).abs() < 1e-6);
1073    }
1074
1075    #[test]
1076    fn test_ext_event_pvt_geodetic_scaled() {
1077        let block = ExtEventPvtGeodeticBlock {
1078            tow_ms: 0,
1079            wnc: 0,
1080            mode: 4,
1081            error: 0,
1082            latitude_rad: 1.0,
1083            longitude_rad: -0.5,
1084            height_m: 10.0,
1085            undulation_m: 1.0,
1086            vn_mps: 0.0,
1087            ve_mps: 0.0,
1088            vu_mps: 0.0,
1089            cog_deg: 0.0,
1090            rx_clk_bias_ms: 0.0,
1091            rx_clk_drift_ppm: 0.0,
1092            time_system: 0,
1093            datum: 0,
1094            nr_sv: 0,
1095            wa_corr_info: 0,
1096            reference_id: 0,
1097            mean_corr_age_raw: 150,
1098            signal_info: 0,
1099            alert_flag: 0,
1100            nr_bases: 0,
1101        };
1102
1103        assert!((block.latitude_deg().unwrap() - 57.2958).abs() < 1e-3);
1104        assert!((block.longitude_deg().unwrap() + 28.6479).abs() < 1e-3);
1105        assert!((block.mean_corr_age_seconds().unwrap() - 1.5).abs() < 1e-6);
1106    }
1107
1108    #[test]
1109    fn test_ext_event_pvt_geodetic_dnu() {
1110        let block = ExtEventPvtGeodeticBlock {
1111            tow_ms: 0,
1112            wnc: 0,
1113            mode: 0,
1114            error: 0,
1115            latitude_rad: F64_DNU,
1116            longitude_rad: 0.0,
1117            height_m: F64_DNU,
1118            undulation_m: F32_DNU,
1119            vn_mps: 0.0,
1120            ve_mps: 0.0,
1121            vu_mps: 0.0,
1122            cog_deg: 0.0,
1123            rx_clk_bias_ms: 0.0,
1124            rx_clk_drift_ppm: 0.0,
1125            time_system: 0,
1126            datum: 0,
1127            nr_sv: 255,
1128            wa_corr_info: 0,
1129            reference_id: 0,
1130            mean_corr_age_raw: U16_DNU,
1131            signal_info: 0,
1132            alert_flag: 0,
1133            nr_bases: 0,
1134        };
1135
1136        assert!(block.latitude_deg().is_none());
1137        assert!(block.height_m().is_none());
1138        assert!(block.undulation_m().is_none());
1139        assert_eq!(block.num_satellites_raw(), 255);
1140        assert_eq!(block.num_satellites_opt(), None);
1141        assert_eq!(block.num_satellites(), 0);
1142        assert!(block.mean_corr_age_seconds().is_none());
1143    }
1144
1145    #[test]
1146    fn test_ext_event_pvt_geodetic_parse() {
1147        let mut data = vec![0u8; 84];
1148        data[12] = 2;
1149        data[13] = 0;
1150        data[14..22].copy_from_slice(&0.5_f64.to_le_bytes());
1151        data[22..30].copy_from_slice(&1.0_f64.to_le_bytes());
1152        data[30..38].copy_from_slice(&50.0_f64.to_le_bytes());
1153        data[38..42].copy_from_slice(&2.5_f32.to_le_bytes());
1154        data[42..46].copy_from_slice(&(-0.1_f32).to_le_bytes());
1155        data[46..50].copy_from_slice(&0.2_f32.to_le_bytes());
1156        data[50..54].copy_from_slice(&0.3_f32.to_le_bytes());
1157        data[54..58].copy_from_slice(&120.0_f32.to_le_bytes());
1158        data[58..66].copy_from_slice(&1.25_f64.to_le_bytes());
1159        data[66..70].copy_from_slice(&(-0.75_f32).to_le_bytes());
1160        data[70] = 1;
1161        data[71] = 2;
1162        data[72] = 9;
1163        data[73] = 4;
1164        data[74..76].copy_from_slice(&321_u16.to_le_bytes());
1165        data[76..78].copy_from_slice(&100_u16.to_le_bytes());
1166        data[78..82].copy_from_slice(&0x01020304_u32.to_le_bytes());
1167        data[82] = 0;
1168        data[83] = 1;
1169
1170        let header = header_for(block_ids::EXT_EVENT_PVT_GEODETIC, data.len(), 9100, 2300);
1171        let block = ExtEventPvtGeodeticBlock::parse(&header, &data).unwrap();
1172
1173        assert_eq!(block.mode_raw(), 2);
1174        assert_eq!(block.reference_id, 321);
1175        assert_eq!(block.num_satellites(), 9);
1176        assert_eq!(block.num_satellites_opt(), Some(9));
1177        assert_eq!(block.num_satellites_raw(), 9);
1178        assert!((block.latitude_deg().unwrap() - 28.6479).abs() < 1e-3);
1179        assert!((block.height_m().unwrap() - 50.0).abs() < 1e-6);
1180    }
1181
1182    #[test]
1183    fn test_ext_event_base_vect_geod_parse() {
1184        let mut data = vec![0u8; 14 + 52];
1185        data[12] = 1;
1186        data[13] = 52;
1187        data[14] = 7;
1188        data[15] = 0;
1189        data[16] = 4;
1190        data[17] = 0;
1191        data[18..26].copy_from_slice(&1.5f64.to_le_bytes());
1192        data[26..34].copy_from_slice(&2.5f64.to_le_bytes());
1193        data[34..42].copy_from_slice(&3.5f64.to_le_bytes());
1194        data[54..56].copy_from_slice(&18000u16.to_le_bytes());
1195        data[56..58].copy_from_slice(&2500i16.to_le_bytes());
1196        data[58..60].copy_from_slice(&42u16.to_le_bytes());
1197        data[60..62].copy_from_slice(&300u16.to_le_bytes());
1198        data[62..66].copy_from_slice(&0x11223344u32.to_le_bytes());
1199
1200        let header = header_for(block_ids::EXT_EVENT_BASE_VECT_GEOD, data.len(), 12000, 2400);
1201        let block = ExtEventBaseVectGeodBlock::parse(&header, &data).unwrap();
1202        assert_eq!(block.num_vectors(), 1);
1203        assert_eq!(block.vectors[0].reference_id, 42);
1204        assert!((block.vectors[0].de_m().unwrap() - 1.5).abs() < 1e-6);
1205        assert!((block.vectors[0].azimuth_deg().unwrap() - 180.0).abs() < 1e-6);
1206        assert!((block.vectors[0].corr_age_seconds().unwrap() - 3.0).abs() < 1e-6);
1207    }
1208
1209    #[test]
1210    fn test_ext_event_att_euler_parse() {
1211        let mut data = vec![0u8; 42];
1212        data[12] = 8;
1213        data[13] = 1;
1214        data[14..16].copy_from_slice(&4u16.to_le_bytes());
1215        data[18..22].copy_from_slice(&45.0f32.to_le_bytes());
1216        data[22..26].copy_from_slice(&(-2.5f32).to_le_bytes());
1217        data[26..30].copy_from_slice(&1.25f32.to_le_bytes());
1218        data[30..34].copy_from_slice(&0.5f32.to_le_bytes());
1219        data[34..38].copy_from_slice(&0.25f32.to_le_bytes());
1220        data[38..42].copy_from_slice(&(-0.75f32).to_le_bytes());
1221
1222        let header = header_for(block_ids::EXT_EVENT_ATT_EULER, data.len(), 13000, 2500);
1223        let block = ExtEventAttEulerBlock::parse(&header, &data).unwrap();
1224        assert_eq!(block.num_satellites(), 8);
1225        assert_eq!(block.num_satellites_opt(), Some(8));
1226        assert_eq!(block.num_satellites_raw(), 8);
1227        assert_eq!(block.error_raw(), 1);
1228        assert_eq!(block.mode_raw(), 4);
1229        assert_eq!(block.heading_deg(), Some(45.0));
1230        assert_eq!(block.pitch_deg(), Some(-2.5));
1231        assert_eq!(block.roll_deg(), Some(1.25));
1232        assert_eq!(block.heading_rate_dps(), Some(-0.75));
1233    }
1234
1235    #[test]
1236    fn test_ext_event_att_euler_nr_sv_dnu() {
1237        let mut data = vec![0u8; 42];
1238        data[12] = 255;
1239        data[18..22].copy_from_slice(&F32_DNU.to_le_bytes());
1240
1241        let header = header_for(block_ids::EXT_EVENT_ATT_EULER, data.len(), 13000, 2500);
1242        let block = ExtEventAttEulerBlock::parse(&header, &data).unwrap();
1243        assert_eq!(block.num_satellites_raw(), 255);
1244        assert_eq!(block.num_satellites_opt(), None);
1245        assert_eq!(block.num_satellites(), 0);
1246        assert_eq!(block.heading_deg(), None);
1247    }
1248}