umsh_ulcp_runtime/gnss.rs
1//! What a board does with a GNSS fix, shared by every board that has one.
2//!
3//! [`umsh_gnss`] gets a receiver talking and turns its sentences into a
4//! [`Fix`](umsh_gnss::Fix). This turns a `Fix` into the three things UMSH
5//! actually wants from one:
6//!
7//! * the ULCP positioning properties a host reads and the device
8//! announces,
9//! * the wall clock, when the receiver's time is trusted,
10//! * the advertised node identity's location, when that is switched on.
11//!
12//! It lives here rather than in `umsh-gnss` because all three are
13//! protocol concerns, and here rather than in each firmware because both
14//! cargo workspaces would otherwise write it twice. A board contributes
15//! its pins; this contributes the meaning.
16//!
17//! # Reading versus announcing
18//!
19//! The last fix is cached here and nowhere else. The ULCP session
20//! deliberately never caches a reading — a `CMD_PROP_GET` samples — so
21//! "the most recent thing the receiver said" has to live somewhere the
22//! sampler can reach, and this is it.
23//!
24//! **A position is never announced.** Where the device is, how high, how
25//! well it knows, and off how many satellites are all poll-only: a host
26//! that wants them asks, and asks no more often than it has something to
27//! do with the answer. Announcing them instead would put this board on
28//! the air continuously for no one — a receiver reports about a fix a
29//! second, and at the precision the cache keeps, ordinary noise from a
30//! receiver that has not moved is enough to make consecutive readings
31//! differ. Every one of those would have cost a BLE notification and a
32//! wakeup at both ends.
33//!
34//! The fix *indicator* is the exception, and the reason it is one is that
35//! it is not a measurement: it changes when the receiver acquires or
36//! loses a solution, which is a handful of times a session, and it is how
37//! a host knows whether asking is worth anything at all.
38
39use core::cell::Cell;
40use core::sync::atomic::{AtomicBool, Ordering};
41
42use embassy_sync::blocking_mutex::CriticalSectionMutex;
43use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
44use embassy_sync::watch::Watch;
45use umsh_gnss::{Fix, FixQuality};
46use umsh_hal::wall_clock::{self, TimeSource, Update};
47use umsh_node::location::{MAX_PRECISION, NodeLocation};
48use umsh_ulcp::gnss::GnssSnapshot;
49use umsh_ulcp::ids::prop;
50
51/// How many consumers can wait on [`ANNOUNCE`] at once.
52///
53/// One: the ULCP driver's publication arm. A second would be a second
54/// thing publishing the same property.
55const ANNOUNCE_RECEIVERS: usize = 1;
56
57/// Precision the cached snapshot's location is encoded at.
58///
59/// The finest the format carries. What a *host* sees is this; what the
60/// mesh sees is clamped separately by `PROP_GNSS_IDENT_PRECISION`,
61/// because telling the host you are attached to where you are is a
62/// different disclosure from telling the mesh.
63const CACHE_PRECISION: u8 = MAX_PRECISION;
64
65/// Something the device should publish unasked.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum Announce {
68 /// A positioning property, named by its key.
69 Gnss(u32, GnssSnapshot),
70 /// The wall clock; `None` means it went back to unknown.
71 Time(Option<u32>),
72 /// A fix offered to the advertised identity, at full precision with
73 /// the fix's altitude. What becomes of it is the session's decision:
74 /// it holds `PROP_IDENT_LOCATION`, clamps to the advertised
75 /// precision, and ignores the offer outright when the operator has
76 /// taken the position over.
77 IdentityFix(NodeLocation, Option<i32>),
78}
79
80/// How many consumers can wait on [`ENABLE`] at once: the pump, and
81/// nothing else.
82const ENABLE_RECEIVERS: usize = 1;
83
84/// The most recent fix, as the ULCP property surface sees it.
85///
86/// A `CriticalSectionMutex` rather than atomics: the snapshot is bigger
87/// than a word and its fields have to move together, or a host could read
88/// this second's satellite count against last second's position.
89static SNAPSHOT: CriticalSectionMutex<Cell<GnssSnapshot>> =
90 CriticalSectionMutex::new(Cell::new(GnssSnapshot::SEARCHING));
91
92/// `PROP_GNSS_ENABLED`, as the pump sees it.
93static ENABLED: AtomicBool = AtomicBool::new(false);
94
95/// Wakes the pump when [`ENABLED`] moves.
96static ENABLE: Watch<CriticalSectionRawMutex, bool, ENABLE_RECEIVERS> = Watch::new();
97
98/// The rest of the positioning policy, at its post-reset values until the
99/// device domain says otherwise.
100static POLICY: CriticalSectionMutex<Cell<Policy>> = CriticalSectionMutex::new(Cell::new(Policy {
101 trust_time: true,
102 update_identity: false,
103 identity_precision: 5,
104}));
105
106/// Set once the device domain has applied its positioning settings.
107///
108/// A `Watch` because it has to be awaitable and it has to be *late*-
109/// readable: whoever waits on it may arrive long after it fired, and a
110/// `Watch` retains its value where a `Signal` consumes it. One receiver
111/// slot, for the one thing that waits on it.
112static CONFIGURED: Watch<CriticalSectionRawMutex, (), 1> = Watch::new();
113
114/// Publications waiting for the driver's select loop.
115///
116/// A `Watch` rather than a `Signal` because the driver's hook must be
117/// cancellation-safe: the select drops and re-creates it on every other
118/// event, and a `Signal` would lose whatever it was cancelled on.
119static ANNOUNCE: Watch<CriticalSectionRawMutex, Announce, ANNOUNCE_RECEIVERS> = Watch::new();
120
121/// The last cell offered to the identity, at the advertised precision.
122///
123/// A rate limit, not a source of truth — `PROP_IDENT_LOCATION` on the
124/// session is where the advertised position actually lives. A receiver
125/// produces a fix a second and a stationary node's fixes all clamp into
126/// one cell, so without this the driver would wake every second to be
127/// told nothing moved. Reset by [`configure`], since a policy change can
128/// make an already-offered cell worth offering again.
129static OFFERED: CriticalSectionMutex<Cell<Option<NodeLocation>>> =
130 CriticalSectionMutex::new(Cell::new(None));
131
132/// The receiver's current view, for a `CMD_PROP_GET`.
133///
134/// [`GnssSnapshot::SEARCHING`] before the first cycle and after the
135/// receiver is switched off, which is what makes `PROP_GNSS_FIX` read 0
136/// rather than empty on a board that has never had a fix.
137pub fn snapshot() -> GnssSnapshot {
138 SNAPSHOT.lock(|cell| cell.get())
139}
140
141/// A handle on the publication stream, held by whatever drives the ULCP
142/// session's announcement arm.
143pub type Announcer =
144 embassy_sync::watch::Receiver<'static, CriticalSectionRawMutex, Announce, ANNOUNCE_RECEIVERS>;
145
146/// Take the publication receiver.
147///
148/// Cancellation-safe to await. `None` only if one was already taken,
149/// which would mean two things publishing the same properties — a wiring
150/// mistake rather than a runtime condition.
151pub fn announcer() -> Option<Announcer> {
152 ANNOUNCE.receiver()
153}
154
155/// Forget everything the receiver said.
156///
157/// Called when the receiver is switched off: a position from before is a
158/// position the device may no longer be at, and reporting it because it
159/// was the last one seen is how a tracker ends up insisting it is
160/// somewhere it left hours ago.
161///
162/// The *advertised* position is not the receiver's to forget: it is a
163/// claim the operator made, or one they froze by switching auto-update
164/// off, and it lives on the session either way.
165pub fn clear() {
166 SNAPSHOT.lock(|cell| cell.set(GnssSnapshot::SEARCHING));
167 offer(None);
168 publish(Announce::Gnss(prop::GNSS_FIX, GnssSnapshot::SEARCHING));
169}
170
171/// Record a cell as offered, returning whether it differs from the last.
172fn offer(location: Option<NodeLocation>) -> bool {
173 OFFERED.lock(|cell| {
174 let changed = cell.get() != location;
175 if changed {
176 cell.set(location);
177 }
178 changed
179 })
180}
181
182/// The policy a fix is folded in under: everything the device domain says
183/// about what to do with one.
184///
185/// Passed in per fix rather than read from a static, because it comes
186/// from the ULCP device-domain mirror and the sink has no business
187/// holding a second copy of it.
188#[derive(Clone, Copy, Debug, PartialEq, Eq)]
189pub struct Policy {
190 /// `PROP_GNSS_TIME_TRUST`: whether the receiver may set the clock.
191 pub trust_time: bool,
192 /// `PROP_GNSS_IDENT_UPDATE`: whether fixes refresh the advertised
193 /// node identity's location.
194 pub update_identity: bool,
195 /// `PROP_GNSS_IDENT_PRECISION`: what the advertised location is
196 /// clamped to.
197 pub identity_precision: u8,
198}
199
200/// What folding a fix in changed, for a caller that has to act on it.
201#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
202pub struct Outcome {
203 /// The advertised location moved to a new cell and the identity
204 /// should be re-signed.
205 pub identity_moved: bool,
206 /// The wall clock was set or stepped.
207 pub clock_changed: bool,
208}
209
210/// The ULCP view of one fix.
211///
212/// Pure, and separate from [`absorb`] for that reason: this is the part
213/// with rules in it — which fields survive a degraded solution, what a
214/// dilution figure becomes — and it is worth being able to check without
215/// a clock, a receiver, or a board.
216pub fn to_snapshot(fix: &Fix) -> GnssSnapshot {
217 let mut snapshot = GnssSnapshot::SEARCHING;
218 snapshot.fix = match fix.quality {
219 FixQuality::None => umsh_ulcp::gnss::FixKind::None,
220 FixQuality::TwoD => umsh_ulcp::gnss::FixKind::TwoD,
221 FixQuality::ThreeD => umsh_ulcp::gnss::FixKind::ThreeD,
222 };
223 snapshot.sats_used = fix.sats_used;
224 snapshot.sats_in_view = fix.sats_in_view;
225 if let Some(location) = fix_location(fix) {
226 snapshot.set_location(location.as_bytes());
227 }
228 snapshot.altitude_m = fix.altitude_m;
229 snapshot.accuracy_dm = fix.hdop_centi.map(GnssSnapshot::accuracy_from_hdop_centi);
230 snapshot
231}
232
233/// The fix's position at the cache precision, or `None` without one.
234fn fix_location(fix: &Fix) -> Option<NodeLocation> {
235 match (fix.latitude_e7, fix.longitude_e7) {
236 (Some(lat), Some(lon)) => Some(NodeLocation::from_e7(lat, lon, CACHE_PRECISION)),
237 _ => None,
238 }
239}
240
241/// Fold one fix into the property surface, the clock, and the identity.
242///
243/// The single place a fix means anything. Returns what changed, so a
244/// caller can re-sign an identity or note a clock step without inspecting
245/// the fix a second time and reaching a different conclusion.
246pub fn absorb(fix: &Fix, policy: Policy) -> Outcome {
247 let mut outcome = Outcome::default();
248
249 // ─── The property surface ────────────────────────────────────────
250 let previous = snapshot();
251 let current = to_snapshot(fix);
252 let location = fix_location(fix);
253 SNAPSHOT.lock(|cell| cell.set(current));
254
255 // The fix indicator is what a host watches to know whether the device
256 // is located at all, so its transitions are always worth a frame. The
257 // position, altitude, accuracy and satellite count are not announced
258 // at any threshold — see the module docs. They are read.
259 if current.fix != previous.fix {
260 publish(Announce::Gnss(prop::GNSS_FIX, current));
261 }
262
263 // ─── The wall clock ──────────────────────────────────────────────
264 //
265 // Every fix carrying an instant offers it. The precedence rule in
266 // `wall_clock` decides what happens next — a manual set outranks
267 // this, and a cleared trust flag refuses it outright — so there is
268 // no second copy of that decision here.
269 if let Some(at) = fix.time
270 && let Some(epoch) = at.to_unix()
271 {
272 let source = if fix.time_from_fix {
273 TimeSource::GnssFix
274 } else {
275 // A receiver that knows the time without a fix is reading its
276 // own clock, which is a restore rather than a correction.
277 TimeSource::GnssRtc
278 };
279 let update = wall_clock::apply(epoch, source, policy.trust_time);
280 if update.is_notable() {
281 outcome.clock_changed = true;
282 publish(Announce::Time(Some(epoch)));
283 }
284 debug_assert!(
285 !matches!(update, Update::Refused) || !policy.trust_time || !fix.time_from_fix,
286 "a trusted fix time was refused"
287 );
288 }
289
290 // ─── The advertised identity ─────────────────────────────────────
291 //
292 // Offered rather than applied: the session holds the advertised
293 // position and decides what a fix does to it. Only a change in the
294 // *clamped* cell is worth waking it for — at the default precision a
295 // stationary node's fixes all land in the same one.
296 if policy.update_identity
297 && let Some(location) = location
298 {
299 let clamped = location.clamped(policy.identity_precision);
300 if offer(Some(clamped)) {
301 outcome.identity_moved = true;
302 publish(Announce::IdentityFix(location, fix.altitude_m));
303 }
304 }
305
306 outcome
307}
308
309/// Apply the device domain's positioning settings.
310///
311/// Called from the ULCP driver's device-domain mirror, which is what
312/// makes a host write, a boot restore and a `CMD_RST` all reach the
313/// receiver by one path. Switching the receiver off also forgets its last
314/// position: a fix from before is a place the device may have left.
315pub fn configure(enabled: bool, policy: Policy) {
316 let previous = POLICY.lock(|cell| cell.replace(policy));
317 // A policy change can make an already-offered cell worth offering
318 // again — a finer precision asks a different question of the same
319 // fix, and auto-update coming back on has to re-establish a position
320 // the operator may have edited in the meantime.
321 if previous != policy {
322 offer(None);
323 }
324 if ENABLED.swap(enabled, Ordering::AcqRel) != enabled {
325 if !enabled {
326 clear();
327 }
328 ENABLE.sender().send(enabled);
329 }
330 CONFIGURED.sender().send(());
331}
332
333/// Complete once [`configure`] has run at least once.
334///
335/// The boot-time receiver-RTC read needs this. That read is gated on
336/// `PROP_GNSS_TIME_TRUST`, and [`policy`] answers with the post-reset
337/// default until the saved state has been restored — so a read that did
338/// not wait would trust a receiver on a device configured not to, exactly
339/// once per boot, which is the one time it matters.
340///
341/// Returns immediately once it has fired, however long ago.
342///
343/// Single-caller: the channel has one receiver slot, because the boot-time
344/// RTC read is the only thing that needs to wait for this.
345pub async fn wait_configured() {
346 let Some(mut configured) = CONFIGURED.receiver() else {
347 // Silently skipping a wait a caller asked for would be worse than
348 // the wiring bug that got here, but this is not worth a panic on a
349 // shipping device — the cost is one boot's clock restore.
350 debug_assert!(false, "gnss: wait_configured is single-caller");
351 return;
352 };
353 configured.get().await;
354}
355
356/// The positioning policy currently in effect.
357pub fn policy() -> Policy {
358 POLICY.lock(|cell| cell.get())
359}
360
361/// `PROP_GNSS_ENABLED`.
362pub fn enabled() -> bool {
363 ENABLED.load(Ordering::Acquire)
364}
365
366/// The pump's view of the enable switch.
367///
368/// Construct one per pump; there is only one pump.
369pub struct EnableSource {
370 changed:
371 embassy_sync::watch::Receiver<'static, CriticalSectionRawMutex, bool, ENABLE_RECEIVERS>,
372}
373
374impl EnableSource {
375 /// Take the pump's enable receiver, or `None` if one was already
376 /// taken — which would mean two pumps for one receiver.
377 pub fn new() -> Option<Self> {
378 ENABLE.receiver().map(|changed| Self { changed })
379 }
380}
381
382impl umsh_gnss::pump::Enable for EnableSource {
383 fn enabled(&self) -> bool {
384 // Read the flag rather than the last value seen on the channel:
385 // the pump asks this after a cancelled wait, and the answer must
386 // be what is true now.
387 enabled()
388 }
389
390 async fn changed(&mut self) {
391 self.changed.changed().await;
392 }
393}
394
395/// The pump's sink: folds each cycle into the property surface, the
396/// clock, and the advertised identity under the current policy.
397pub struct FixSink;
398
399impl umsh_gnss::pump::Sink for FixSink {
400 async fn fix(&mut self, fix: &Fix) {
401 absorb(fix, policy());
402 }
403}
404
405fn publish(announce: Announce) {
406 ANNOUNCE.sender().send(announce);
407}
408
409/// A time driver for the host tests.
410///
411/// [`absorb`] offers every fix's instant to the wall clock, so a test
412/// that calls it links `embassy_time`'s driver hook — which on a device
413/// is the RTC and here is nothing at all. A monotonic counter is enough:
414/// no test in this module asserts on elapsed time.
415#[cfg(test)]
416mod test_driver {
417 use core::sync::atomic::{AtomicU64, Ordering};
418 use core::task::Waker;
419
420 struct Stub;
421
422 impl embassy_time_driver::Driver for Stub {
423 fn now(&self) -> u64 {
424 static TICKS: AtomicU64 = AtomicU64::new(0);
425 TICKS.fetch_add(1, Ordering::Relaxed)
426 }
427
428 fn schedule_wake(&self, _at: u64, _waker: &Waker) {
429 unimplemented!("these tests never wait on a timer");
430 }
431 }
432
433 embassy_time_driver::time_driver_impl!(static DRIVER: Stub = Stub);
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use umsh_gnss::DateTime;
440
441 fn policy() -> Policy {
442 Policy {
443 trust_time: true,
444 update_identity: false,
445 identity_precision: 5,
446 }
447 }
448
449 fn fixed_at(lat_e7: i32, lon_e7: i32) -> Fix {
450 Fix {
451 quality: FixQuality::ThreeD,
452 latitude_e7: Some(lat_e7),
453 longitude_e7: Some(lon_e7),
454 altitude_m: Some(31),
455 hdop_centi: Some(120),
456 sats_used: 9,
457 sats_in_view: Some(13),
458 time: Some(DateTime {
459 year: 2026,
460 month: 8,
461 day: 4,
462 hour: 12,
463 minute: 0,
464 second: 0,
465 }),
466 time_from_fix: true,
467 }
468 }
469
470 #[test]
471 fn a_fix_becomes_the_property_surface() {
472 let snapshot = to_snapshot(&fixed_at(481_173_000, 115_166_666));
473 assert_eq!(snapshot.fix, umsh_ulcp::gnss::FixKind::ThreeD);
474 assert_eq!(snapshot.sats_used, 9);
475 assert_eq!(snapshot.sats_in_view, Some(13));
476 assert_eq!(snapshot.altitude_m, Some(31));
477 // HDOP 1.20 scaled by the assumed range error.
478 assert_eq!(snapshot.accuracy_dm, Some(60));
479 assert_eq!(snapshot.location().len(), MAX_PRECISION as usize);
480 // The cached location is the finest the format carries: what the
481 // *mesh* sees is clamped separately, because telling a host
482 // where you are is a different disclosure from telling everyone.
483 assert_eq!(
484 snapshot.location(),
485 NodeLocation::from_e7(481_173_000, 115_166_666, MAX_PRECISION).as_bytes()
486 );
487 }
488
489 /// A searching receiver reports zero for the facts it is sure of and
490 /// empty for the position it does not have — never a stale one.
491 #[test]
492 fn a_searching_cycle_carries_no_position() {
493 let snapshot = to_snapshot(&Fix::default());
494 assert_eq!(snapshot.fix, umsh_ulcp::gnss::FixKind::None);
495 assert!(snapshot.location().is_empty());
496 assert_eq!(snapshot.altitude_m, None);
497 assert_eq!(snapshot.accuracy_dm, None);
498 assert_eq!(snapshot.sats_used, 0);
499 }
500
501 /// Half a coordinate pair is not a position.
502 #[test]
503 fn a_partial_coordinate_pair_yields_no_location() {
504 let mut fix = fixed_at(481_173_000, 115_166_666);
505 fix.longitude_e7 = None;
506 assert!(to_snapshot(&fix).location().is_empty());
507 }
508
509 /// The advertised cell is what decides whether an identity is worth
510 /// re-signing: at the default precision a stationary node's fixes all
511 /// land in the same cell, and re-signing each would put a fresh
512 /// identity on the air every second to say what the last one said.
513 #[test]
514 fn only_a_change_of_advertised_cell_is_a_move() {
515 let precision = policy().identity_precision;
516 let here = fix_location(&fixed_at(481_173_000, 115_166_666))
517 .unwrap()
518 .clamped(precision);
519 // A jitter of a few centimeters.
520 let jittered = fix_location(&fixed_at(481_173_010, 115_166_680))
521 .unwrap()
522 .clamped(precision);
523 assert_eq!(here, jittered, "a sub-cell jitter changed the cell");
524
525 let elsewhere = fix_location(&fixed_at(490_000_000, 120_000_000))
526 .unwrap()
527 .clamped(precision);
528 assert_ne!(here, elsewhere);
529
530 // The advertised value is coarser than the cached one, and is a
531 // prefix of it: the same position, said less precisely.
532 let cached = fix_location(&fixed_at(481_173_000, 115_166_666)).unwrap();
533 assert_eq!(here.precision(), precision);
534 assert_eq!(here.as_bytes(), &cached.as_bytes()[..precision as usize]);
535 }
536
537 /// Which fixes are worth offering to the identity, in one test on
538 /// purpose: it is the only one that touches the module's statics, and
539 /// two of them would race each other for the same globals.
540 ///
541 /// What *becomes* of an offer is the session's business — it holds
542 /// `PROP_IDENT_LOCATION` and has its own tests. All this module
543 /// decides is whether waking the driver is warranted.
544 #[test]
545 fn a_fix_is_offered_only_when_the_advertised_cell_moves() {
546 // Undated fixes throughout: what the clock does with a fix is a
547 // separate decision with its own tests, and letting these set it
548 // would leave a wall clock behind for whatever runs next.
549 let untimed = |lat_e7, lon_e7| Fix {
550 time: None,
551 ..fixed_at(lat_e7, lon_e7)
552 };
553 let updating = Policy {
554 update_identity: true,
555 ..policy()
556 };
557
558 // The first fix under the switch is offered; a second in the same
559 // cell is not.
560 configure(true, updating);
561 let here = untimed(481_173_000, 115_166_666);
562 assert!(absorb(&here, updating).identity_moved);
563 assert!(!absorb(&here, updating).identity_moved);
564
565 // Far enough to leave the cell.
566 assert!(absorb(&untimed(490_000_000, 120_000_000), updating).identity_moved);
567
568 // A fix arriving while the switch is off is not offered at all.
569 configure(true, policy());
570 assert!(!absorb(&here, policy()).identity_moved);
571
572 // Switching back on re-arms it: the operator may have edited the
573 // advertised position while the device was not maintaining it,
574 // and the same cell is now worth offering again.
575 configure(true, updating);
576 assert!(absorb(&here, updating).identity_moved);
577
578 // So does switching the receiver off and back on — the position
579 // it comes back with has to reach the identity even if the
580 // device never left.
581 configure(false, updating);
582 configure(true, updating);
583 assert!(absorb(&here, updating).identity_moved);
584 }
585}