point.rs
⎇
Raw
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
21use crate::error::ValidationError;
22
23/// Wire size of one point record.
24pub const POINT_LEN: usize = 24;
25
26const ACC_UNKNOWN: u16 = 0xFFFF;
27const ALT_UNKNOWN: i16 = i16::MIN; // 0x8000
28const SPD_UNKNOWN: u16 = 0xFFFF;
29const BRG_UNKNOWN: u16 = 0xFFFF;
30const BAT_UNKNOWN: u8 = 0xFF;
31
32/// Largest representable value for each sentinel-terminated field.
33const ACC_MAX: u16 = ACC_UNKNOWN - 1;
34const SPD_MAX: u16 = SPD_UNKNOWN - 1;
35const BRG_MAX: u16 = 35_999;
36const ALT_MIN: i16 = i16::MIN + 1;
37
38pub const LAT_MAX_E7: i32 = 900_000_000;
39pub 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))]
45pub struct Flags(pub u8);
46
47impl 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
72impl 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))]
86pub 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
103impl 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)]
239mod 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