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::position::BaseVectorGeodBlock;
8use super::block_ids;
9use super::dnu::{f32_or_none, f64_or_none, U16_DNU};
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    pub fn num_satellites(&self) -> u8 {
405        self.nr_sv
406    }
407}
408
409impl SbfBlockParse for ExtEventPvtCartesianBlock {
410    const BLOCK_ID: u16 = block_ids::EXT_EVENT_PVT_CARTESIAN;
411
412    fn parse(header: &SbfHeader, data: &[u8]) -> SbfResult<Self> {
413        if data.len() < 84 {
414            return Err(SbfError::ParseError(
415                "ExtEventPVTCartesian too short".into(),
416            ));
417        }
418
419        let mode = data[12];
420        let error = data[13];
421        let x_m = f64::from_le_bytes(data[14..22].try_into().unwrap());
422        let y_m = f64::from_le_bytes(data[22..30].try_into().unwrap());
423        let z_m = f64::from_le_bytes(data[30..38].try_into().unwrap());
424        let undulation_m = f32::from_le_bytes(data[38..42].try_into().unwrap());
425        let vx_mps = f32::from_le_bytes(data[42..46].try_into().unwrap());
426        let vy_mps = f32::from_le_bytes(data[46..50].try_into().unwrap());
427        let vz_mps = f32::from_le_bytes(data[50..54].try_into().unwrap());
428        let cog_deg = f32::from_le_bytes(data[54..58].try_into().unwrap());
429        let rx_clk_bias_ms = f64::from_le_bytes(data[58..66].try_into().unwrap());
430        let rx_clk_drift_ppm = f32::from_le_bytes(data[66..70].try_into().unwrap());
431        let time_system = data[70];
432        let datum = data[71];
433        let nr_sv = data[72];
434        let wa_corr_info = data[73];
435        let reference_id = u16::from_le_bytes([data[74], data[75]]);
436        let mean_corr_age_raw = u16::from_le_bytes([data[76], data[77]]);
437        let signal_info = u32::from_le_bytes(data[78..82].try_into().unwrap());
438        let alert_flag = data[82];
439        let nr_bases = data[83];
440
441        Ok(Self {
442            tow_ms: header.tow_ms,
443            wnc: header.wnc,
444            mode,
445            error,
446            x_m,
447            y_m,
448            z_m,
449            undulation_m,
450            vx_mps,
451            vy_mps,
452            vz_mps,
453            cog_deg,
454            rx_clk_bias_ms,
455            rx_clk_drift_ppm,
456            time_system,
457            datum,
458            nr_sv,
459            wa_corr_info,
460            reference_id,
461            mean_corr_age_raw,
462            signal_info,
463            alert_flag,
464            nr_bases,
465        })
466    }
467}
468
469/// ExtEventPVTGeodetic block (Block ID 4038)
470///
471/// External event timing with PVT solution in geodetic coordinates.
472#[derive(Debug, Clone)]
473pub struct ExtEventPvtGeodeticBlock {
474    tow_ms: u32,
475    wnc: u16,
476    mode: u8,
477    error: u8,
478    latitude_rad: f64,
479    longitude_rad: f64,
480    height_m: f64,
481    undulation_m: f32,
482    vn_mps: f32,
483    ve_mps: f32,
484    vu_mps: f32,
485    cog_deg: f32,
486    rx_clk_bias_ms: f64,
487    rx_clk_drift_ppm: f32,
488    pub time_system: u8,
489    pub datum: u8,
490    nr_sv: u8,
491    pub wa_corr_info: u8,
492    pub reference_id: u16,
493    mean_corr_age_raw: u16,
494    pub signal_info: u32,
495    pub alert_flag: u8,
496    pub nr_bases: u8,
497}
498
499impl ExtEventPvtGeodeticBlock {
500    pub fn tow_seconds(&self) -> f64 {
501        self.tow_ms as f64 * 0.001
502    }
503    pub fn tow_ms(&self) -> u32 {
504        self.tow_ms
505    }
506    pub fn wnc(&self) -> u16 {
507        self.wnc
508    }
509
510    pub fn mode(&self) -> PvtMode {
511        PvtMode::from_mode_byte(self.mode)
512    }
513    pub fn mode_raw(&self) -> u8 {
514        self.mode
515    }
516    pub fn error(&self) -> PvtError {
517        PvtError::from_error_byte(self.error)
518    }
519    pub fn error_raw(&self) -> u8 {
520        self.error
521    }
522    pub fn has_fix(&self) -> bool {
523        self.mode().has_fix() && self.error().is_ok()
524    }
525
526    // Position
527    pub fn latitude_deg(&self) -> Option<f64> {
528        f64_or_none(self.latitude_rad).map(|value| value.to_degrees())
529    }
530    pub fn longitude_deg(&self) -> Option<f64> {
531        f64_or_none(self.longitude_rad).map(|value| value.to_degrees())
532    }
533    pub fn latitude_rad(&self) -> f64 {
534        self.latitude_rad
535    }
536    pub fn longitude_rad(&self) -> f64 {
537        self.longitude_rad
538    }
539    pub fn height_m(&self) -> Option<f64> {
540        f64_or_none(self.height_m)
541    }
542    pub fn undulation_m(&self) -> Option<f32> {
543        f32_or_none(self.undulation_m)
544    }
545
546    // Velocity
547    pub fn velocity_north_mps(&self) -> Option<f32> {
548        f32_or_none(self.vn_mps)
549    }
550    pub fn velocity_east_mps(&self) -> Option<f32> {
551        f32_or_none(self.ve_mps)
552    }
553    pub fn velocity_up_mps(&self) -> Option<f32> {
554        f32_or_none(self.vu_mps)
555    }
556    pub fn course_over_ground_deg(&self) -> Option<f32> {
557        f32_or_none(self.cog_deg)
558    }
559
560    // Clock
561    pub fn clock_bias_ms(&self) -> Option<f64> {
562        f64_or_none(self.rx_clk_bias_ms)
563    }
564    pub fn clock_drift_ppm(&self) -> Option<f32> {
565        f32_or_none(self.rx_clk_drift_ppm)
566    }
567
568    // Correction age
569    pub fn mean_corr_age_seconds(&self) -> Option<f32> {
570        if self.mean_corr_age_raw == U16_DNU {
571            None
572        } else {
573            Some(self.mean_corr_age_raw as f32 * 0.01)
574        }
575    }
576    pub fn mean_corr_age_raw(&self) -> u16 {
577        self.mean_corr_age_raw
578    }
579
580    pub fn num_satellites(&self) -> u8 {
581        self.nr_sv
582    }
583}
584
585impl SbfBlockParse for ExtEventPvtGeodeticBlock {
586    const BLOCK_ID: u16 = block_ids::EXT_EVENT_PVT_GEODETIC;
587
588    fn parse(header: &SbfHeader, data: &[u8]) -> SbfResult<Self> {
589        if data.len() < 84 {
590            return Err(SbfError::ParseError("ExtEventPVTGeodetic too short".into()));
591        }
592
593        let mode = data[12];
594        let error = data[13];
595        let latitude_rad = f64::from_le_bytes(data[14..22].try_into().unwrap());
596        let longitude_rad = f64::from_le_bytes(data[22..30].try_into().unwrap());
597        let height_m = f64::from_le_bytes(data[30..38].try_into().unwrap());
598        let undulation_m = f32::from_le_bytes(data[38..42].try_into().unwrap());
599        let vn_mps = f32::from_le_bytes(data[42..46].try_into().unwrap());
600        let ve_mps = f32::from_le_bytes(data[46..50].try_into().unwrap());
601        let vu_mps = f32::from_le_bytes(data[50..54].try_into().unwrap());
602        let cog_deg = f32::from_le_bytes(data[54..58].try_into().unwrap());
603        let rx_clk_bias_ms = f64::from_le_bytes(data[58..66].try_into().unwrap());
604        let rx_clk_drift_ppm = f32::from_le_bytes(data[66..70].try_into().unwrap());
605        let time_system = data[70];
606        let datum = data[71];
607        let nr_sv = data[72];
608        let wa_corr_info = data[73];
609        let reference_id = u16::from_le_bytes([data[74], data[75]]);
610        let mean_corr_age_raw = u16::from_le_bytes([data[76], data[77]]);
611        let signal_info = u32::from_le_bytes(data[78..82].try_into().unwrap());
612        let alert_flag = data[82];
613        let nr_bases = data[83];
614
615        Ok(Self {
616            tow_ms: header.tow_ms,
617            wnc: header.wnc,
618            mode,
619            error,
620            latitude_rad,
621            longitude_rad,
622            height_m,
623            undulation_m,
624            vn_mps,
625            ve_mps,
626            vu_mps,
627            cog_deg,
628            rx_clk_bias_ms,
629            rx_clk_drift_ppm,
630            time_system,
631            datum,
632            nr_sv,
633            wa_corr_info,
634            reference_id,
635            mean_corr_age_raw,
636            signal_info,
637            alert_flag,
638            nr_bases,
639        })
640    }
641}
642
643// ============================================================================
644// ExtEventBaseVectGeod / ExtEventAttEuler
645// ============================================================================
646
647/// ExtEventBaseVectGeod (4217) — base vectors at event time.
648#[derive(Debug, Clone)]
649pub struct ExtEventBaseVectGeodBlock(pub BaseVectorGeodBlock);
650
651impl std::ops::Deref for ExtEventBaseVectGeodBlock {
652    type Target = BaseVectorGeodBlock;
653
654    fn deref(&self) -> &Self::Target {
655        &self.0
656    }
657}
658
659impl SbfBlockParse for ExtEventBaseVectGeodBlock {
660    const BLOCK_ID: u16 = block_ids::EXT_EVENT_BASE_VECT_GEOD;
661
662    fn parse(header: &SbfHeader, data: &[u8]) -> SbfResult<Self> {
663        Ok(Self(BaseVectorGeodBlock::parse(header, data)?))
664    }
665}
666
667/// ExtEventAttEuler (4237) — attitude at event time.
668#[derive(Debug, Clone)]
669pub struct ExtEventAttEulerBlock {
670    tow_ms: u32,
671    wnc: u16,
672    nr_sv: u8,
673    error: u8,
674    mode: u16,
675    heading_deg: f32,
676    pitch_deg: f32,
677    roll_deg: f32,
678    pitch_rate_dps: f32,
679    roll_rate_dps: f32,
680    heading_rate_dps: f32,
681}
682
683impl ExtEventAttEulerBlock {
684    pub fn tow_seconds(&self) -> f64 {
685        self.tow_ms as f64 * 0.001
686    }
687    pub fn tow_ms(&self) -> u32 {
688        self.tow_ms
689    }
690    pub fn wnc(&self) -> u16 {
691        self.wnc
692    }
693    pub fn num_satellites(&self) -> u8 {
694        self.nr_sv
695    }
696    pub fn error_raw(&self) -> u8 {
697        self.error
698    }
699    pub fn mode_raw(&self) -> u16 {
700        self.mode
701    }
702    pub fn heading_deg(&self) -> Option<f32> {
703        f32_or_none(self.heading_deg)
704    }
705    pub fn pitch_deg(&self) -> Option<f32> {
706        f32_or_none(self.pitch_deg)
707    }
708    pub fn roll_deg(&self) -> Option<f32> {
709        f32_or_none(self.roll_deg)
710    }
711    pub fn pitch_rate_dps(&self) -> Option<f32> {
712        f32_or_none(self.pitch_rate_dps)
713    }
714    pub fn roll_rate_dps(&self) -> Option<f32> {
715        f32_or_none(self.roll_rate_dps)
716    }
717    pub fn heading_rate_dps(&self) -> Option<f32> {
718        f32_or_none(self.heading_rate_dps)
719    }
720}
721
722impl SbfBlockParse for ExtEventAttEulerBlock {
723    const BLOCK_ID: u16 = block_ids::EXT_EVENT_ATT_EULER;
724
725    fn parse(header: &SbfHeader, data: &[u8]) -> SbfResult<Self> {
726        if data.len() < 42 {
727            return Err(SbfError::ParseError("ExtEventAttEuler too short".into()));
728        }
729        Ok(Self {
730            tow_ms: header.tow_ms,
731            wnc: header.wnc,
732            nr_sv: data[12],
733            error: data[13],
734            mode: u16::from_le_bytes(data[14..16].try_into().unwrap()),
735            heading_deg: f32::from_le_bytes(data[18..22].try_into().unwrap()),
736            pitch_deg: f32::from_le_bytes(data[22..26].try_into().unwrap()),
737            roll_deg: f32::from_le_bytes(data[26..30].try_into().unwrap()),
738            pitch_rate_dps: f32::from_le_bytes(data[30..34].try_into().unwrap()),
739            roll_rate_dps: f32::from_le_bytes(data[34..38].try_into().unwrap()),
740            heading_rate_dps: f32::from_le_bytes(data[38..42].try_into().unwrap()),
741        })
742    }
743}
744
745// ============================================================================
746// EndOfPVT Block
747// ============================================================================
748
749/// EndOfPVT block (Block ID 5921)
750///
751/// Marker indicating end of PVT-related blocks for current epoch.
752#[derive(Debug, Clone)]
753pub struct EndOfPvtBlock {
754    tow_ms: u32,
755    wnc: u16,
756}
757
758impl EndOfPvtBlock {
759    pub fn tow_seconds(&self) -> f64 {
760        self.tow_ms as f64 * 0.001
761    }
762    pub fn tow_ms(&self) -> u32 {
763        self.tow_ms
764    }
765    pub fn wnc(&self) -> u16 {
766        self.wnc
767    }
768}
769
770impl SbfBlockParse for EndOfPvtBlock {
771    const BLOCK_ID: u16 = block_ids::END_OF_PVT;
772
773    fn parse(header: &SbfHeader, data: &[u8]) -> SbfResult<Self> {
774        // Minimal block - just TOW and WNc from header
775        if data.len() < 12 {
776            return Err(SbfError::ParseError("EndOfPVT too short".into()));
777        }
778
779        Ok(Self {
780            tow_ms: header.tow_ms,
781            wnc: header.wnc,
782        })
783    }
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789    use crate::header::SbfHeader;
790
791    fn header_for(block_id: u16, data_len: usize, tow_ms: u32, wnc: u16) -> SbfHeader {
792        SbfHeader {
793            crc: 0,
794            block_id,
795            block_rev: 0,
796            length: (data_len + 2) as u16,
797            tow_ms,
798            wnc,
799        }
800    }
801
802    #[test]
803    fn test_receiver_time_string() {
804        let time = ReceiverTimeBlock {
805            tow_ms: 0,
806            wnc: 0,
807            utc_year: 2024,
808            utc_month: 3,
809            utc_day: 15,
810            utc_hour: 12,
811            utc_minute: 30,
812            utc_second: 45,
813            delta_ls: 18,
814            sync_level: 4,
815        };
816
817        assert_eq!(time.utc_string(), "2024-03-15T12:30:45Z");
818        assert!(time.is_valid());
819        assert!(time.is_synchronized());
820    }
821
822    #[test]
823    fn test_receiver_time_validation() {
824        let invalid_time = ReceiverTimeBlock {
825            tow_ms: 0,
826            wnc: 0,
827            utc_year: 1999, // Invalid
828            utc_month: 13,  // Invalid
829            utc_day: 1,
830            utc_hour: 0,
831            utc_minute: 0,
832            utc_second: 0,
833            delta_ls: 0,
834            sync_level: 0,
835        };
836
837        assert!(!invalid_time.is_valid());
838        assert!(!invalid_time.is_synchronized());
839    }
840
841    #[test]
842    fn test_sync_levels() {
843        let time = ReceiverTimeBlock {
844            tow_ms: 0,
845            wnc: 0,
846            utc_year: 2024,
847            utc_month: 1,
848            utc_day: 1,
849            utc_hour: 0,
850            utc_minute: 0,
851            utc_second: 0,
852            delta_ls: 18,
853            sync_level: 0,
854        };
855        assert_eq!(time.sync_level_desc(), "Not synchronized");
856
857        let time_sync = ReceiverTimeBlock {
858            sync_level: 4,
859            ..time
860        };
861        assert_eq!(time_sync.sync_level_desc(), "Fine time (PPS)");
862    }
863
864    #[test]
865    fn test_pps_offset_accessors() {
866        let block = PpsOffsetBlock {
867            tow_ms: 1500,
868            wnc: 2100,
869            sync_age: 3,
870            timescale: 1,
871            offset_ns: F32_DNU,
872        };
873
874        assert!((block.tow_seconds() - 1.5).abs() < 1e-6);
875        assert!(block.offset_ns().is_none());
876        assert!(block.offset_seconds().is_none());
877    }
878
879    #[test]
880    fn test_pps_offset_parse() {
881        let mut data = vec![0u8; 18];
882        data[12] = 2;
883        data[13] = 1;
884        data[14..18].copy_from_slice(&2500.0_f32.to_le_bytes());
885
886        let header = header_for(block_ids::PPS_OFFSET, data.len(), 5000, 2200);
887        let block = PpsOffsetBlock::parse(&header, &data).unwrap();
888
889        assert_eq!(block.sync_age, 2);
890        assert_eq!(block.timescale, 1);
891        assert!((block.offset_ns().unwrap() - 2500.0).abs() < 1e-6);
892    }
893
894    #[test]
895    fn test_ext_event_accessors() {
896        let block = ExtEventBlock {
897            tow_ms: 2500,
898            wnc: 2300,
899            source: 1,
900            polarity: 0,
901            offset_s: F32_DNU,
902            rx_clk_bias_s: F64_DNU,
903            pvt_age: 15,
904        };
905
906        assert!((block.tow_seconds() - 2.5).abs() < 1e-6);
907        assert!(block.offset_seconds().is_none());
908        assert!(block.offset_ns().is_none());
909        assert!(block.rx_clk_bias_seconds().is_none());
910        assert!(block.rx_clk_bias_ms().is_none());
911    }
912
913    #[test]
914    fn test_ext_event_parse() {
915        let mut data = vec![0u8; 28];
916        data[12] = 2;
917        data[13] = 1;
918        data[14..18].copy_from_slice(&0.125_f32.to_le_bytes());
919        data[18..26].copy_from_slice(&(-0.25_f64).to_le_bytes());
920        data[26..28].copy_from_slice(&20_u16.to_le_bytes());
921
922        let header = header_for(block_ids::EXT_EVENT, data.len(), 6000, 2400);
923        let block = ExtEventBlock::parse(&header, &data).unwrap();
924
925        assert_eq!(block.source, 2);
926        assert_eq!(block.polarity, 1);
927        assert_eq!(block.pvt_age, 20);
928        assert!((block.offset_seconds().unwrap() - 0.125).abs() < 1e-9);
929        assert!((block.offset_ns().unwrap() - 125000000.0).abs() < 1.0);
930        assert!((block.rx_clk_bias_seconds().unwrap() + 0.25).abs() < 1e-12);
931        assert!((block.rx_clk_bias_ms().unwrap() + 250.0).abs() < 1e-9);
932    }
933
934    #[test]
935    fn test_ext_event_pvt_cartesian_scaled() {
936        let block = ExtEventPvtCartesianBlock {
937            tow_ms: 1000,
938            wnc: 2000,
939            mode: 4,
940            error: 0,
941            x_m: 1.0,
942            y_m: 2.0,
943            z_m: 3.0,
944            undulation_m: 4.0,
945            vx_mps: 0.1,
946            vy_mps: 0.2,
947            vz_mps: 0.3,
948            cog_deg: 45.0,
949            rx_clk_bias_ms: 0.0,
950            rx_clk_drift_ppm: 0.0,
951            time_system: 1,
952            datum: 0,
953            nr_sv: 8,
954            wa_corr_info: 0,
955            reference_id: 12,
956            mean_corr_age_raw: 250,
957            signal_info: 0,
958            alert_flag: 0,
959            nr_bases: 0,
960        };
961
962        assert!((block.mean_corr_age_seconds().unwrap() - 2.5).abs() < 1e-6);
963    }
964
965    #[test]
966    fn test_ext_event_pvt_cartesian_dnu() {
967        let block = ExtEventPvtCartesianBlock {
968            tow_ms: 0,
969            wnc: 0,
970            mode: 0,
971            error: 0,
972            x_m: F64_DNU,
973            y_m: 0.0,
974            z_m: 0.0,
975            undulation_m: F32_DNU,
976            vx_mps: 0.0,
977            vy_mps: 0.0,
978            vz_mps: 0.0,
979            cog_deg: 0.0,
980            rx_clk_bias_ms: 0.0,
981            rx_clk_drift_ppm: 0.0,
982            time_system: 0,
983            datum: 0,
984            nr_sv: 0,
985            wa_corr_info: 0,
986            reference_id: 0,
987            mean_corr_age_raw: U16_DNU,
988            signal_info: 0,
989            alert_flag: 0,
990            nr_bases: 0,
991        };
992
993        assert!(block.x_m().is_none());
994        assert!(block.undulation_m().is_none());
995        assert!(block.mean_corr_age_seconds().is_none());
996    }
997
998    #[test]
999    fn test_ext_event_pvt_cartesian_parse() {
1000        let mut data = vec![0u8; 84];
1001        data[12] = 3;
1002        data[13] = 1;
1003        data[14..22].copy_from_slice(&1.5_f64.to_le_bytes());
1004        data[22..30].copy_from_slice(&2.5_f64.to_le_bytes());
1005        data[30..38].copy_from_slice(&3.5_f64.to_le_bytes());
1006        data[38..42].copy_from_slice(&(-1.25_f32).to_le_bytes());
1007        data[42..46].copy_from_slice(&0.1_f32.to_le_bytes());
1008        data[46..50].copy_from_slice(&0.2_f32.to_le_bytes());
1009        data[50..54].copy_from_slice(&0.3_f32.to_le_bytes());
1010        data[54..58].copy_from_slice(&90.0_f32.to_le_bytes());
1011        data[58..66].copy_from_slice(&(-0.25_f64).to_le_bytes());
1012        data[66..70].copy_from_slice(&0.5_f32.to_le_bytes());
1013        data[70] = 2;
1014        data[71] = 1;
1015        data[72] = 7;
1016        data[73] = 3;
1017        data[74..76].copy_from_slice(&123_u16.to_le_bytes());
1018        data[76..78].copy_from_slice(&200_u16.to_le_bytes());
1019        data[78..82].copy_from_slice(&0xAABBCCDD_u32.to_le_bytes());
1020        data[82] = 1;
1021        data[83] = 2;
1022
1023        let header = header_for(block_ids::EXT_EVENT_PVT_CARTESIAN, data.len(), 9000, 2200);
1024        let block = ExtEventPvtCartesianBlock::parse(&header, &data).unwrap();
1025
1026        assert_eq!(block.mode_raw(), 3);
1027        assert_eq!(block.error_raw(), 1);
1028        assert_eq!(block.reference_id, 123);
1029        assert_eq!(block.num_satellites(), 7);
1030        assert!((block.x_m().unwrap() - 1.5).abs() < 1e-6);
1031        assert!((block.mean_corr_age_seconds().unwrap() - 2.0).abs() < 1e-6);
1032    }
1033
1034    #[test]
1035    fn test_ext_event_pvt_geodetic_scaled() {
1036        let block = ExtEventPvtGeodeticBlock {
1037            tow_ms: 0,
1038            wnc: 0,
1039            mode: 4,
1040            error: 0,
1041            latitude_rad: 1.0,
1042            longitude_rad: -0.5,
1043            height_m: 10.0,
1044            undulation_m: 1.0,
1045            vn_mps: 0.0,
1046            ve_mps: 0.0,
1047            vu_mps: 0.0,
1048            cog_deg: 0.0,
1049            rx_clk_bias_ms: 0.0,
1050            rx_clk_drift_ppm: 0.0,
1051            time_system: 0,
1052            datum: 0,
1053            nr_sv: 0,
1054            wa_corr_info: 0,
1055            reference_id: 0,
1056            mean_corr_age_raw: 150,
1057            signal_info: 0,
1058            alert_flag: 0,
1059            nr_bases: 0,
1060        };
1061
1062        assert!((block.latitude_deg().unwrap() - 57.2958).abs() < 1e-3);
1063        assert!((block.longitude_deg().unwrap() + 28.6479).abs() < 1e-3);
1064        assert!((block.mean_corr_age_seconds().unwrap() - 1.5).abs() < 1e-6);
1065    }
1066
1067    #[test]
1068    fn test_ext_event_pvt_geodetic_dnu() {
1069        let block = ExtEventPvtGeodeticBlock {
1070            tow_ms: 0,
1071            wnc: 0,
1072            mode: 0,
1073            error: 0,
1074            latitude_rad: F64_DNU,
1075            longitude_rad: 0.0,
1076            height_m: F64_DNU,
1077            undulation_m: F32_DNU,
1078            vn_mps: 0.0,
1079            ve_mps: 0.0,
1080            vu_mps: 0.0,
1081            cog_deg: 0.0,
1082            rx_clk_bias_ms: 0.0,
1083            rx_clk_drift_ppm: 0.0,
1084            time_system: 0,
1085            datum: 0,
1086            nr_sv: 0,
1087            wa_corr_info: 0,
1088            reference_id: 0,
1089            mean_corr_age_raw: U16_DNU,
1090            signal_info: 0,
1091            alert_flag: 0,
1092            nr_bases: 0,
1093        };
1094
1095        assert!(block.latitude_deg().is_none());
1096        assert!(block.height_m().is_none());
1097        assert!(block.undulation_m().is_none());
1098        assert!(block.mean_corr_age_seconds().is_none());
1099    }
1100
1101    #[test]
1102    fn test_ext_event_pvt_geodetic_parse() {
1103        let mut data = vec![0u8; 84];
1104        data[12] = 2;
1105        data[13] = 0;
1106        data[14..22].copy_from_slice(&0.5_f64.to_le_bytes());
1107        data[22..30].copy_from_slice(&1.0_f64.to_le_bytes());
1108        data[30..38].copy_from_slice(&50.0_f64.to_le_bytes());
1109        data[38..42].copy_from_slice(&2.5_f32.to_le_bytes());
1110        data[42..46].copy_from_slice(&(-0.1_f32).to_le_bytes());
1111        data[46..50].copy_from_slice(&0.2_f32.to_le_bytes());
1112        data[50..54].copy_from_slice(&0.3_f32.to_le_bytes());
1113        data[54..58].copy_from_slice(&120.0_f32.to_le_bytes());
1114        data[58..66].copy_from_slice(&1.25_f64.to_le_bytes());
1115        data[66..70].copy_from_slice(&(-0.75_f32).to_le_bytes());
1116        data[70] = 1;
1117        data[71] = 2;
1118        data[72] = 9;
1119        data[73] = 4;
1120        data[74..76].copy_from_slice(&321_u16.to_le_bytes());
1121        data[76..78].copy_from_slice(&100_u16.to_le_bytes());
1122        data[78..82].copy_from_slice(&0x01020304_u32.to_le_bytes());
1123        data[82] = 0;
1124        data[83] = 1;
1125
1126        let header = header_for(block_ids::EXT_EVENT_PVT_GEODETIC, data.len(), 9100, 2300);
1127        let block = ExtEventPvtGeodeticBlock::parse(&header, &data).unwrap();
1128
1129        assert_eq!(block.mode_raw(), 2);
1130        assert_eq!(block.reference_id, 321);
1131        assert_eq!(block.num_satellites(), 9);
1132        assert!((block.latitude_deg().unwrap() - 28.6479).abs() < 1e-3);
1133        assert!((block.height_m().unwrap() - 50.0).abs() < 1e-6);
1134    }
1135
1136    #[test]
1137    fn test_ext_event_base_vect_geod_parse() {
1138        let mut data = vec![0u8; 14 + 52];
1139        data[12] = 1;
1140        data[13] = 52;
1141        data[14] = 7;
1142        data[15] = 0;
1143        data[16] = 4;
1144        data[17] = 0;
1145        data[18..26].copy_from_slice(&1.5f64.to_le_bytes());
1146        data[26..34].copy_from_slice(&2.5f64.to_le_bytes());
1147        data[34..42].copy_from_slice(&3.5f64.to_le_bytes());
1148        data[54..56].copy_from_slice(&18000u16.to_le_bytes());
1149        data[56..58].copy_from_slice(&2500i16.to_le_bytes());
1150        data[58..60].copy_from_slice(&42u16.to_le_bytes());
1151        data[60..62].copy_from_slice(&300u16.to_le_bytes());
1152        data[62..66].copy_from_slice(&0x11223344u32.to_le_bytes());
1153
1154        let header = header_for(block_ids::EXT_EVENT_BASE_VECT_GEOD, data.len(), 12000, 2400);
1155        let block = ExtEventBaseVectGeodBlock::parse(&header, &data).unwrap();
1156        assert_eq!(block.num_vectors(), 1);
1157        assert_eq!(block.vectors[0].reference_id, 42);
1158        assert!((block.vectors[0].de_m().unwrap() - 1.5).abs() < 1e-6);
1159        assert!((block.vectors[0].azimuth_deg().unwrap() - 180.0).abs() < 1e-6);
1160        assert!((block.vectors[0].corr_age_seconds().unwrap() - 3.0).abs() < 1e-6);
1161    }
1162
1163    #[test]
1164    fn test_ext_event_att_euler_parse() {
1165        let mut data = vec![0u8; 42];
1166        data[12] = 8;
1167        data[13] = 1;
1168        data[14..16].copy_from_slice(&4u16.to_le_bytes());
1169        data[18..22].copy_from_slice(&45.0f32.to_le_bytes());
1170        data[22..26].copy_from_slice(&(-2.5f32).to_le_bytes());
1171        data[26..30].copy_from_slice(&1.25f32.to_le_bytes());
1172        data[30..34].copy_from_slice(&0.5f32.to_le_bytes());
1173        data[34..38].copy_from_slice(&0.25f32.to_le_bytes());
1174        data[38..42].copy_from_slice(&(-0.75f32).to_le_bytes());
1175
1176        let header = header_for(block_ids::EXT_EVENT_ATT_EULER, data.len(), 13000, 2500);
1177        let block = ExtEventAttEulerBlock::parse(&header, &data).unwrap();
1178        assert_eq!(block.num_satellites(), 8);
1179        assert_eq!(block.error_raw(), 1);
1180        assert_eq!(block.mode_raw(), 4);
1181        assert_eq!(block.heading_deg(), Some(45.0));
1182        assert_eq!(block.pitch_deg(), Some(-2.5));
1183        assert_eq!(block.roll_deg(), Some(1.25));
1184        assert_eq!(block.heading_rate_dps(), Some(-0.75));
1185    }
1186}