point.rs
| 1 | //! The 24-byte point record — the only part of OTP/1 that appears in bulk. |
| 2 | //! |
| 3 | //! ```text |
| 4 | //! off size field range / unit |
| 5 | //! 0 4 ts u32 unix seconds (good to 2106) |
| 6 | //! 4 4 lat i32 degrees × 1e7 → 1.1 cm |
| 7 | //! 8 4 lon i32 degrees × 1e7 |
| 8 | //! 12 2 acc_dm u16 decimetres, 0–6553 m; 0xFFFF unknown |
| 9 | //! 14 2 alt_m i16 metres, ±32 km; 0x8000 unknown |
| 10 | //! 16 2 spd_cms u16 cm/s, 0–655 m/s; 0xFFFF unknown |
| 11 | //! 18 2 brg_cdeg u16 centidegrees, 0–35999; 0xFFFF unknown |
| 12 | //! 20 1 bat_pct u8 0–100; 0xFF unknown |
| 13 | //! 21 1 flags u8 |
| 14 | //! 22 2 reserved zero |
| 15 | //! ``` |
| 16 | //! |
| 17 | //! Fixed width, no varints, no delta coding, no flag-driven optional fields: |
| 18 | //! the codec is a straight struct read, and a 40-point datagram still fits in |
| 19 | //! 998 bytes. |
| 20 | |
| 21 | use crate::error::ValidationError; |
| 22 | |
| 23 | /// Wire size of one point record. |
| 24 | pub const POINT_LEN: usize = 24; |
| 25 | |
| 26 | const ACC_UNKNOWN: u16 = 0xFFFF; |
| 27 | const ALT_UNKNOWN: i16 = i16::MIN; // 0x8000 |
| 28 | const SPD_UNKNOWN: u16 = 0xFFFF; |
| 29 | const BRG_UNKNOWN: u16 = 0xFFFF; |
| 30 | const BAT_UNKNOWN: u8 = 0xFF; |
| 31 | |
| 32 | /// Largest representable value for each sentinel-terminated field. |
| 33 | const ACC_MAX: u16 = ACC_UNKNOWN - 1; |
| 34 | const SPD_MAX: u16 = SPD_UNKNOWN - 1; |
| 35 | const BRG_MAX: u16 = 35_999; |
| 36 | const ALT_MIN: i16 = i16::MIN + 1; |
| 37 | |
| 38 | pub const LAT_MAX_E7: i32 = 900_000_000; |
| 39 | pub const LON_MAX_E7: i32 = 1_800_000_000; |
| 40 | |
| 41 | /// Per-point flag bits. |
| 42 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord, Hash)] |
| 43 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] |
| 44 | #[cfg_attr(feature = "serde", serde(transparent))] |
| 45 | pub struct Flags(pub u8); |
| 46 | |
| 47 | impl Flags { |
| 48 | pub const NONE: Self = Self(0); |
| 49 | /// Device is plugged in. |
| 50 | pub const CHARGING: Self = Self(1 << 0); |
| 51 | /// Fix came from the network provider rather than GNSS. |
| 52 | pub const NETWORK_FIX: Self = Self(1 << 1); |
| 53 | /// Accepted despite exceeding the accuracy gate — nothing better arrived. |
| 54 | pub const LOW_ACCURACY: Self = Self(1 << 2); |
| 55 | /// `Location.isFromMockProvider()`. |
| 56 | pub const MOCK: Self = Self(1 << 3); |
| 57 | |
| 58 | /// Bits with an assigned meaning in version 1. |
| 59 | pub const KNOWN: u8 = 0b0000_1111; |
| 60 | |
| 61 | #[must_use] |
| 62 | pub const fn contains(self, other: Self) -> bool { |
| 63 | self.0 & other.0 == other.0 |
| 64 | } |
| 65 | |
| 66 | #[must_use] |
| 67 | pub const fn union(self, other: Self) -> Self { |
| 68 | Self(self.0 | other.0) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | impl core::ops::BitOr for Flags { |
| 73 | type Output = Self; |
| 74 | fn bitor(self, rhs: Self) -> Self { |
| 75 | self.union(rhs) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | /// One location report. |
| 80 | /// |
| 81 | /// `None` in an optional field means "the device could not measure this" and is |
| 82 | /// carried on the wire as that field's sentinel. Points inside a multi-point |
| 83 | /// `LOC` are fully independent — any order, any spacing. |
| 84 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] |
| 85 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] |
| 86 | pub struct Point { |
| 87 | /// Unix seconds, from the device's wall clock. Trusted as-is; the server |
| 88 | /// never rewrites it, it only reports the observed skew back to the user. |
| 89 | pub ts: u32, |
| 90 | pub lat_e7: i32, |
| 91 | pub lon_e7: i32, |
| 92 | /// Horizontal accuracy in decimetres. |
| 93 | pub acc_dm: Option<u16>, |
| 94 | pub alt_m: Option<i16>, |
| 95 | /// Ground speed in cm/s. |
| 96 | pub spd_cms: Option<u16>, |
| 97 | /// Bearing in centidegrees, 0..=35999. |
| 98 | pub brg_cdeg: Option<u16>, |
| 99 | pub bat_pct: Option<u8>, |
| 100 | pub flags: Flags, |
| 101 | } |
| 102 | |
| 103 | impl Point { |
| 104 | /// A point with only a time and a position — every optional field unknown. |
| 105 | #[must_use] |
| 106 | pub const fn new(ts: u32, lat_e7: i32, lon_e7: i32) -> Self { |
| 107 | Self { |
| 108 | ts, |
| 109 | lat_e7, |
| 110 | lon_e7, |
| 111 | acc_dm: None, |
| 112 | alt_m: None, |
| 113 | spd_cms: None, |
| 114 | brg_cdeg: None, |
| 115 | bat_pct: None, |
| 116 | flags: Flags::NONE, |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | /// Decode a point record. Infallible: every 24-byte string is a point. |
| 121 | /// |
| 122 | /// The two reserved bytes are dropped rather than rejected, so a future |
| 123 | /// version can put something there without this build treating the packet |
| 124 | /// as garbage. |
| 125 | #[must_use] |
| 126 | pub fn from_bytes(b: &[u8; POINT_LEN]) -> Self { |
| 127 | let acc = u16::from_be_bytes([b[12], b[13]]); |
| 128 | let alt = i16::from_be_bytes([b[14], b[15]]); |
| 129 | let spd = u16::from_be_bytes([b[16], b[17]]); |
| 130 | let brg = u16::from_be_bytes([b[18], b[19]]); |
| 131 | Self { |
| 132 | ts: u32::from_be_bytes([b[0], b[1], b[2], b[3]]), |
| 133 | lat_e7: i32::from_be_bytes([b[4], b[5], b[6], b[7]]), |
| 134 | lon_e7: i32::from_be_bytes([b[8], b[9], b[10], b[11]]), |
| 135 | acc_dm: (acc != ACC_UNKNOWN).then_some(acc), |
| 136 | alt_m: (alt != ALT_UNKNOWN).then_some(alt), |
| 137 | spd_cms: (spd != SPD_UNKNOWN).then_some(spd), |
| 138 | brg_cdeg: (brg != BRG_UNKNOWN).then_some(brg), |
| 139 | bat_pct: (b[20] != BAT_UNKNOWN).then_some(b[20]), |
| 140 | flags: Flags(b[21]), |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | /// Encode a point record. |
| 145 | /// |
| 146 | /// Values that would collide with a sentinel are clamped to the largest |
| 147 | /// representable value, so "20 km up" degrades to "32.767 km up" rather |
| 148 | /// than silently becoming "unknown". |
| 149 | #[must_use] |
| 150 | pub fn to_bytes(self) -> [u8; POINT_LEN] { |
| 151 | let mut b = [0u8; POINT_LEN]; |
| 152 | b[0..4].copy_from_slice(&self.ts.to_be_bytes()); |
| 153 | b[4..8].copy_from_slice(&self.lat_e7.to_be_bytes()); |
| 154 | b[8..12].copy_from_slice(&self.lon_e7.to_be_bytes()); |
| 155 | b[12..14].copy_from_slice( |
| 156 | &self |
| 157 | .acc_dm |
| 158 | .map_or(ACC_UNKNOWN, |v| v.min(ACC_MAX)) |
| 159 | .to_be_bytes(), |
| 160 | ); |
| 161 | b[14..16].copy_from_slice( |
| 162 | &self |
| 163 | .alt_m |
| 164 | .map_or(ALT_UNKNOWN, |v| v.max(ALT_MIN)) |
| 165 | .to_be_bytes(), |
| 166 | ); |
| 167 | b[16..18].copy_from_slice( |
| 168 | &self |
| 169 | .spd_cms |
| 170 | .map_or(SPD_UNKNOWN, |v| v.min(SPD_MAX)) |
| 171 | .to_be_bytes(), |
| 172 | ); |
| 173 | b[18..20].copy_from_slice( |
| 174 | &self |
| 175 | .brg_cdeg |
| 176 | .map_or(BRG_UNKNOWN, |v| v.min(BRG_MAX)) |
| 177 | .to_be_bytes(), |
| 178 | ); |
| 179 | b[20] = self.bat_pct.map_or(BAT_UNKNOWN, |v| v.min(100)); |
| 180 | b[21] = self.flags.0; |
| 181 | // b[22..24] stay zero. |
| 182 | b |
| 183 | } |
| 184 | |
| 185 | /// True when `to_bytes` will not have to clamp anything, i.e. the struct |
| 186 | /// survives a round trip unchanged. |
| 187 | #[must_use] |
| 188 | pub fn is_canonical(self) -> bool { |
| 189 | self.acc_dm.is_none_or(|v| v <= ACC_MAX) |
| 190 | && self.alt_m.is_none_or(|v| v >= ALT_MIN) |
| 191 | && self.spd_cms.is_none_or(|v| v <= SPD_MAX) |
| 192 | && self.brg_cdeg.is_none_or(|v| v <= BRG_MAX) |
| 193 | && self.bat_pct.is_none_or(|v| v <= 100) |
| 194 | } |
| 195 | |
| 196 | /// Clamp every field into its representable range. `to_bytes` does this |
| 197 | /// implicitly; call this when you want the struct itself to agree. |
| 198 | #[must_use] |
| 199 | pub fn canonical(self) -> Self { |
| 200 | Self::from_bytes(&self.to_bytes()) |
| 201 | } |
| 202 | |
| 203 | /// Reject values the server should not store. |
| 204 | /// |
| 205 | /// `now` is the server's clock; timestamps are accepted within ±`window_s` |
| 206 | /// of it. That bound exists to stop a badly-set phone clock from writing |
| 207 | /// points into the year 2100 where retention will never reach them — it is |
| 208 | /// not a security control, since the client clock is trusted by design. |
| 209 | pub fn validate(self, now: u32, window_s: u32) -> Result<(), ValidationError> { |
| 210 | if !(-LAT_MAX_E7..=LAT_MAX_E7).contains(&self.lat_e7) { |
| 211 | return Err(ValidationError::Latitude(self.lat_e7)); |
| 212 | } |
| 213 | if !(-LON_MAX_E7..=LON_MAX_E7).contains(&self.lon_e7) { |
| 214 | return Err(ValidationError::Longitude(self.lon_e7)); |
| 215 | } |
| 216 | if let Some(brg) = self.brg_cdeg |
| 217 | && brg > BRG_MAX |
| 218 | { |
| 219 | return Err(ValidationError::Bearing(brg)); |
| 220 | } |
| 221 | if let Some(bat) = self.bat_pct |
| 222 | && bat > 100 |
| 223 | { |
| 224 | return Err(ValidationError::Battery(bat)); |
| 225 | } |
| 226 | let off_by = i64::from(self.ts) - i64::from(now); |
| 227 | if off_by.unsigned_abs() > u64::from(window_s) { |
| 228 | return Err(ValidationError::Timestamp { |
| 229 | ts: self.ts, |
| 230 | now, |
| 231 | off_by, |
| 232 | }); |
| 233 | } |
| 234 | Ok(()) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | #[cfg(test)] |
| 239 | mod tests { |
| 240 | use super::*; |
| 241 | |
| 242 | #[test] |
| 243 | fn sentinels_round_trip_as_none() { |
| 244 | let p = Point::from_bytes(&[0xFF; POINT_LEN]); |
| 245 | assert_eq!(p.acc_dm, None); |
| 246 | assert_eq!(p.spd_cms, None); |
| 247 | assert_eq!(p.brg_cdeg, None); |
| 248 | assert_eq!(p.bat_pct, None); |
| 249 | // 0xFFFF as i16 is -1, a perfectly good altitude, not the sentinel. |
| 250 | assert_eq!(p.alt_m, Some(-1)); |
| 251 | assert_eq!( |
| 252 | Point::from_bytes(&{ |
| 253 | let mut b = [0u8; POINT_LEN]; |
| 254 | b[14..16].copy_from_slice(&ALT_UNKNOWN.to_be_bytes()); |
| 255 | b |
| 256 | }) |
| 257 | .alt_m, |
| 258 | None |
| 259 | ); |
| 260 | } |
| 261 | |
| 262 | #[test] |
| 263 | fn out_of_range_values_clamp_rather_than_vanish() { |
| 264 | let p = Point { |
| 265 | acc_dm: Some(u16::MAX), |
| 266 | alt_m: Some(i16::MIN), |
| 267 | spd_cms: Some(u16::MAX), |
| 268 | brg_cdeg: Some(40_000), |
| 269 | bat_pct: Some(200), |
| 270 | ..Point::new(0, 0, 0) |
| 271 | }; |
| 272 | assert!(!p.is_canonical()); |
| 273 | let back = p.canonical(); |
| 274 | assert_eq!(back.acc_dm, Some(ACC_MAX)); |
| 275 | assert_eq!(back.alt_m, Some(ALT_MIN)); |
| 276 | assert_eq!(back.spd_cms, Some(SPD_MAX)); |
| 277 | assert_eq!(back.brg_cdeg, Some(BRG_MAX)); |
| 278 | assert_eq!(back.bat_pct, Some(100)); |
| 279 | assert!(back.is_canonical()); |
| 280 | } |
| 281 | |
| 282 | #[test] |
| 283 | fn reserved_bytes_are_written_zero() { |
| 284 | let b = Point::new(1, 2, 3).to_bytes(); |
| 285 | assert_eq!(&b[22..24], &[0, 0]); |
| 286 | } |
| 287 | |
| 288 | #[test] |
| 289 | fn quantization_stays_inside_stated_precision() { |
| 290 | // 1e7 fixed point resolves to ~1.1 cm at the equator; assert the |
| 291 | // encoder does not lose more than one unit. |
| 292 | let lat = 52.520_008_f64; |
| 293 | let e7 = (lat * 1e7).round() as i32; |
| 294 | let p = Point::new(0, e7, 0).canonical(); |
| 295 | assert!((f64::from(p.lat_e7) / 1e7 - lat).abs() < 1e-7); |
| 296 | } |
| 297 | |
| 298 | #[test] |
| 299 | fn validate_rejects_impossible_coordinates() { |
| 300 | let now = 1_785_000_000; |
| 301 | assert!(Point::new(now, 910_000_000, 0).validate(now, 60).is_err()); |
| 302 | assert!( |
| 303 | Point::new(now, 0, -1_810_000_000) |
| 304 | .validate(now, 60) |
| 305 | .is_err() |
| 306 | ); |
| 307 | assert!( |
| 308 | Point::new(now, 525_200_000, 134_050_000) |
| 309 | .validate(now, 60) |
| 310 | .is_ok() |
| 311 | ); |
| 312 | } |
| 313 | |
| 314 | #[test] |
| 315 | fn validate_rejects_timestamps_outside_the_window() { |
| 316 | let now = 1_785_000_000; |
| 317 | assert!(Point::new(now - 61, 0, 0).validate(now, 60).is_err()); |
| 318 | assert!(Point::new(now + 61, 0, 0).validate(now, 60).is_err()); |
| 319 | assert!(Point::new(now - 60, 0, 0).validate(now, 60).is_ok()); |
| 320 | // A zero timestamp must not underflow into "close enough". |
| 321 | assert!(Point::new(0, 0, 0).validate(now, 60).is_err()); |
| 322 | } |
| 323 | } |
| 324 |