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}