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}
73
74/// How many consumers can wait on [`ENABLE`] at once: the pump, and
75/// nothing else.
76const ENABLE_RECEIVERS: usize = 1;
77
78/// How many consumers can wait on [`IDENTITY`] at once: whatever owns the
79/// node's identity profile. A second would be a second writer of the same
80/// three fields.
81const IDENTITY_RECEIVERS: usize = 1;
82
83/// The most recent fix, as the ULCP property surface sees it.
84///
85/// A `CriticalSectionMutex` rather than atomics: the snapshot is bigger
86/// than a word and its fields have to move together, or a host could read
87/// this second's satellite count against last second's position.
88static SNAPSHOT: CriticalSectionMutex<Cell<GnssSnapshot>> =
89    CriticalSectionMutex::new(Cell::new(GnssSnapshot::SEARCHING));
90
91/// `PROP_GNSS_ENABLED`, as the pump sees it.
92static ENABLED: AtomicBool = AtomicBool::new(false);
93
94/// Wakes the pump when [`ENABLED`] moves.
95static ENABLE: Watch<CriticalSectionRawMutex, bool, ENABLE_RECEIVERS> = Watch::new();
96
97/// The rest of the positioning policy, at its post-reset values until the
98/// device domain says otherwise.
99static POLICY: CriticalSectionMutex<Cell<Policy>> = CriticalSectionMutex::new(Cell::new(Policy {
100    trust_time: true,
101    update_identity: false,
102    identity_precision: 5,
103}));
104
105/// Set once the device domain has applied its positioning settings.
106///
107/// A `Watch` because it has to be awaitable and it has to be *late*-
108/// readable: whoever waits on it may arrive long after it fired, and a
109/// `Watch` retains its value where a `Signal` consumes it. One receiver
110/// slot, for the one thing that waits on it.
111static CONFIGURED: Watch<CriticalSectionRawMutex, (), 1> = Watch::new();
112
113/// Publications waiting for the driver's select loop.
114///
115/// A `Watch` rather than a `Signal` because the driver's hook must be
116/// cancellation-safe: the select drops and re-creates it on every other
117/// event, and a `Signal` would lose whatever it was cancelled on.
118static ANNOUNCE: Watch<CriticalSectionRawMutex, Announce, ANNOUNCE_RECEIVERS> = Watch::new();
119
120/// The position last handed to the node identity, at the advertised
121/// precision. `None` until one has been advertised.
122static ADVERTISED: CriticalSectionMutex<Cell<Option<NodeLocation>>> =
123    CriticalSectionMutex::new(Cell::new(None));
124
125/// Wakes whoever owns the identity profile when [`ADVERTISED`] moves.
126///
127/// A `Watch` for the same reason [`ANNOUNCE`] is one: the consumer selects
128/// on this against a refresh timer, so the wait is dropped and rebuilt
129/// constantly and must not lose an edge it was cancelled on.
130static IDENTITY: Watch<CriticalSectionRawMutex, (), IDENTITY_RECEIVERS> = Watch::new();
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.
161pub fn clear() {
162    SNAPSHOT.lock(|cell| cell.set(GnssSnapshot::SEARCHING));
163    set_advertised(None);
164    publish(Announce::Gnss(prop::GNSS_FIX, GnssSnapshot::SEARCHING));
165}
166
167/// Move the advertised position, waking the identity owner if this
168/// changed it. Returns whether it did.
169fn set_advertised(location: Option<NodeLocation>) -> bool {
170    let changed = ADVERTISED.lock(|cell| {
171        let changed = cell.get() != location;
172        if changed {
173            cell.set(location);
174        }
175        changed
176    });
177    if changed {
178        IDENTITY.sender().send(());
179    }
180    changed
181}
182
183/// The policy a fix is folded in under: everything the device domain says
184/// about what to do with one.
185///
186/// Passed in per fix rather than read from a static, because it comes
187/// from the ULCP device-domain mirror and the sink has no business
188/// holding a second copy of it.
189#[derive(Clone, Copy, Debug, PartialEq, Eq)]
190pub struct Policy {
191    /// `PROP_GNSS_TIME_TRUST`: whether the receiver may set the clock.
192    pub trust_time: bool,
193    /// `PROP_GNSS_IDENT_UPDATE`: whether fixes refresh the advertised
194    /// node identity's location.
195    pub update_identity: bool,
196    /// `PROP_GNSS_IDENT_PRECISION`: what the advertised location is
197    /// clamped to.
198    pub identity_precision: u8,
199}
200
201/// What folding a fix in changed, for a caller that has to act on it.
202#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
203pub struct Outcome {
204    /// The advertised location moved to a new cell and the identity
205    /// should be re-signed.
206    pub identity_moved: bool,
207    /// The wall clock was set or stepped.
208    pub clock_changed: bool,
209}
210
211/// The ULCP view of one fix.
212///
213/// Pure, and separate from [`absorb`] for that reason: this is the part
214/// with rules in it — which fields survive a degraded solution, what a
215/// dilution figure becomes — and it is worth being able to check without
216/// a clock, a receiver, or a board.
217pub fn to_snapshot(fix: &Fix) -> GnssSnapshot {
218    let mut snapshot = GnssSnapshot::SEARCHING;
219    snapshot.fix = match fix.quality {
220        FixQuality::None => umsh_ulcp::gnss::FixKind::None,
221        FixQuality::TwoD => umsh_ulcp::gnss::FixKind::TwoD,
222        FixQuality::ThreeD => umsh_ulcp::gnss::FixKind::ThreeD,
223    };
224    snapshot.sats_used = fix.sats_used;
225    snapshot.sats_in_view = fix.sats_in_view;
226    if let Some(location) = fix_location(fix) {
227        snapshot.set_location(location.as_bytes());
228    }
229    snapshot.altitude_m = fix.altitude_m;
230    snapshot.accuracy_dm = fix.hdop_centi.map(GnssSnapshot::accuracy_from_hdop_centi);
231    snapshot
232}
233
234/// The fix's position at the cache precision, or `None` without one.
235fn fix_location(fix: &Fix) -> Option<NodeLocation> {
236    match (fix.latitude_e7, fix.longitude_e7) {
237        (Some(lat), Some(lon)) => Some(NodeLocation::from_e7(lat, lon, CACHE_PRECISION)),
238        _ => None,
239    }
240}
241
242/// Fold one fix into the property surface, the clock, and the identity.
243///
244/// The single place a fix means anything. Returns what changed, so a
245/// caller can re-sign an identity or note a clock step without inspecting
246/// the fix a second time and reaching a different conclusion.
247pub fn absorb(fix: &Fix, policy: Policy) -> Outcome {
248    let mut outcome = Outcome::default();
249
250    // ─── The property surface ────────────────────────────────────────
251    let previous = snapshot();
252    let current = to_snapshot(fix);
253    let location = fix_location(fix);
254    SNAPSHOT.lock(|cell| cell.set(current));
255
256    // The fix indicator is what a host watches to know whether the device
257    // is located at all, so its transitions are always worth a frame. The
258    // position, altitude, accuracy and satellite count are not announced
259    // at any threshold — see the module docs. They are read.
260    if current.fix != previous.fix {
261        publish(Announce::Gnss(prop::GNSS_FIX, current));
262    }
263
264    // ─── The wall clock ──────────────────────────────────────────────
265    //
266    // Every fix carrying an instant offers it. The precedence rule in
267    // `wall_clock` decides what happens next — a manual set outranks
268    // this, and a cleared trust flag refuses it outright — so there is
269    // no second copy of that decision here.
270    if let Some(at) = fix.time
271        && let Some(epoch) = at.to_unix()
272    {
273        let source = if fix.time_from_fix {
274            TimeSource::GnssFix
275        } else {
276            // A receiver that knows the time without a fix is reading its
277            // own clock, which is a restore rather than a correction.
278            TimeSource::GnssRtc
279        };
280        let update = wall_clock::apply(epoch, source, policy.trust_time);
281        if update.is_notable() {
282            outcome.clock_changed = true;
283            publish(Announce::Time(Some(epoch)));
284        }
285        debug_assert!(
286            !matches!(update, Update::Refused) || !policy.trust_time || !fix.time_from_fix,
287            "a trusted fix time was refused"
288        );
289    }
290
291    // ─── The advertised identity ─────────────────────────────────────
292    //
293    // Only a change in the *clamped* cell counts. At the default
294    // precision a stationary node's fixes all land in the same cell, and
295    // re-signing for each would put a fresh identity on the air every
296    // second to say exactly what the last one said.
297    if policy.update_identity
298        && let Some(location) = location
299    {
300        outcome.identity_moved = set_advertised(Some(location.clamped(policy.identity_precision)));
301    }
302
303    outcome
304}
305
306/// Apply the device domain's positioning settings.
307///
308/// Called from the ULCP driver's device-domain mirror, which is what
309/// makes a host write, a boot restore and a `CMD_RST` all reach the
310/// receiver by one path. Switching the receiver off also forgets its last
311/// position: a fix from before is a place the device may have left.
312pub fn configure(enabled: bool, policy: Policy) {
313    POLICY.lock(|cell| cell.set(policy));
314    // Switching the update off retracts what it put there. Leaving the
315    // last auto-set cell in place would advertise a position nothing is
316    // refreshing any more, which ages into a lie at walking pace — and it
317    // would make the switch mean "stop correcting my location" rather
318    // than "stop telling people where I am".
319    if !policy.update_identity {
320        set_advertised(None);
321    }
322    if ENABLED.swap(enabled, Ordering::AcqRel) != enabled {
323        if !enabled {
324            clear();
325        }
326        ENABLE.sender().send(enabled);
327    }
328    CONFIGURED.sender().send(());
329}
330
331/// Complete once [`configure`] has run at least once.
332///
333/// The boot-time receiver-RTC read needs this. That read is gated on
334/// `PROP_GNSS_TIME_TRUST`, and [`policy`] answers with the post-reset
335/// default until the saved state has been restored — so a read that did
336/// not wait would trust a receiver on a device configured not to, exactly
337/// once per boot, which is the one time it matters.
338///
339/// Returns immediately once it has fired, however long ago.
340///
341/// Single-caller: the channel has one receiver slot, because the boot-time
342/// RTC read is the only thing that needs to wait for this.
343pub async fn wait_configured() {
344    let Some(mut configured) = CONFIGURED.receiver() else {
345        // Silently skipping a wait a caller asked for would be worse than
346        // the wiring bug that got here, but this is not worth a panic on a
347        // shipping device — the cost is one boot's clock restore.
348        debug_assert!(false, "gnss: wait_configured is single-caller");
349        return;
350    };
351    configured.get().await;
352}
353
354/// The positioning policy currently in effect.
355pub fn policy() -> Policy {
356    POLICY.lock(|cell| cell.get())
357}
358
359/// `PROP_GNSS_ENABLED`.
360pub fn enabled() -> bool {
361    ENABLED.load(Ordering::Acquire)
362}
363
364/// The pump's view of the enable switch.
365///
366/// Construct one per pump; there is only one pump.
367pub struct EnableSource {
368    changed:
369        embassy_sync::watch::Receiver<'static, CriticalSectionRawMutex, bool, ENABLE_RECEIVERS>,
370}
371
372impl EnableSource {
373    /// Take the pump's enable receiver, or `None` if one was already
374    /// taken — which would mean two pumps for one receiver.
375    pub fn new() -> Option<Self> {
376        ENABLE.receiver().map(|changed| Self { changed })
377    }
378}
379
380impl umsh_gnss::pump::Enable for EnableSource {
381    fn enabled(&self) -> bool {
382        // Read the flag rather than the last value seen on the channel:
383        // the pump asks this after a cancelled wait, and the answer must
384        // be what is true now.
385        enabled()
386    }
387
388    async fn changed(&mut self) {
389        self.changed.changed().await;
390    }
391}
392
393/// The pump's sink: folds each cycle into the property surface, the
394/// clock, and the advertised identity under the current policy.
395pub struct FixSink;
396
397impl umsh_gnss::pump::Sink for FixSink {
398    async fn fix(&mut self, fix: &Fix) {
399        absorb(fix, policy());
400    }
401}
402
403/// The location to advertise, at the precision it is advertised at, or
404/// `None` when there is nothing to advertise.
405pub fn advertised_location() -> Option<NodeLocation> {
406    ADVERTISED.lock(|cell| cell.get())
407}
408
409/// The altitude to advertise alongside it.
410pub fn advertised_altitude_m() -> Option<i32> {
411    snapshot().altitude_m
412}
413
414/// A handle on the advertised position, held by whatever owns the node's
415/// identity profile.
416pub type IdentityUpdates =
417    embassy_sync::watch::Receiver<'static, CriticalSectionRawMutex, (), IDENTITY_RECEIVERS>;
418
419/// Take the identity-update receiver.
420///
421/// Cancellation-safe to await. `None` only if one was already taken,
422/// which would mean two things writing the same profile fields.
423pub fn identity_updates() -> Option<IdentityUpdates> {
424    IDENTITY.receiver()
425}
426
427/// Write the advertised position into a node identity profile.
428///
429/// The one place that decides what an identity says about where the node
430/// is, called both by the loop that reacts to movement and by the
431/// device-domain sync that rebuilds the profile from scratch — those two
432/// must not be able to disagree, and a profile rebuilt without this would
433/// silently drop the position until the node next moved.
434///
435/// Both fields move together, including to `None`: an altitude without a
436/// position describes nothing.
437pub fn stamp_identity(profile: &mut umsh_node::NodeIdentityProfile) {
438    let location = advertised_location();
439    profile.location = location;
440    profile.altitude_m = location.and(advertised_altitude_m());
441}
442
443fn publish(announce: Announce) {
444    ANNOUNCE.sender().send(announce);
445}
446
447/// A time driver for the host tests.
448///
449/// [`absorb`] offers every fix's instant to the wall clock, so a test
450/// that calls it links `embassy_time`'s driver hook — which on a device
451/// is the RTC and here is nothing at all. A monotonic counter is enough:
452/// no test in this module asserts on elapsed time.
453#[cfg(test)]
454mod test_driver {
455    use core::sync::atomic::{AtomicU64, Ordering};
456    use core::task::Waker;
457
458    struct Stub;
459
460    impl embassy_time_driver::Driver for Stub {
461        fn now(&self) -> u64 {
462            static TICKS: AtomicU64 = AtomicU64::new(0);
463            TICKS.fetch_add(1, Ordering::Relaxed)
464        }
465
466        fn schedule_wake(&self, _at: u64, _waker: &Waker) {
467            unimplemented!("these tests never wait on a timer");
468        }
469    }
470
471    embassy_time_driver::time_driver_impl!(static DRIVER: Stub = Stub);
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use umsh_gnss::DateTime;
478
479    fn policy() -> Policy {
480        Policy {
481            trust_time: true,
482            update_identity: false,
483            identity_precision: 5,
484        }
485    }
486
487    fn fixed_at(lat_e7: i32, lon_e7: i32) -> Fix {
488        Fix {
489            quality: FixQuality::ThreeD,
490            latitude_e7: Some(lat_e7),
491            longitude_e7: Some(lon_e7),
492            altitude_m: Some(31),
493            hdop_centi: Some(120),
494            sats_used: 9,
495            sats_in_view: Some(13),
496            time: Some(DateTime {
497                year: 2026,
498                month: 8,
499                day: 4,
500                hour: 12,
501                minute: 0,
502                second: 0,
503            }),
504            time_from_fix: true,
505        }
506    }
507
508    #[test]
509    fn a_fix_becomes_the_property_surface() {
510        let snapshot = to_snapshot(&fixed_at(481_173_000, 115_166_666));
511        assert_eq!(snapshot.fix, umsh_ulcp::gnss::FixKind::ThreeD);
512        assert_eq!(snapshot.sats_used, 9);
513        assert_eq!(snapshot.sats_in_view, Some(13));
514        assert_eq!(snapshot.altitude_m, Some(31));
515        // HDOP 1.20 scaled by the assumed range error.
516        assert_eq!(snapshot.accuracy_dm, Some(60));
517        assert_eq!(snapshot.location().len(), MAX_PRECISION as usize);
518        // The cached location is the finest the format carries: what the
519        // *mesh* sees is clamped separately, because telling a host
520        // where you are is a different disclosure from telling everyone.
521        assert_eq!(
522            snapshot.location(),
523            NodeLocation::from_e7(481_173_000, 115_166_666, MAX_PRECISION).as_bytes()
524        );
525    }
526
527    /// A searching receiver reports zero for the facts it is sure of and
528    /// empty for the position it does not have — never a stale one.
529    #[test]
530    fn a_searching_cycle_carries_no_position() {
531        let snapshot = to_snapshot(&Fix::default());
532        assert_eq!(snapshot.fix, umsh_ulcp::gnss::FixKind::None);
533        assert!(snapshot.location().is_empty());
534        assert_eq!(snapshot.altitude_m, None);
535        assert_eq!(snapshot.accuracy_dm, None);
536        assert_eq!(snapshot.sats_used, 0);
537    }
538
539    /// Half a coordinate pair is not a position.
540    #[test]
541    fn a_partial_coordinate_pair_yields_no_location() {
542        let mut fix = fixed_at(481_173_000, 115_166_666);
543        fix.longitude_e7 = None;
544        assert!(to_snapshot(&fix).location().is_empty());
545    }
546
547    /// The advertised cell is what decides whether an identity is worth
548    /// re-signing: at the default precision a stationary node's fixes all
549    /// land in the same cell, and re-signing each would put a fresh
550    /// identity on the air every second to say what the last one said.
551    #[test]
552    fn only_a_change_of_advertised_cell_is_a_move() {
553        let precision = policy().identity_precision;
554        let here = fix_location(&fixed_at(481_173_000, 115_166_666))
555            .unwrap()
556            .clamped(precision);
557        // A jitter of a few centimetres.
558        let jittered = fix_location(&fixed_at(481_173_010, 115_166_680))
559            .unwrap()
560            .clamped(precision);
561        assert_eq!(here, jittered, "a sub-cell jitter changed the cell");
562
563        let elsewhere = fix_location(&fixed_at(490_000_000, 120_000_000))
564            .unwrap()
565            .clamped(precision);
566        assert_ne!(here, elsewhere);
567
568        // The advertised value is coarser than the cached one, and is a
569        // prefix of it: the same position, said less precisely.
570        let cached = fix_location(&fixed_at(481_173_000, 115_166_666)).unwrap();
571        assert_eq!(here.precision(), precision);
572        assert_eq!(here.as_bytes(), &cached.as_bytes()[..precision as usize]);
573    }
574
575    /// The whole advertised-position lifecycle, in one test on purpose:
576    /// it is the only one that touches the module's statics, and two of
577    /// them would race each other for the same globals.
578    #[test]
579    fn the_advertised_position_follows_the_switch_that_governs_it() {
580        let profile = || {
581            umsh_node::NodeIdentityProfile::new(
582                umsh_core::PublicKey([7; 32]),
583                umsh_node::NodeRole::Tracker,
584                umsh_node::NodeCapabilities::empty(),
585            )
586        };
587        // Undated fixes throughout: what the clock does with a fix is a
588        // separate decision with its own tests, and letting these set it
589        // would leave a wall clock behind for whatever runs next.
590        let untimed = |lat_e7, lon_e7| Fix {
591            time: None,
592            ..fixed_at(lat_e7, lon_e7)
593        };
594        let updating = Policy {
595            update_identity: true,
596            ..policy()
597        };
598
599        // Nothing advertised until a fix arrives under the switch.
600        configure(true, updating);
601        assert_eq!(advertised_location(), None);
602        let mut blank = profile();
603        stamp_identity(&mut blank);
604        assert_eq!(blank.location, None);
605        assert_eq!(blank.altitude_m, None, "an altitude with no position");
606
607        // The first fix moves it; a second in the same cell does not.
608        let here = untimed(481_173_000, 115_166_666);
609        assert!(absorb(&here, updating).identity_moved);
610        assert!(!absorb(&here, updating).identity_moved);
611        let advertised = advertised_location().expect("a fix went unadvertised");
612        assert_eq!(advertised.precision(), updating.identity_precision);
613
614        let mut located = profile();
615        stamp_identity(&mut located);
616        assert_eq!(located.location, Some(advertised));
617        assert_eq!(located.altitude_m, Some(31));
618
619        // Far enough to leave the cell.
620        assert!(absorb(&untimed(490_000_000, 120_000_000), updating).identity_moved);
621
622        // Turning the switch off retracts what it advertised, rather than
623        // leaving a position nothing is refreshing any more.
624        configure(true, policy());
625        assert_eq!(advertised_location(), None);
626        let mut retracted = profile();
627        stamp_identity(&mut retracted);
628        assert_eq!(retracted.location, None);
629        assert_eq!(retracted.altitude_m, None);
630
631        // And a fix arriving while it is off changes nothing.
632        assert!(!absorb(&here, policy()).identity_moved);
633        assert_eq!(advertised_location(), None);
634
635        // Switching the receiver off retracts it too: the last position
636        // is a place the device may have left.
637        configure(true, updating);
638        assert!(absorb(&here, updating).identity_moved);
639        configure(false, updating);
640        assert_eq!(advertised_location(), None);
641    }
642}