1use alloc::string::String;
21use alloc::vec::Vec;
22
23use umsh_core::{NodeHint, PublicKey, RouterHint};
24
25use crate::identity::{NodeCapabilities, NodeIdentityPayload, NodeRole};
26use crate::location::NodeLocation;
27
28pub const MAX_PEER_REPEATERS: usize = 16;
34
35pub const MAX_PEER_REGIONS: usize = 10;
37
38#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct PeerRepeaterRecord {
41 pub hint: NodeHint,
43 pub name: Option<String>,
44 pub location: Option<NodeLocation>,
45 pub regions: Vec<[u8; 2]>,
48 pub last_identity_ms: u64,
50}
51
52impl PeerRepeaterRecord {
53 pub fn router_hint(&self) -> RouterHint {
56 RouterHint([self.hint.0[0], self.hint.0[1]])
57 }
58}
59
60#[derive(Clone, Debug, Default)]
65pub struct PeerRepeaterTable {
66 records: Vec<PeerRepeaterRecord>,
67 generation: u16,
70}
71
72impl PeerRepeaterTable {
73 pub fn new() -> Self {
74 Self::default()
75 }
76
77 pub fn is_repeater(identity: &NodeIdentityPayload) -> bool {
83 identity.capabilities.contains(NodeCapabilities::REPEATER)
84 || identity.role == NodeRole::Repeater
85 }
86
87 pub fn observe_identity(
93 &mut self,
94 from: &PublicKey,
95 identity: &NodeIdentityPayload,
96 now_ms: u64,
97 ) -> bool {
98 if !Self::is_repeater(identity) {
99 return false;
100 }
101 let record = PeerRepeaterRecord {
102 hint: from.hint(),
103 name: identity.name.clone(),
104 location: identity.location,
105 regions: region_codes(identity),
106 last_identity_ms: now_ms,
107 };
108 self.generation = self.generation.wrapping_add(1);
109 if let Some(existing) = self
110 .records
111 .iter_mut()
112 .find(|entry| entry.hint == record.hint)
113 {
114 *existing = record;
115 return true;
116 }
117 if self.records.len() < MAX_PEER_REPEATERS {
118 self.records.push(record);
119 return true;
120 }
121 let Some(oldest) = self
124 .records
125 .iter_mut()
126 .min_by_key(|entry| entry.last_identity_ms)
127 else {
128 return false;
129 };
130 *oldest = record;
131 true
132 }
133
134 pub fn iter(&self) -> impl Iterator<Item = &PeerRepeaterRecord> {
135 self.records.iter()
136 }
137
138 pub fn len(&self) -> usize {
139 self.records.len()
140 }
141
142 pub fn is_empty(&self) -> bool {
143 self.records.is_empty()
144 }
145
146 pub fn generation(&self) -> u16 {
149 self.generation
150 }
151}
152
153fn region_codes(identity: &NodeIdentityPayload) -> Vec<[u8; 2]> {
159 let Some(regions) = identity.supported_regions.as_ref() else {
160 return Vec::new();
161 };
162 regions
163 .iter()
164 .filter_map(|region| region.parse::<umsh_core::RegionCode>().ok())
165 .map(|code| code.to_bytes())
166 .take(MAX_PEER_REGIONS)
167 .collect()
168}
169
170#[derive(Clone, Debug, PartialEq, Eq)]
173pub struct MergedPeerRepeater {
174 pub hint: Vec<u8>,
177 pub name: Option<String>,
178 pub location: Option<NodeLocation>,
179 pub regions: Vec<[u8; 2]>,
180 pub rssi_dbm: Option<i16>,
182 pub snr: Option<umsh_hal::Snr>,
183 pub last_heard_min: Option<u16>,
185}
186
187pub fn merge<'a>(
195 identities: &PeerRepeaterTable,
196 observations: impl IntoIterator<Item = &'a umsh_mac::TransmitterObservation>,
197 now_ms: u64,
198) -> Vec<MergedPeerRepeater> {
199 let observations: Vec<&umsh_mac::TransmitterObservation> = observations.into_iter().collect();
200 let mut merged = Vec::new();
201 let mut claimed: Vec<RouterHint> = Vec::new();
202
203 for record in identities.iter() {
204 let router_hint = record.router_hint();
205 let observation = observations
206 .iter()
207 .find(|entry| entry.hint == router_hint)
208 .copied();
209 if observation.is_some() {
210 claimed.push(router_hint);
211 }
212 merged.push(MergedPeerRepeater {
213 hint: Vec::from(&record.hint.0[..]),
214 name: record.name.clone(),
215 location: record.location,
216 regions: record.regions.clone(),
217 rssi_dbm: observation.map(|entry| entry.rssi_dbm),
218 snr: observation.map(|entry| entry.snr),
219 last_heard_min: minutes_since(
220 [
221 Some(record.last_identity_ms),
222 observation.map(|entry| entry.last_seen_ms),
223 ]
224 .into_iter()
225 .flatten()
226 .max(),
227 now_ms,
228 ),
229 });
230 }
231
232 for observation in observations {
233 if claimed.contains(&observation.hint) {
234 continue;
235 }
236 merged.push(MergedPeerRepeater {
237 hint: Vec::from(&observation.hint.0[..]),
238 name: None,
239 location: None,
240 regions: Vec::new(),
241 rssi_dbm: Some(observation.rssi_dbm),
242 snr: Some(observation.snr),
243 last_heard_min: minutes_since(Some(observation.last_seen_ms), now_ms),
244 });
245 }
246
247 merged
248}
249
250fn minutes_since(then_ms: Option<u64>, now_ms: u64) -> Option<u16> {
254 let then_ms = then_ms?;
255 let elapsed_min = now_ms.saturating_sub(then_ms) / 60_000;
256 Some(u16::try_from(elapsed_min).unwrap_or(u16::MAX))
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use umsh_hal::Snr;
263
264 fn key(seed: u8) -> PublicKey {
265 PublicKey([seed; 32])
266 }
267
268 fn repeater_identity(name: &str, regions: &[&str]) -> NodeIdentityPayload {
269 NodeIdentityPayload {
270 role: NodeRole::Repeater,
271 capabilities: NodeCapabilities::REPEATER,
272 name: Some(String::from(name)),
273 location: None,
274 altitude_m: None,
275 timestamp: None,
276 supported_regions: Some(regions.iter().map(|text| String::from(*text)).collect()),
277 nonce: None,
278 signature: None,
279 }
280 }
281
282 fn observation(hint: RouterHint, last_seen_ms: u64) -> umsh_mac::TransmitterObservation {
283 umsh_mac::TransmitterObservation {
284 hint,
285 rssi_dbm: -95,
286 snr: Snr::from_decibels(2),
287 last_seen_ms,
288 }
289 }
290
291 #[test]
292 fn only_repeaters_are_recorded() {
293 let mut table = PeerRepeaterTable::new();
294 let mut chat = repeater_identity("Handset", &[]);
295 chat.role = NodeRole::Chat;
296 chat.capabilities = NodeCapabilities::TEXT_MESSAGES;
297 assert!(!table.observe_identity(&key(1), &chat, 0));
298 assert!(table.is_empty());
299
300 let mut forwarding_handset = chat.clone();
303 forwarding_handset.capabilities |= NodeCapabilities::REPEATER;
304 assert!(table.observe_identity(&key(1), &forwarding_handset, 0));
305 assert_eq!(table.len(), 1);
306 }
307
308 #[test]
309 fn an_identity_supplies_the_name_and_regions_an_observation_cannot() {
310 let mut table = PeerRepeaterTable::new();
311 table.observe_identity(&key(0xAA), &repeater_identity("Ridge", &["SJC"]), 1_000);
312
313 let record = table.iter().next().unwrap();
314 assert_eq!(record.name.as_deref(), Some("Ridge"));
315 assert_eq!(record.regions, [[0x78, 0x53]]);
316 assert_eq!(record.hint, key(0xAA).hint());
317 }
318
319 #[test]
322 fn an_identity_claims_the_observation_whose_hint_it_starts_with() {
323 let mut table = PeerRepeaterTable::new();
324 table.observe_identity(&key(0xAA), &repeater_identity("Ridge", &["SJC"]), 60_000);
325 let hint = key(0xAA).hint();
326 let observations = [
327 observation(RouterHint([hint.0[0], hint.0[1]]), 120_000),
328 observation(RouterHint([0x11, 0x22]), 60_000),
329 ];
330
331 let merged = merge(&table, observations.iter(), 180_000);
332 assert_eq!(merged.len(), 2);
333
334 assert_eq!(merged[0].hint, hint.0);
335 assert_eq!(merged[0].name.as_deref(), Some("Ridge"));
336 assert_eq!(merged[0].rssi_dbm, Some(-95));
337 assert_eq!(
338 merged[0].last_heard_min,
339 Some(1),
340 "the newer of the two sources is when it was last heard"
341 );
342
343 assert_eq!(merged[1].hint, [0x11, 0x22]);
346 assert_eq!(merged[1].name, None);
347 assert_eq!(merged[1].rssi_dbm, Some(-95));
348 assert_eq!(merged[1].last_heard_min, Some(2));
349 }
350
351 #[test]
354 fn an_unobserved_identity_reports_no_signal() {
355 let mut table = PeerRepeaterTable::new();
356 table.observe_identity(&key(0xAA), &repeater_identity("Far", &[]), 0);
357 let merged = merge(&table, [].iter(), 60_000);
358 assert_eq!(merged.len(), 1);
359 assert_eq!(merged[0].rssi_dbm, None);
360 assert_eq!(merged[0].snr, None);
361 assert_eq!(merged[0].last_heard_min, Some(1));
362 }
363
364 #[test]
365 fn a_repeat_identity_replaces_the_record_and_moves_the_generation() {
366 let mut table = PeerRepeaterTable::new();
367 table.observe_identity(&key(0xAA), &repeater_identity("Ridge", &["SJC"]), 0);
368 let first = table.generation();
369 table.observe_identity(&key(0xAA), &repeater_identity("Ridge Two", &[]), 1_000);
370 assert_eq!(table.len(), 1);
371 assert_ne!(table.generation(), first);
372 let record = table.iter().next().unwrap();
373 assert_eq!(record.name.as_deref(), Some("Ridge Two"));
374 assert!(
375 record.regions.is_empty(),
376 "the newest account is the whole account"
377 );
378 }
379
380 #[test]
381 fn a_full_table_drops_the_identity_heard_longest_ago() {
382 let mut table = PeerRepeaterTable::new();
383 for seed in 0..MAX_PEER_REPEATERS as u8 {
384 table.observe_identity(
385 &key(seed),
386 &repeater_identity("Peer", &[]),
387 1_000 + u64::from(seed),
388 );
389 }
390 table.observe_identity(&key(200), &repeater_identity("Newcomer", &[]), 9_000);
391 assert_eq!(table.len(), MAX_PEER_REPEATERS);
392 assert!(table.iter().any(|entry| entry.hint == key(200).hint()));
393 assert!(
394 !table.iter().any(|entry| entry.hint == key(0).hint()),
395 "the oldest identity is the one that left"
396 );
397 }
398
399 #[test]
400 fn last_heard_saturates_rather_than_wrapping() {
401 assert_eq!(minutes_since(Some(0), 60_000 * 65_535), Some(u16::MAX));
402 assert_eq!(minutes_since(Some(0), 60_000 * 100_000), Some(u16::MAX));
403 assert_eq!(minutes_since(None, 1_000), None);
404 assert_eq!(minutes_since(Some(5_000), 1_000), Some(0));
406 }
407}