//! The 24-byte point record — the only part of OTP/1 that appears in bulk. //! //! ```text //! off size field range / unit //! 0 4 ts u32 unix seconds (good to 2106) //! 4 4 lat i32 degrees × 1e7 → 1.1 cm //! 8 4 lon i32 degrees × 1e7 //! 12 2 acc_dm u16 decimetres, 0–6553 m; 0xFFFF unknown //! 14 2 alt_m i16 metres, ±32 km; 0x8000 unknown //! 16 2 spd_cms u16 cm/s, 0–655 m/s; 0xFFFF unknown //! 18 2 brg_cdeg u16 centidegrees, 0–35999; 0xFFFF unknown //! 20 1 bat_pct u8 0–100; 0xFF unknown //! 21 1 flags u8 //! 22 2 reserved zero //! ``` //! //! Fixed width, no varints, no delta coding, no flag-driven optional fields: //! the codec is a straight struct read, and a 40-point datagram still fits in //! 998 bytes. use crate::error::ValidationError; /// Wire size of one point record. pub const POINT_LEN: usize = 24; const ACC_UNKNOWN: u16 = 0xFFFF; const ALT_UNKNOWN: i16 = i16::MIN; // 0x8000 const SPD_UNKNOWN: u16 = 0xFFFF; const BRG_UNKNOWN: u16 = 0xFFFF; const BAT_UNKNOWN: u8 = 0xFF; /// Largest representable value for each sentinel-terminated field. const ACC_MAX: u16 = ACC_UNKNOWN - 1; const SPD_MAX: u16 = SPD_UNKNOWN - 1; const BRG_MAX: u16 = 35_999; const ALT_MIN: i16 = i16::MIN + 1; pub const LAT_MAX_E7: i32 = 900_000_000; pub const LON_MAX_E7: i32 = 1_800_000_000; /// Per-point flag bits. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] pub struct Flags(pub u8); impl Flags { pub const NONE: Self = Self(0); /// Device is plugged in. pub const CHARGING: Self = Self(1 << 0); /// Fix came from the network provider rather than GNSS. pub const NETWORK_FIX: Self = Self(1 << 1); /// Accepted despite exceeding the accuracy gate — nothing better arrived. pub const LOW_ACCURACY: Self = Self(1 << 2); /// `Location.isFromMockProvider()`. pub const MOCK: Self = Self(1 << 3); /// Bits with an assigned meaning in version 1. pub const KNOWN: u8 = 0b0000_1111; #[must_use] pub const fn contains(self, other: Self) -> bool { self.0 & other.0 == other.0 } #[must_use] pub const fn union(self, other: Self) -> Self { Self(self.0 | other.0) } } impl core::ops::BitOr for Flags { type Output = Self; fn bitor(self, rhs: Self) -> Self { self.union(rhs) } } /// One location report. /// /// `None` in an optional field means "the device could not measure this" and is /// carried on the wire as that field's sentinel. Points inside a multi-point /// `LOC` are fully independent — any order, any spacing. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Point { /// Unix seconds, from the device's wall clock. Trusted as-is; the server /// never rewrites it, it only reports the observed skew back to the user. pub ts: u32, pub lat_e7: i32, pub lon_e7: i32, /// Horizontal accuracy in decimetres. pub acc_dm: Option, pub alt_m: Option, /// Ground speed in cm/s. pub spd_cms: Option, /// Bearing in centidegrees, 0..=35999. pub brg_cdeg: Option, pub bat_pct: Option, pub flags: Flags, } impl Point { /// A point with only a time and a position — every optional field unknown. #[must_use] pub const fn new(ts: u32, lat_e7: i32, lon_e7: i32) -> Self { Self { ts, lat_e7, lon_e7, acc_dm: None, alt_m: None, spd_cms: None, brg_cdeg: None, bat_pct: None, flags: Flags::NONE, } } /// Decode a point record. Infallible: every 24-byte string is a point. /// /// The two reserved bytes are dropped rather than rejected, so a future /// version can put something there without this build treating the packet /// as garbage. #[must_use] pub fn from_bytes(b: &[u8; POINT_LEN]) -> Self { let acc = u16::from_be_bytes([b[12], b[13]]); let alt = i16::from_be_bytes([b[14], b[15]]); let spd = u16::from_be_bytes([b[16], b[17]]); let brg = u16::from_be_bytes([b[18], b[19]]); Self { ts: u32::from_be_bytes([b[0], b[1], b[2], b[3]]), lat_e7: i32::from_be_bytes([b[4], b[5], b[6], b[7]]), lon_e7: i32::from_be_bytes([b[8], b[9], b[10], b[11]]), acc_dm: (acc != ACC_UNKNOWN).then_some(acc), alt_m: (alt != ALT_UNKNOWN).then_some(alt), spd_cms: (spd != SPD_UNKNOWN).then_some(spd), brg_cdeg: (brg != BRG_UNKNOWN).then_some(brg), bat_pct: (b[20] != BAT_UNKNOWN).then_some(b[20]), flags: Flags(b[21]), } } /// Encode a point record. /// /// Values that would collide with a sentinel are clamped to the largest /// representable value, so "20 km up" degrades to "32.767 km up" rather /// than silently becoming "unknown". #[must_use] pub fn to_bytes(self) -> [u8; POINT_LEN] { let mut b = [0u8; POINT_LEN]; b[0..4].copy_from_slice(&self.ts.to_be_bytes()); b[4..8].copy_from_slice(&self.lat_e7.to_be_bytes()); b[8..12].copy_from_slice(&self.lon_e7.to_be_bytes()); b[12..14].copy_from_slice( &self .acc_dm .map_or(ACC_UNKNOWN, |v| v.min(ACC_MAX)) .to_be_bytes(), ); b[14..16].copy_from_slice( &self .alt_m .map_or(ALT_UNKNOWN, |v| v.max(ALT_MIN)) .to_be_bytes(), ); b[16..18].copy_from_slice( &self .spd_cms .map_or(SPD_UNKNOWN, |v| v.min(SPD_MAX)) .to_be_bytes(), ); b[18..20].copy_from_slice( &self .brg_cdeg .map_or(BRG_UNKNOWN, |v| v.min(BRG_MAX)) .to_be_bytes(), ); b[20] = self.bat_pct.map_or(BAT_UNKNOWN, |v| v.min(100)); b[21] = self.flags.0; // b[22..24] stay zero. b } /// True when `to_bytes` will not have to clamp anything, i.e. the struct /// survives a round trip unchanged. #[must_use] pub fn is_canonical(self) -> bool { self.acc_dm.is_none_or(|v| v <= ACC_MAX) && self.alt_m.is_none_or(|v| v >= ALT_MIN) && self.spd_cms.is_none_or(|v| v <= SPD_MAX) && self.brg_cdeg.is_none_or(|v| v <= BRG_MAX) && self.bat_pct.is_none_or(|v| v <= 100) } /// Clamp every field into its representable range. `to_bytes` does this /// implicitly; call this when you want the struct itself to agree. #[must_use] pub fn canonical(self) -> Self { Self::from_bytes(&self.to_bytes()) } /// Reject values the server should not store. /// /// `now` is the server's clock; timestamps are accepted within ±`window_s` /// of it. That bound exists to stop a badly-set phone clock from writing /// points into the year 2100 where retention will never reach them — it is /// not a security control, since the client clock is trusted by design. pub fn validate(self, now: u32, window_s: u32) -> Result<(), ValidationError> { if !(-LAT_MAX_E7..=LAT_MAX_E7).contains(&self.lat_e7) { return Err(ValidationError::Latitude(self.lat_e7)); } if !(-LON_MAX_E7..=LON_MAX_E7).contains(&self.lon_e7) { return Err(ValidationError::Longitude(self.lon_e7)); } if let Some(brg) = self.brg_cdeg && brg > BRG_MAX { return Err(ValidationError::Bearing(brg)); } if let Some(bat) = self.bat_pct && bat > 100 { return Err(ValidationError::Battery(bat)); } let off_by = i64::from(self.ts) - i64::from(now); if off_by.unsigned_abs() > u64::from(window_s) { return Err(ValidationError::Timestamp { ts: self.ts, now, off_by, }); } Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn sentinels_round_trip_as_none() { let p = Point::from_bytes(&[0xFF; POINT_LEN]); assert_eq!(p.acc_dm, None); assert_eq!(p.spd_cms, None); assert_eq!(p.brg_cdeg, None); assert_eq!(p.bat_pct, None); // 0xFFFF as i16 is -1, a perfectly good altitude, not the sentinel. assert_eq!(p.alt_m, Some(-1)); assert_eq!( Point::from_bytes(&{ let mut b = [0u8; POINT_LEN]; b[14..16].copy_from_slice(&ALT_UNKNOWN.to_be_bytes()); b }) .alt_m, None ); } #[test] fn out_of_range_values_clamp_rather_than_vanish() { let p = Point { acc_dm: Some(u16::MAX), alt_m: Some(i16::MIN), spd_cms: Some(u16::MAX), brg_cdeg: Some(40_000), bat_pct: Some(200), ..Point::new(0, 0, 0) }; assert!(!p.is_canonical()); let back = p.canonical(); assert_eq!(back.acc_dm, Some(ACC_MAX)); assert_eq!(back.alt_m, Some(ALT_MIN)); assert_eq!(back.spd_cms, Some(SPD_MAX)); assert_eq!(back.brg_cdeg, Some(BRG_MAX)); assert_eq!(back.bat_pct, Some(100)); assert!(back.is_canonical()); } #[test] fn reserved_bytes_are_written_zero() { let b = Point::new(1, 2, 3).to_bytes(); assert_eq!(&b[22..24], &[0, 0]); } #[test] fn quantization_stays_inside_stated_precision() { // 1e7 fixed point resolves to ~1.1 cm at the equator; assert the // encoder does not lose more than one unit. let lat = 52.520_008_f64; let e7 = (lat * 1e7).round() as i32; let p = Point::new(0, e7, 0).canonical(); assert!((f64::from(p.lat_e7) / 1e7 - lat).abs() < 1e-7); } #[test] fn validate_rejects_impossible_coordinates() { let now = 1_785_000_000; assert!(Point::new(now, 910_000_000, 0).validate(now, 60).is_err()); assert!( Point::new(now, 0, -1_810_000_000) .validate(now, 60) .is_err() ); assert!( Point::new(now, 525_200_000, 134_050_000) .validate(now, 60) .is_ok() ); } #[test] fn validate_rejects_timestamps_outside_the_window() { let now = 1_785_000_000; assert!(Point::new(now - 61, 0, 0).validate(now, 60).is_err()); assert!(Point::new(now + 61, 0, 0).validate(now, 60).is_err()); assert!(Point::new(now - 60, 0, 0).validate(now, 60).is_ok()); // A zero timestamp must not underflow into "close enough". assert!(Point::new(0, 0, 0).validate(now, 60).is_err()); } }