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}