Skip to main content

sedsnet/
discovery.rs

1use alloc::collections::BTreeMap;
2use alloc::string::{String, ToString};
3use alloc::vec;
4use alloc::vec::Vec;
5
6#[cfg(feature = "std")]
7use crate::E2eEncryptionPolicy;
8#[cfg(feature = "std")]
9use crate::config::export_schema;
10use crate::router::encode_slice_le;
11use crate::{
12    DataEndpoint, DataType, MessageElement, TelemetryError, TelemetryResult,
13    config::{
14        OwnedDataTypeDefinition, OwnedEndpointDefinition, OwnedRuntimeSchemaSnapshot,
15        RuntimeSchemaSnapshot, e2e_encryption_policy_code, e2e_encryption_policy_from_code,
16        message_class_code, message_class_from_code, message_data_type_code,
17        message_data_type_from_code, reliable_code, reliable_from_code,
18    },
19    packet::Packet,
20    try_enum_from_u32,
21};
22
23pub const DISCOVERY_ROUTE_TTL_MS: u64 = 30_000;
24pub const DISCOVERY_FAST_INTERVAL_MS: u64 = 250;
25pub const DISCOVERY_SLOW_INTERVAL_MS: u64 = 5_000;
26pub const DISCOVERY_SLOW_LINK_CAPACITY_BPS: u64 = 512;
27pub const DISCOVERY_SLOW_LINK_PING_INTERVAL_MS: u64 = 15_000;
28pub const DISCOVERY_SLOW_LINK_FULL_INTERVAL_MS: u64 = 120_000;
29/// Number of compact topology advertisements emitted for each change. These
30/// packets are intentionally idempotent, so a small bounded repeat avoids a
31/// 120-second repair delay after one lossy control frame without resending the
32/// complete graph.
33pub const DISCOVERY_INCREMENTAL_RETRY_COUNT: u8 = 3;
34pub const TIMESYNC_SLOW_LINK_MIN_INTERVAL_MS: u64 = 30_000;
35// A zero-node full topology is never emitted. Reusing zero as the extension
36// marker makes older decoders reject the trailing delta payload cheaply
37// instead of interpreting a marker as an enormous allocation count.
38const DISCOVERY_TOPOLOGY_DELTA_MARKER: u32 = 0;
39
40/// Validate an untrusted wire count before reserving collection storage.
41///
42/// Every decoded element consumes at least `minimum_encoded_bytes` from the
43/// remaining payload. Checking that relationship before `Vec::with_capacity`
44/// prevents a corrupt count from reaching Rust's infallible capacity path and
45/// halting an embedded router with `capacity overflow`.
46fn bounded_wire_vec<T>(
47    payload: &[u8],
48    cursor: usize,
49    count: usize,
50    minimum_encoded_bytes: usize,
51    label: &'static str,
52) -> TelemetryResult<Vec<T>> {
53    let remaining = payload.len().saturating_sub(cursor);
54    if minimum_encoded_bytes == 0 || count > remaining / minimum_encoded_bytes {
55        return Err(TelemetryError::Unpack(label));
56    }
57    let mut values = Vec::new();
58    values
59        .try_reserve_exact(count)
60        .map_err(|_| TelemetryError::Unpack(label))?;
61    Ok(values)
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct DiscoveryCadenceState {
66    pub current_interval_ms: u64,
67    pub next_announce_ms: u64,
68}
69
70impl Default for DiscoveryCadenceState {
71    fn default() -> Self {
72        Self {
73            current_interval_ms: DISCOVERY_FAST_INTERVAL_MS,
74            next_announce_ms: 0,
75        }
76    }
77}
78
79impl DiscoveryCadenceState {
80    /// Switches discovery back to fast cadence and schedules an immediate announce.
81    pub fn on_topology_change(&mut self, now_ms: u64) {
82        self.current_interval_ms = DISCOVERY_FAST_INTERVAL_MS;
83        self.next_announce_ms = now_ms;
84    }
85
86    /// Advances the cadence after sending an announce, backing off toward the slow interval.
87    pub fn on_announce_sent(&mut self, now_ms: u64) {
88        self.next_announce_ms = now_ms.saturating_add(self.current_interval_ms);
89        self.current_interval_ms = core::cmp::min(
90            self.current_interval_ms.saturating_mul(2),
91            DISCOVERY_SLOW_INTERVAL_MS,
92        );
93    }
94
95    /// Returns `true` when discovery should emit another announce at `now_ms`.
96    pub fn due(&self, now_ms: u64) -> bool {
97        now_ms >= self.next_announce_ms
98    }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct TopologyBoardNode {
103    pub sender_id: String,
104    pub reachable_endpoints: Vec<DataEndpoint>,
105    pub reachable_timesync_sources: Vec<String>,
106    pub connections: Vec<String>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct TopologyLink {
111    pub source: String,
112    pub target: String,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct TopologyAnnouncerRoute {
117    pub sender_id: String,
118    pub reachable_endpoints: Vec<DataEndpoint>,
119    pub reachable_network_variables: Vec<DataType>,
120    pub reachable_timesync_sources: Vec<String>,
121    pub routers: Vec<TopologyBoardNode>,
122    pub last_seen_ms: u64,
123    pub age_ms: u64,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct TopologySideRoute {
128    pub side_id: usize,
129    pub side_name: String,
130    pub reachable_endpoints: Vec<DataEndpoint>,
131    pub reachable_network_variables: Vec<DataType>,
132    pub reachable_timesync_sources: Vec<String>,
133    pub announcers: Vec<TopologyAnnouncerRoute>,
134    pub last_seen_ms: u64,
135    pub age_ms: u64,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct TopologySnapshot {
140    pub advertised_endpoints: Vec<DataEndpoint>,
141    pub advertised_timesync_sources: Vec<String>,
142    pub routers: Vec<TopologyBoardNode>,
143    pub links: Vec<TopologyLink>,
144    pub routes: Vec<TopologySideRoute>,
145    pub current_announce_interval_ms: u64,
146    pub next_announce_ms: u64,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct ClientStatsSnapshot {
151    pub sender_id: String,
152    pub connected: bool,
153    pub side_ids: Vec<usize>,
154    pub side_names: Vec<String>,
155    pub last_seen_ms: Option<u64>,
156    pub age_ms: Option<u64>,
157    pub reachable_endpoints: Vec<DataEndpoint>,
158    pub reachable_timesync_sources: Vec<String>,
159    pub packets_sent: u64,
160    pub packets_received: u64,
161    pub bytes_sent: u64,
162    pub bytes_received: u64,
163}
164
165pub const LINK_CAPABILITY_HEADER_TEMPLATES: u32 = 0x0000_0001;
166pub const LINK_CAPABILITY_CHUNKING: u32 = 0x0000_0002;
167pub const LINK_CAPABILITY_RELIABILITY: u32 = 0x0000_0004;
168pub const LINK_CAPABILITY_CRYPTO: u32 = 0x0000_0008;
169pub const LINK_CAPABILITY_END_TO_END_RELIABILITY: u32 = 0x0000_0010;
170pub const LINK_CAPABILITY_OMIT_UNCHANGED_TIMESTAMPS: u32 = 0x0000_0020;
171
172pub const LINK_PROFILE_CANONICAL: u8 = 0;
173pub const LINK_PROFILE_TEMPLATE: u8 = 1;
174pub const LINK_PROFILE_IPV6_LIKE: u8 = 2;
175pub const LINK_PROFILE_IPV4_LIKE: u8 = 3;
176
177pub const ADDRESS_MODE_DYNAMIC: u8 = 0;
178pub const ADDRESS_MODE_REQUESTED: u8 = 1;
179pub const ADDRESS_MODE_STATIC: u8 = 2;
180pub const ADDRESS_STATE_REQUEST: u8 = 0;
181pub const ADDRESS_STATE_APPROVED: u8 = 1;
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub struct LinkCapabilities {
185    pub version: u8,
186    pub flags: u32,
187    pub profile: u8,
188    pub max_frame_bytes: u32,
189    pub compact_header_target_bytes: u32,
190    pub max_side_transport_templates: u32,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct AddressAdvertisement {
195    pub hostname: String,
196    pub address: u32,
197    pub requested_address: u32,
198    pub mode: u8,
199    pub state: u8,
200    pub birth_ms: u64,
201    pub owner_hash: u64,
202    pub reachable_endpoints: Vec<DataEndpoint>,
203    /// Network-variable data types available through this sender and the
204    /// routers behind it. This lets routing distinguish variables that share
205    /// a broad schema endpoint without falling back to link fanout.
206    pub reachable_network_variables: Vec<DataType>,
207    pub reachable_timesync_sources: Vec<String>,
208    pub link_capabilities: LinkCapabilities,
209}
210
211#[inline]
212pub const fn is_router_control_endpoint(ep: DataEndpoint) -> bool {
213    matches!(
214        ep,
215        DataEndpoint::TelemetryError | DataEndpoint::TimeSync | DataEndpoint::Discovery
216    )
217}
218
219/// Returns `true` when the endpoint is reserved for discovery control traffic.
220#[inline]
221pub const fn is_discovery_endpoint(ep: DataEndpoint) -> bool {
222    matches!(ep, DataEndpoint::Discovery)
223}
224
225/// Returns `true` when the data type is a discovery control packet type.
226#[inline]
227pub const fn is_discovery_type(ty: DataType) -> bool {
228    matches!(
229        ty,
230        DataType::DiscoveryAnnounce
231            | DataType::DiscoveryTimeSyncSources
232            | DataType::DiscoveryTopology
233            | DataType::DiscoverySchema
234            | DataType::DiscoveryTopologyRequest
235            | DataType::DiscoverySchemaRequest
236            | DataType::ManagedVariableRequest
237            | DataType::ManagedVariableValue
238            | DataType::DiscoveryLeave
239            | DataType::DiscoveryLinkCapabilities
240            | DataType::DiscoveryAddress
241    )
242}
243
244#[inline]
245pub const fn is_discovery_request_type(ty: DataType) -> bool {
246    matches!(
247        ty,
248        DataType::DiscoveryTopologyRequest
249            | DataType::DiscoverySchemaRequest
250            | DataType::ManagedVariableRequest
251            | DataType::DiscoveryLeave
252            | DataType::DiscoveryAddress
253    )
254}
255
256fn sort_dedup_strings(items: &mut Vec<String>) {
257    items.sort_unstable();
258    items.dedup();
259}
260
261/// Normalizes a topology-board list in place so it can be compared, exported, or encoded.
262pub fn normalize_topology_boards(boards: &mut Vec<TopologyBoardNode>) {
263    for board in boards.iter_mut() {
264        board
265            .reachable_endpoints
266            .retain(|ep| !is_router_control_endpoint(*ep));
267        board.reachable_endpoints.sort_unstable();
268        board.reachable_endpoints.dedup();
269        sort_dedup_strings(&mut board.reachable_timesync_sources);
270        board.connections.retain(|peer| peer != &board.sender_id);
271        sort_dedup_strings(&mut board.connections);
272    }
273    boards.sort_unstable_by(|a, b| a.sender_id.cmp(&b.sender_id));
274    boards.dedup_by(|a, b| a.sender_id == b.sender_id);
275}
276
277pub fn topology_links_from_boards(boards: &[TopologyBoardNode]) -> Vec<TopologyLink> {
278    let mut links = Vec::new();
279    for board in boards {
280        for peer in &board.connections {
281            if peer == &board.sender_id {
282                continue;
283            }
284            let (source, target) = if board.sender_id <= *peer {
285                (board.sender_id.clone(), peer.clone())
286            } else {
287                (peer.clone(), board.sender_id.clone())
288            };
289            links.push(TopologyLink { source, target });
290        }
291    }
292    links.sort_unstable_by(|a, b| (&a.source, &a.target).cmp(&(&b.source, &b.target)));
293    links.dedup_by(|a, b| a.source == b.source && a.target == b.target);
294    links
295}
296
297/// Merges board topology views keyed by sender ID.
298pub fn merge_topology_boards(dst: &mut Vec<TopologyBoardNode>, src: &[TopologyBoardNode]) {
299    let mut merged: BTreeMap<String, TopologyBoardNode> = dst
300        .iter()
301        .cloned()
302        .map(|board| (board.sender_id.clone(), board))
303        .collect();
304    for board in src {
305        let entry = merged
306            .entry(board.sender_id.clone())
307            .or_insert_with(|| TopologyBoardNode {
308                sender_id: board.sender_id.clone(),
309                reachable_endpoints: Vec::new(),
310                reachable_timesync_sources: Vec::new(),
311                connections: Vec::new(),
312            });
313        entry
314            .reachable_endpoints
315            .extend(board.reachable_endpoints.iter().copied());
316        entry
317            .reachable_timesync_sources
318            .extend(board.reachable_timesync_sources.iter().cloned());
319        entry.connections.extend(board.connections.iter().cloned());
320    }
321    let mut out: Vec<TopologyBoardNode> = merged.into_values().collect();
322    normalize_topology_boards(&mut out);
323    *dst = out;
324}
325
326/// Summarizes a board topology list into aggregated endpoint and time-source reachability.
327pub fn summarize_topology_boards(boards: &[TopologyBoardNode]) -> (Vec<DataEndpoint>, Vec<String>) {
328    let mut reachable_endpoints = Vec::new();
329    let mut reachable_timesync_sources = Vec::new();
330    for board in boards {
331        reachable_endpoints.extend(board.reachable_endpoints.iter().copied());
332        reachable_timesync_sources.extend(board.reachable_timesync_sources.iter().cloned());
333    }
334    reachable_endpoints.sort_unstable();
335    reachable_endpoints.dedup();
336    reachable_endpoints.retain(|ep| !is_router_control_endpoint(*ep));
337    sort_dedup_strings(&mut reachable_timesync_sources);
338    (reachable_endpoints, reachable_timesync_sources)
339}
340
341/// Builds a discovery announce packet advertising reachable non-discovery endpoints.
342pub fn build_discovery_announce(
343    sender: &str,
344    timestamp_ms: u64,
345    endpoints: &[DataEndpoint],
346) -> TelemetryResult<Packet> {
347    let payload_words: Vec<u32> = endpoints.iter().copied().map(|ep| ep.as_u32()).collect();
348    Packet::new(
349        DataType::DiscoveryAnnounce,
350        &[DataEndpoint::Discovery],
351        sender,
352        timestamp_ms,
353        encode_slice_le(payload_words.as_slice()),
354    )
355}
356
357/// Decodes a discovery announce packet into its advertised endpoints.
358pub fn decode_discovery_announce(pkt: &Packet) -> TelemetryResult<Vec<DataEndpoint>> {
359    if pkt.data_type() != DataType::DiscoveryAnnounce {
360        return Err(TelemetryError::InvalidType);
361    }
362    decode_discovery_payload(pkt.payload())
363}
364
365/// Decodes a discovery announce payload into a sorted, deduplicated endpoint list.
366pub fn decode_discovery_payload(payload: &[u8]) -> TelemetryResult<Vec<DataEndpoint>> {
367    if !payload.len().is_multiple_of(4) {
368        return Err(TelemetryError::Unpack("discovery payload width"));
369    }
370
371    let mut endpoints = Vec::with_capacity(payload.len() / 4);
372    for chunk in payload.as_chunks::<4>().0 {
373        let raw = u32::from_le_bytes(*chunk);
374        let ep = try_enum_from_u32(raw).ok_or(TelemetryError::Unpack("bad discovery endpoint"))?;
375        if is_discovery_endpoint(ep) {
376            continue;
377        }
378        endpoints.push(ep);
379    }
380    endpoints.sort_unstable();
381    endpoints.dedup();
382    Ok(endpoints)
383}
384
385/// Builds a discovery packet advertising reachable time sync source identifiers.
386pub fn build_discovery_timesync_sources<S: AsRef<str>>(
387    sender: &str,
388    timestamp_ms: u64,
389    sources: &[S],
390) -> TelemetryResult<Packet> {
391    let mut payload = Vec::new();
392    let mut deduped: Vec<&str> = sources.iter().map(|s| s.as_ref()).collect();
393    deduped.sort_unstable();
394    deduped.dedup();
395
396    payload.extend_from_slice(&(deduped.len() as u32).to_le_bytes());
397    for source in deduped {
398        let bytes = source.as_bytes();
399        let len = u32::try_from(bytes.len())
400            .map_err(|_| TelemetryError::Pack("discovery source id too long"))?;
401        payload.extend_from_slice(&len.to_le_bytes());
402        payload.extend_from_slice(bytes);
403    }
404
405    Packet::new(
406        DataType::DiscoveryTimeSyncSources,
407        &[DataEndpoint::Discovery],
408        sender,
409        timestamp_ms,
410        payload.into(),
411    )
412}
413
414pub fn build_discovery_topology_request(
415    sender: &str,
416    timestamp_ms: u64,
417) -> TelemetryResult<Packet> {
418    Packet::new(
419        DataType::DiscoveryTopologyRequest,
420        &[DataEndpoint::Discovery],
421        sender,
422        timestamp_ms,
423        Vec::<u8>::new().into(),
424    )
425}
426
427pub fn build_discovery_schema_request(sender: &str, timestamp_ms: u64) -> TelemetryResult<Packet> {
428    Packet::new(
429        DataType::DiscoverySchemaRequest,
430        &[DataEndpoint::Discovery],
431        sender,
432        timestamp_ms,
433        Vec::<u8>::new().into(),
434    )
435}
436
437pub fn build_discovery_leave(sender: &str, timestamp_ms: u64) -> TelemetryResult<Packet> {
438    Packet::new(
439        DataType::DiscoveryLeave,
440        &[DataEndpoint::Discovery],
441        sender,
442        timestamp_ms,
443        Vec::<u8>::new().into(),
444    )
445}
446
447pub fn build_discovery_link_capabilities(
448    sender: &str,
449    timestamp_ms: u64,
450    capabilities: LinkCapabilities,
451) -> TelemetryResult<Packet> {
452    let mut payload = Vec::with_capacity(18);
453    payload.push(capabilities.version);
454    payload.extend_from_slice(&capabilities.flags.to_le_bytes());
455    payload.push(capabilities.profile);
456    payload.extend_from_slice(&capabilities.max_frame_bytes.to_le_bytes());
457    payload.extend_from_slice(&capabilities.compact_header_target_bytes.to_le_bytes());
458    payload.extend_from_slice(&capabilities.max_side_transport_templates.to_le_bytes());
459    Packet::new(
460        DataType::DiscoveryLinkCapabilities,
461        &[DataEndpoint::Discovery],
462        sender,
463        timestamp_ms,
464        payload.into(),
465    )
466}
467
468pub fn decode_discovery_link_capabilities(pkt: &Packet) -> TelemetryResult<LinkCapabilities> {
469    if pkt.data_type() != DataType::DiscoveryLinkCapabilities {
470        return Err(TelemetryError::InvalidType);
471    }
472    let payload = pkt.payload();
473    if payload.len() != 18 {
474        return Err(TelemetryError::Unpack("discovery link capabilities width"));
475    }
476    Ok(LinkCapabilities {
477        version: payload[0],
478        flags: u32::from_le_bytes(payload[1..5].try_into().expect("4-byte flags")),
479        profile: payload[5],
480        max_frame_bytes: u32::from_le_bytes(payload[6..10].try_into().expect("4-byte max frame")),
481        compact_header_target_bytes: u32::from_le_bytes(
482            payload[10..14].try_into().expect("4-byte target"),
483        ),
484        max_side_transport_templates: u32::from_le_bytes(
485            payload[14..18].try_into().expect("4-byte templates"),
486        ),
487    })
488}
489
490pub fn build_discovery_address(
491    sender: &str,
492    timestamp_ms: u64,
493    ad: &AddressAdvertisement,
494) -> TelemetryResult<Packet> {
495    let mut payload = Vec::new();
496    payload.push(2);
497    payload.push(ad.mode);
498    payload.push(ad.state);
499    payload.extend_from_slice(&ad.address.to_le_bytes());
500    payload.extend_from_slice(&ad.requested_address.to_le_bytes());
501    payload.extend_from_slice(&ad.birth_ms.to_le_bytes());
502    payload.extend_from_slice(&ad.owner_hash.to_le_bytes());
503    encode_string(&mut payload, &ad.hostname)?;
504    let mut endpoints = ad.reachable_endpoints.clone();
505    endpoints.retain(|ep| !is_discovery_endpoint(*ep));
506    endpoints.sort_unstable();
507    endpoints.dedup();
508    let endpoint_count = u32::try_from(endpoints.len())
509        .map_err(|_| TelemetryError::Pack("discovery address endpoint count"))?;
510    payload.extend_from_slice(&endpoint_count.to_le_bytes());
511    for ep in endpoints {
512        payload.extend_from_slice(&ep.as_u32().to_le_bytes());
513    }
514    let mut network_variables = ad.reachable_network_variables.clone();
515    network_variables.sort_unstable();
516    network_variables.dedup();
517    let network_variable_count = u32::try_from(network_variables.len())
518        .map_err(|_| TelemetryError::Pack("discovery address network variable count"))?;
519    payload.extend_from_slice(&network_variable_count.to_le_bytes());
520    for ty in network_variables {
521        payload.extend_from_slice(&ty.as_u32().to_le_bytes());
522    }
523    let mut sources = ad.reachable_timesync_sources.clone();
524    sort_dedup_strings(&mut sources);
525    let source_count = u32::try_from(sources.len())
526        .map_err(|_| TelemetryError::Pack("discovery address source count"))?;
527    payload.extend_from_slice(&source_count.to_le_bytes());
528    for source in sources {
529        encode_string(&mut payload, &source)?;
530    }
531    payload.push(ad.link_capabilities.version);
532    payload.extend_from_slice(&ad.link_capabilities.flags.to_le_bytes());
533    payload.push(ad.link_capabilities.profile);
534    payload.extend_from_slice(&ad.link_capabilities.max_frame_bytes.to_le_bytes());
535    payload.extend_from_slice(
536        &ad.link_capabilities
537            .compact_header_target_bytes
538            .to_le_bytes(),
539    );
540    payload.extend_from_slice(
541        &ad.link_capabilities
542            .max_side_transport_templates
543            .to_le_bytes(),
544    );
545    Packet::new(
546        DataType::DiscoveryAddress,
547        &[DataEndpoint::Discovery],
548        sender,
549        timestamp_ms,
550        payload.into(),
551    )
552}
553
554pub fn decode_discovery_address(pkt: &Packet) -> TelemetryResult<AddressAdvertisement> {
555    if pkt.data_type() != DataType::DiscoveryAddress {
556        return Err(TelemetryError::InvalidType);
557    }
558    let payload = pkt.payload();
559    let mut cursor = 0usize;
560    let version = read_u8(payload, &mut cursor, "discovery address version")?;
561    if version != 1 && version != 2 {
562        return Err(TelemetryError::Unpack("discovery address version"));
563    }
564    let mode = read_u8(payload, &mut cursor, "discovery address mode")?;
565    let state = read_u8(payload, &mut cursor, "discovery address state")?;
566    let address = read_u32(payload, &mut cursor, "discovery address current")?;
567    let requested_address = read_u32(payload, &mut cursor, "discovery address requested")?;
568    if payload.len().saturating_sub(cursor) < 8 {
569        return Err(TelemetryError::Unpack("discovery address birth"));
570    }
571    let birth_ms = u64::from_le_bytes(
572        payload[cursor..cursor + 8]
573            .try_into()
574            .expect("8-byte birth"),
575    );
576    cursor += 8;
577    if payload.len().saturating_sub(cursor) < 8 {
578        return Err(TelemetryError::Unpack("discovery address owner"));
579    }
580    let owner_hash = u64::from_le_bytes(
581        payload[cursor..cursor + 8]
582            .try_into()
583            .expect("8-byte owner"),
584    );
585    cursor += 8;
586    let hostname = decode_string(payload, &mut cursor, "discovery address hostname")?;
587    let endpoint_count =
588        read_u32(payload, &mut cursor, "discovery address endpoint count")? as usize;
589    let mut reachable_endpoints = bounded_wire_vec(
590        payload, cursor, endpoint_count, 4, "discovery address endpoint count",
591    )?;
592    for _ in 0..endpoint_count {
593        let raw = read_u32(payload, &mut cursor, "discovery address endpoint")?;
594        let ep = try_enum_from_u32(raw).ok_or(TelemetryError::Unpack("bad discovery endpoint"))?;
595        if !is_discovery_endpoint(ep) {
596            reachable_endpoints.push(ep);
597        }
598    }
599    reachable_endpoints.sort_unstable();
600    reachable_endpoints.dedup();
601    let mut reachable_network_variables = Vec::new();
602    if version >= 2 {
603        let count = read_u32(
604            payload,
605            &mut cursor,
606            "discovery address network variable count",
607        )? as usize;
608        reachable_network_variables = bounded_wire_vec(
609            payload, cursor, count, 4, "discovery address network variable count",
610        )?;
611        for _ in 0..count {
612            let raw = read_u32(payload, &mut cursor, "discovery address network variable")?;
613            let ty = DataType::try_from_u32(raw)
614                .ok_or(TelemetryError::Unpack("bad discovery network variable"))?;
615            if !is_discovery_type(ty) {
616                reachable_network_variables.push(ty);
617            }
618        }
619        reachable_network_variables.sort_unstable();
620        reachable_network_variables.dedup();
621    }
622    let source_count = read_u32(payload, &mut cursor, "discovery address source count")? as usize;
623    let mut reachable_timesync_sources = bounded_wire_vec(
624        payload, cursor, source_count, 4, "discovery address source count",
625    )?;
626    for _ in 0..source_count {
627        let source = decode_string(payload, &mut cursor, "discovery address source")?;
628        if !source.is_empty() {
629            reachable_timesync_sources.push(source);
630        }
631    }
632    sort_dedup_strings(&mut reachable_timesync_sources);
633    let version = read_u8(payload, &mut cursor, "discovery address link version")?;
634    let flags = read_u32(payload, &mut cursor, "discovery address link flags")?;
635    let profile = read_u8(payload, &mut cursor, "discovery address link profile")?;
636    let max_frame_bytes = read_u32(payload, &mut cursor, "discovery address link max frame")?;
637    let compact_header_target_bytes = read_u32(
638        payload,
639        &mut cursor,
640        "discovery address link compact target",
641    )?;
642    let max_side_transport_templates =
643        read_u32(payload, &mut cursor, "discovery address link templates")?;
644    if cursor != payload.len() {
645        return Err(TelemetryError::Unpack("discovery address trailing bytes"));
646    }
647    if hostname.is_empty() || address == 0 {
648        return Err(TelemetryError::Unpack("bad discovery address"));
649    }
650    Ok(AddressAdvertisement {
651        hostname,
652        address,
653        requested_address,
654        mode,
655        state,
656        birth_ms,
657        owner_hash,
658        reachable_endpoints,
659        reachable_network_variables,
660        reachable_timesync_sources,
661        link_capabilities: LinkCapabilities {
662            version,
663            flags,
664            profile,
665            max_frame_bytes,
666            compact_header_target_bytes,
667            max_side_transport_templates,
668        },
669    })
670}
671
672pub fn build_managed_variable_request(
673    sender: &str,
674    timestamp_ms: u64,
675    ty: DataType,
676) -> TelemetryResult<Packet> {
677    Packet::new(
678        DataType::ManagedVariableRequest,
679        &[DataEndpoint::Discovery],
680        sender,
681        timestamp_ms,
682        encode_slice_le(&[ty.as_u32()]),
683    )
684}
685
686pub fn decode_managed_variable_request(pkt: &Packet) -> TelemetryResult<DataType> {
687    if pkt.data_type() != DataType::ManagedVariableRequest {
688        return Err(TelemetryError::InvalidType);
689    }
690    let payload = pkt.payload();
691    if payload.len() != 4 {
692        return Err(TelemetryError::Unpack("managed variable request width"));
693    }
694    let raw = u32::from_le_bytes(payload.try_into().expect("4-byte payload"));
695    try_enum_from_u32(raw).ok_or(TelemetryError::Unpack("bad managed variable data type"))
696}
697
698/// Decodes a discovery time sync source packet into source identifiers.
699pub fn decode_discovery_timesync_sources(pkt: &Packet) -> TelemetryResult<Vec<String>> {
700    if pkt.data_type() != DataType::DiscoveryTimeSyncSources {
701        return Err(TelemetryError::InvalidType);
702    }
703    decode_discovery_timesync_sources_payload(pkt.payload())
704}
705
706/// Decodes a discovery time sync source payload into a sorted, deduplicated source list.
707pub fn decode_discovery_timesync_sources_payload(payload: &[u8]) -> TelemetryResult<Vec<String>> {
708    if payload.len() < 4 {
709        return Err(TelemetryError::Unpack("discovery timesync source count"));
710    }
711
712    let count = u32::from_le_bytes(payload[..4].try_into().expect("4-byte count")) as usize;
713    let mut cursor = 4usize;
714    let mut out = bounded_wire_vec(
715        payload, cursor, count, 4, "discovery timesync source count",
716    )?;
717
718    for _ in 0..count {
719        if payload.len().saturating_sub(cursor) < 4 {
720            return Err(TelemetryError::Unpack("discovery timesync source len"));
721        }
722        let len = u32::from_le_bytes(payload[cursor..cursor + 4].try_into().expect("4-byte len"))
723            as usize;
724        cursor += 4;
725        if payload.len().saturating_sub(cursor) < len {
726            return Err(TelemetryError::Unpack("discovery timesync source bytes"));
727        }
728        let raw = &payload[cursor..cursor + len];
729        cursor += len;
730        let source = core::str::from_utf8(raw)
731            .map_err(|_| TelemetryError::Unpack("discovery timesync source utf8"))?;
732        if !source.is_empty() {
733            out.push(source.to_string());
734        }
735    }
736
737    if cursor != payload.len() {
738        return Err(TelemetryError::Unpack("discovery timesync trailing bytes"));
739    }
740
741    out.sort_unstable();
742    out.dedup();
743    Ok(out)
744}
745
746/// Builds a discovery packet advertising the sender's current board/edge topology graph.
747pub fn build_discovery_topology(
748    sender: &str,
749    timestamp_ms: u64,
750    boards: &[TopologyBoardNode],
751) -> TelemetryResult<Packet> {
752    let mut payload = Vec::new();
753    let mut normalized = boards.to_vec();
754    normalize_topology_boards(&mut normalized);
755
756    payload.extend_from_slice(&(normalized.len() as u32).to_le_bytes());
757    for board in normalized {
758        let sender_bytes = board.sender_id.as_bytes();
759        let sender_len = u32::try_from(sender_bytes.len())
760            .map_err(|_| TelemetryError::Pack("discovery topology sender id too long"))?;
761        payload.extend_from_slice(&sender_len.to_le_bytes());
762        payload.extend_from_slice(sender_bytes);
763
764        payload.extend_from_slice(&(board.reachable_endpoints.len() as u32).to_le_bytes());
765        for ep in board.reachable_endpoints {
766            payload.extend_from_slice(&ep.as_u32().to_le_bytes());
767        }
768
769        payload.extend_from_slice(&(board.reachable_timesync_sources.len() as u32).to_le_bytes());
770        for source in board.reachable_timesync_sources {
771            let bytes = source.as_bytes();
772            let len = u32::try_from(bytes.len())
773                .map_err(|_| TelemetryError::Pack("discovery topology source id too long"))?;
774            payload.extend_from_slice(&len.to_le_bytes());
775            payload.extend_from_slice(bytes);
776        }
777
778        payload.extend_from_slice(&(board.connections.len() as u32).to_le_bytes());
779        for peer in board.connections {
780            let bytes = peer.as_bytes();
781            let len = u32::try_from(bytes.len())
782                .map_err(|_| TelemetryError::Pack("discovery topology connection id too long"))?;
783            payload.extend_from_slice(&len.to_le_bytes());
784            payload.extend_from_slice(bytes);
785        }
786    }
787
788    Packet::new(
789        DataType::DiscoveryTopology,
790        &[DataEndpoint::Discovery],
791        sender,
792        timestamp_ms,
793        payload.into(),
794    )
795}
796
797/// A topology update decoded from the wire. Full snapshots replace an
798/// announcer's graph; incremental updates only upsert/remove named nodes.
799#[derive(Debug, Clone, PartialEq, Eq)]
800pub struct DiscoveryTopologyUpdate {
801    pub incremental: bool,
802    pub boards: Vec<TopologyBoardNode>,
803    pub removed: Vec<String>,
804}
805
806/// Computes the minimal named-node delta from one normalized graph to another.
807pub fn topology_delta(
808    previous: &[TopologyBoardNode],
809    current: &[TopologyBoardNode],
810) -> (Vec<TopologyBoardNode>, Vec<String>) {
811    let boards = current
812        .iter()
813        .filter(|node| previous.iter().find(|old| old.sender_id == node.sender_id) != Some(*node))
814        .cloned()
815        .collect();
816    let removed = previous
817        .iter()
818        .filter(|old| !current.iter().any(|node| node.sender_id == old.sender_id))
819        .map(|old| old.sender_id.clone())
820        .collect();
821    (boards, removed)
822}
823
824/// Builds a compact topology delta containing only changed and removed nodes.
825pub fn build_discovery_topology_delta(
826    sender: &str,
827    timestamp_ms: u64,
828    boards: &[TopologyBoardNode],
829    removed: &[String],
830) -> TelemetryResult<Packet> {
831    let full = build_discovery_topology(sender, timestamp_ms, boards)?;
832    let mut payload = Vec::with_capacity(full.payload().len() + 8 + removed.len() * 8);
833    payload.extend_from_slice(&DISCOVERY_TOPOLOGY_DELTA_MARKER.to_le_bytes());
834    payload.extend_from_slice(full.payload());
835    payload.extend_from_slice(&(removed.len() as u32).to_le_bytes());
836    for sender_id in removed {
837        encode_string(&mut payload, sender_id)?;
838    }
839    Packet::new(
840        DataType::DiscoveryTopology,
841        &[DataEndpoint::Discovery],
842        sender,
843        timestamp_ms,
844        payload.into(),
845    )
846}
847
848fn decode_string(
849    payload: &[u8],
850    cursor: &mut usize,
851    label: &'static str,
852) -> TelemetryResult<String> {
853    if payload.len().saturating_sub(*cursor) < 4 {
854        return Err(TelemetryError::Unpack(label));
855    }
856    let len = u32::from_le_bytes(
857        payload[*cursor..*cursor + 4]
858            .try_into()
859            .expect("4-byte len"),
860    ) as usize;
861    *cursor += 4;
862    if payload.len().saturating_sub(*cursor) < len {
863        return Err(TelemetryError::Unpack(label));
864    }
865    let raw = &payload[*cursor..*cursor + len];
866    *cursor += len;
867    core::str::from_utf8(raw)
868        .map(|s| s.to_string())
869        .map_err(|_| TelemetryError::Unpack(label))
870}
871
872/// Decodes a discovery topology packet into board-node records.
873pub fn decode_discovery_topology(pkt: &Packet) -> TelemetryResult<Vec<TopologyBoardNode>> {
874    if pkt.data_type() != DataType::DiscoveryTopology {
875        return Err(TelemetryError::InvalidType);
876    }
877    Ok(decode_discovery_topology_update(pkt)?.boards)
878}
879
880/// Decodes either a legacy/full topology snapshot or an incremental delta.
881pub fn decode_discovery_topology_update(pkt: &Packet) -> TelemetryResult<DiscoveryTopologyUpdate> {
882    if pkt.data_type() != DataType::DiscoveryTopology {
883        return Err(TelemetryError::InvalidType);
884    }
885    let payload = pkt.payload();
886    if payload.len() < 4 {
887        return Err(TelemetryError::Unpack("discovery topology board count"));
888    }
889    let first = u32::from_le_bytes(payload[..4].try_into().expect("4-byte count"));
890    if first != DISCOVERY_TOPOLOGY_DELTA_MARKER {
891        return Ok(DiscoveryTopologyUpdate {
892            incremental: false,
893            boards: decode_discovery_topology_payload(payload)?,
894            removed: Vec::new(),
895        });
896    }
897    if payload.len() < 8 {
898        return Err(TelemetryError::Unpack("discovery topology delta count"));
899    }
900    let count = u32::from_le_bytes(payload[4..8].try_into().expect("4-byte count")) as usize;
901    let mut cursor = 8usize;
902    let mut boards = decode_topology_boards(payload, &mut cursor, count)?;
903    if payload.len().saturating_sub(cursor) < 4 {
904        return Err(TelemetryError::Unpack("discovery topology removal count"));
905    }
906    let removed_count = u32::from_le_bytes(
907        payload[cursor..cursor + 4]
908            .try_into()
909            .expect("4-byte count"),
910    ) as usize;
911    cursor += 4;
912    let mut removed = bounded_wire_vec(
913        payload, cursor, removed_count, 4, "discovery topology removal count",
914    )?;
915    for _ in 0..removed_count {
916        removed.push(decode_string(
917            payload,
918            &mut cursor,
919            "discovery topology removed sender id",
920        )?);
921    }
922    if cursor != payload.len() {
923        return Err(TelemetryError::Unpack("discovery topology trailing bytes"));
924    }
925    normalize_topology_boards(&mut boards);
926    removed.sort_unstable();
927    removed.dedup();
928    Ok(DiscoveryTopologyUpdate {
929        incremental: true,
930        boards,
931        removed,
932    })
933}
934
935/// Decodes a discovery topology payload into normalized board-node records.
936pub fn decode_discovery_topology_payload(
937    payload: &[u8],
938) -> TelemetryResult<Vec<TopologyBoardNode>> {
939    if payload.len() < 4 {
940        return Err(TelemetryError::Unpack("discovery topology board count"));
941    }
942
943    let count = u32::from_le_bytes(payload[..4].try_into().expect("4-byte count")) as usize;
944    let mut cursor = 4usize;
945    let mut boards = decode_topology_boards(payload, &mut cursor, count)?;
946
947    if cursor != payload.len() {
948        return Err(TelemetryError::Unpack("discovery topology trailing bytes"));
949    }
950
951    normalize_topology_boards(&mut boards);
952    Ok(boards)
953}
954
955fn decode_topology_boards(
956    payload: &[u8],
957    cursor: &mut usize,
958    count: usize,
959) -> TelemetryResult<Vec<TopologyBoardNode>> {
960    /* Each board has at minimum a string length and three collection counts. */
961    let mut boards = bounded_wire_vec(
962        payload, *cursor, count, 16, "discovery topology board count",
963    )?;
964
965    for _ in 0..count {
966        let sender_id = decode_string(payload, cursor, "discovery topology sender id")?;
967
968        if payload.len().saturating_sub(*cursor) < 4 {
969            return Err(TelemetryError::Unpack("discovery topology endpoint count"));
970        }
971        let endpoint_count = u32::from_le_bytes(
972            payload[*cursor..*cursor + 4]
973                .try_into()
974                .expect("4-byte count"),
975        ) as usize;
976        *cursor += 4;
977        let mut reachable_endpoints = bounded_wire_vec(
978            payload, *cursor, endpoint_count, 4, "discovery topology endpoint count",
979        )?;
980        for _ in 0..endpoint_count {
981            if payload.len().saturating_sub(*cursor) < 4 {
982                return Err(TelemetryError::Unpack("discovery topology endpoint"));
983            }
984            let raw =
985                u32::from_le_bytes(payload[*cursor..*cursor + 4].try_into().expect("4-byte ep"));
986            *cursor += 4;
987            let ep =
988                try_enum_from_u32(raw).ok_or(TelemetryError::Unpack("bad discovery endpoint"))?;
989            if !is_discovery_endpoint(ep) {
990                reachable_endpoints.push(ep);
991            }
992        }
993
994        if payload.len().saturating_sub(*cursor) < 4 {
995            return Err(TelemetryError::Unpack(
996                "discovery topology timesync source count",
997            ));
998        }
999        let source_count = u32::from_le_bytes(
1000            payload[*cursor..*cursor + 4]
1001                .try_into()
1002                .expect("4-byte count"),
1003        ) as usize;
1004        *cursor += 4;
1005        let mut reachable_timesync_sources = bounded_wire_vec(
1006            payload, *cursor, source_count, 4,
1007            "discovery topology timesync source count",
1008        )?;
1009        for _ in 0..source_count {
1010            let source = decode_string(payload, cursor, "discovery topology timesync source")?;
1011            if !source.is_empty() {
1012                reachable_timesync_sources.push(source);
1013            }
1014        }
1015
1016        if payload.len().saturating_sub(*cursor) < 4 {
1017            return Err(TelemetryError::Unpack(
1018                "discovery topology connection count",
1019            ));
1020        }
1021        let connection_count = u32::from_le_bytes(
1022            payload[*cursor..*cursor + 4]
1023                .try_into()
1024                .expect("4-byte count"),
1025        ) as usize;
1026        *cursor += 4;
1027        let mut connections = bounded_wire_vec(
1028            payload, *cursor, connection_count, 4,
1029            "discovery topology connection count",
1030        )?;
1031        for _ in 0..connection_count {
1032            let peer = decode_string(payload, cursor, "discovery topology connection")?;
1033            if !peer.is_empty() {
1034                connections.push(peer);
1035            }
1036        }
1037
1038        boards.push(TopologyBoardNode {
1039            sender_id,
1040            reachable_endpoints,
1041            reachable_timesync_sources,
1042            connections,
1043        });
1044    }
1045
1046    Ok(boards)
1047}
1048
1049fn encode_string(payload: &mut Vec<u8>, value: &str) -> TelemetryResult<()> {
1050    let len = u32::try_from(value.len())
1051        .map_err(|_| TelemetryError::Pack("discovery schema string too long"))?;
1052    payload.extend_from_slice(&len.to_le_bytes());
1053    payload.extend_from_slice(value.as_bytes());
1054    Ok(())
1055}
1056
1057fn read_u8(payload: &[u8], cursor: &mut usize, label: &'static str) -> TelemetryResult<u8> {
1058    if payload.len().saturating_sub(*cursor) < 1 {
1059        return Err(TelemetryError::Unpack(label));
1060    }
1061    let out = payload[*cursor];
1062    *cursor += 1;
1063    Ok(out)
1064}
1065
1066fn read_u32(payload: &[u8], cursor: &mut usize, label: &'static str) -> TelemetryResult<u32> {
1067    if payload.len().saturating_sub(*cursor) < 4 {
1068        return Err(TelemetryError::Unpack(label));
1069    }
1070    let out = u32::from_le_bytes(
1071        payload[*cursor..*cursor + 4]
1072            .try_into()
1073            .expect("4-byte u32"),
1074    );
1075    *cursor += 4;
1076    Ok(out)
1077}
1078
1079#[cfg(not(feature = "std"))]
1080fn skip_string(payload: &[u8], cursor: &mut usize, label: &'static str) -> TelemetryResult<()> {
1081    let len = read_u32(payload, cursor, label)? as usize;
1082    if payload.len().saturating_sub(*cursor) < len {
1083        return Err(TelemetryError::Unpack(label));
1084    }
1085    *cursor += len;
1086    Ok(())
1087}
1088
1089/// Builds a discovery packet containing the complete runtime schema snapshot.
1090pub fn build_discovery_schema(sender: &str, timestamp_ms: u64) -> TelemetryResult<Packet> {
1091    #[cfg(feature = "std")]
1092    return build_discovery_schema_from_owned_snapshot(sender, timestamp_ms, export_schema());
1093    #[cfg(not(feature = "std"))]
1094    return Packet::new(
1095        DataType::DiscoverySchema,
1096        &[DataEndpoint::Discovery],
1097        sender,
1098        timestamp_ms,
1099        crate::config::encode_embedded_schema_payload().into(),
1100    );
1101}
1102
1103/// Elects the authoritative discovery/schema master for the current topology view.
1104///
1105/// Tie-breaks are deterministic:
1106/// 1. Fewest unreachable boards.
1107/// 2. Lowest maximum hop distance to any reachable board.
1108/// 3. Lowest total hop distance across all reachable boards.
1109/// 4. Lexicographically smallest sender ID.
1110pub fn elect_discovery_master(local_sender: &str, boards: &[TopologyBoardNode]) -> String {
1111    let mut nodes: BTreeMap<String, Vec<String>> = BTreeMap::new();
1112    for board in boards {
1113        nodes.entry(board.sender_id.clone()).or_default();
1114        for peer in board.connections.iter() {
1115            nodes
1116                .entry(peer.clone())
1117                .or_default()
1118                .push(board.sender_id.clone());
1119            nodes
1120                .entry(board.sender_id.clone())
1121                .or_default()
1122                .push(peer.clone());
1123        }
1124    }
1125    nodes.entry(local_sender.to_string()).or_default();
1126    for peers in nodes.values_mut() {
1127        peers.sort_unstable();
1128        peers.dedup();
1129    }
1130
1131    let mut best_sender = local_sender.to_string();
1132    let mut best_unreachable = usize::MAX;
1133    let mut best_max_hops = usize::MAX;
1134    let mut best_total_hops = usize::MAX;
1135
1136    for sender in nodes.keys() {
1137        let mut frontier = vec![sender.clone()];
1138        let mut seen: BTreeMap<String, usize> = BTreeMap::new();
1139        seen.insert(sender.clone(), 0);
1140        let mut idx = 0;
1141        while idx < frontier.len() {
1142            let cur = frontier[idx].clone();
1143            idx += 1;
1144            let cur_dist = seen[&cur];
1145            if let Some(peers) = nodes.get(&cur) {
1146                for peer in peers {
1147                    if !seen.contains_key(peer) {
1148                        seen.insert(peer.clone(), cur_dist + 1);
1149                        frontier.push(peer.clone());
1150                    }
1151                }
1152            }
1153        }
1154
1155        let unreachable = nodes.len().saturating_sub(seen.len());
1156        let max_hops = seen.values().copied().max().unwrap_or(0);
1157        let total_hops = seen.values().copied().sum();
1158        let better = unreachable < best_unreachable
1159            || (unreachable == best_unreachable && max_hops < best_max_hops)
1160            || (unreachable == best_unreachable
1161                && max_hops == best_max_hops
1162                && total_hops < best_total_hops)
1163            || (unreachable == best_unreachable
1164                && max_hops == best_max_hops
1165                && total_hops == best_total_hops
1166                && sender < &best_sender);
1167        if better {
1168            best_sender = sender.clone();
1169            best_unreachable = unreachable;
1170            best_max_hops = max_hops;
1171            best_total_hops = total_hops;
1172        }
1173    }
1174
1175    best_sender
1176}
1177
1178/// Builds a discovery schema packet from an explicit snapshot.
1179pub fn build_discovery_schema_from_snapshot(
1180    sender: &str,
1181    timestamp_ms: u64,
1182    schema: RuntimeSchemaSnapshot,
1183) -> TelemetryResult<Packet> {
1184    let mut payload = Vec::new();
1185    payload.extend_from_slice(&3u32.to_le_bytes());
1186
1187    payload.extend_from_slice(&(schema.endpoints.len() as u32).to_le_bytes());
1188    for ep in schema.endpoints {
1189        payload.extend_from_slice(&ep.id.as_u32().to_le_bytes());
1190        payload.push(ep.link_local_only as u8);
1191        encode_string(&mut payload, ep.name)?;
1192        #[cfg(feature = "std")]
1193        encode_string(&mut payload, ep.description)?;
1194        #[cfg(not(feature = "std"))]
1195        encode_string(&mut payload, "")?;
1196    }
1197
1198    payload.extend_from_slice(&(schema.types.len() as u32).to_le_bytes());
1199    for ty in schema.types {
1200        payload.extend_from_slice(&ty.id.as_u32().to_le_bytes());
1201        encode_string(&mut payload, ty.name)?;
1202        #[cfg(feature = "std")]
1203        encode_string(&mut payload, ty.description)?;
1204        #[cfg(not(feature = "std"))]
1205        encode_string(&mut payload, "")?;
1206        match ty.element {
1207            MessageElement::Static(count, data_type, class) => {
1208                payload.push(0);
1209                payload.extend_from_slice(&(count as u32).to_le_bytes());
1210                payload.push(message_data_type_code(data_type));
1211                payload.push(message_class_code(class));
1212            }
1213            MessageElement::Dynamic(data_type, class) => {
1214                payload.push(1);
1215                payload.extend_from_slice(&0u32.to_le_bytes());
1216                payload.push(message_data_type_code(data_type));
1217                payload.push(message_class_code(class));
1218            }
1219        }
1220        payload.push(reliable_code(ty.reliable));
1221        payload.push(ty.priority);
1222        payload.push(e2e_encryption_policy_code(ty.e2e_encryption));
1223        payload.extend_from_slice(&(ty.endpoints.len() as u32).to_le_bytes());
1224        for ep in ty.endpoints {
1225            payload.extend_from_slice(&ep.as_u32().to_le_bytes());
1226        }
1227    }
1228
1229    Packet::new(
1230        DataType::DiscoverySchema,
1231        &[DataEndpoint::Discovery],
1232        sender,
1233        timestamp_ms,
1234        payload.into(),
1235    )
1236}
1237
1238/// Builds a discovery schema packet from an owned snapshot.
1239pub fn build_discovery_schema_from_owned_snapshot(
1240    sender: &str,
1241    timestamp_ms: u64,
1242    schema: OwnedRuntimeSchemaSnapshot,
1243) -> TelemetryResult<Packet> {
1244    let mut payload = Vec::new();
1245    payload.extend_from_slice(&3u32.to_le_bytes());
1246
1247    payload.extend_from_slice(&(schema.endpoints.len() as u32).to_le_bytes());
1248    for ep in schema.endpoints {
1249        payload.extend_from_slice(&ep.id.as_u32().to_le_bytes());
1250        payload.push(ep.link_local_only as u8);
1251        encode_string(&mut payload, &ep.name)?;
1252        // Descriptions are documentation, not routing metadata. Every node
1253        // keeps its local copy, while omitting them here prevents schema sync
1254        // from consuming constrained radio/CAN queue capacity.
1255        encode_string(&mut payload, "")?;
1256    }
1257
1258    payload.extend_from_slice(&(schema.types.len() as u32).to_le_bytes());
1259    for ty in schema.types {
1260        payload.extend_from_slice(&ty.id.as_u32().to_le_bytes());
1261        encode_string(&mut payload, &ty.name)?;
1262        encode_string(&mut payload, "")?;
1263        match ty.element {
1264            MessageElement::Static(count, data_type, class) => {
1265                payload.push(0);
1266                payload.extend_from_slice(&(count as u32).to_le_bytes());
1267                payload.push(message_data_type_code(data_type));
1268                payload.push(message_class_code(class));
1269            }
1270            MessageElement::Dynamic(data_type, class) => {
1271                payload.push(1);
1272                payload.extend_from_slice(&0u32.to_le_bytes());
1273                payload.push(message_data_type_code(data_type));
1274                payload.push(message_class_code(class));
1275            }
1276        }
1277        payload.push(reliable_code(ty.reliable));
1278        payload.push(ty.priority);
1279        payload.push(e2e_encryption_policy_code(ty.e2e_encryption));
1280        payload.extend_from_slice(&(ty.endpoints.len() as u32).to_le_bytes());
1281        for ep in ty.endpoints {
1282            payload.extend_from_slice(&ep.as_u32().to_le_bytes());
1283        }
1284    }
1285
1286    Packet::new(
1287        DataType::DiscoverySchema,
1288        &[DataEndpoint::Discovery],
1289        sender,
1290        timestamp_ms,
1291        payload.into(),
1292    )
1293}
1294
1295/// Decodes a discovery schema packet.
1296pub fn decode_discovery_schema(pkt: &Packet) -> TelemetryResult<OwnedRuntimeSchemaSnapshot> {
1297    if pkt.data_type() != DataType::DiscoverySchema {
1298        return Err(TelemetryError::InvalidType);
1299    }
1300    decode_discovery_schema_payload(pkt.payload())
1301}
1302
1303/// Decodes a discovery schema payload into runtime definitions.
1304pub fn decode_discovery_schema_payload(
1305    payload: &[u8],
1306) -> TelemetryResult<OwnedRuntimeSchemaSnapshot> {
1307    let mut cursor = 0usize;
1308    let version = read_u32(payload, &mut cursor, "discovery schema version")?;
1309    #[cfg(feature = "std")]
1310    if version != 1 && version != 2 && version != 3 {
1311        return Err(TelemetryError::Unpack("discovery schema version"));
1312    }
1313    #[cfg(not(feature = "std"))]
1314    if version != 3 {
1315        return Err(TelemetryError::Unpack("discovery schema version"));
1316    }
1317
1318    let endpoint_count =
1319        read_u32(payload, &mut cursor, "discovery schema endpoint count")? as usize;
1320    let mut endpoints = bounded_wire_vec(
1321        payload, cursor, endpoint_count, 13, "discovery schema endpoint count",
1322    )?;
1323    for _ in 0..endpoint_count {
1324        let id = DataEndpoint(read_u32(
1325            payload,
1326            &mut cursor,
1327            "discovery schema endpoint id",
1328        )?);
1329        let link_local_only =
1330            read_u8(payload, &mut cursor, "discovery schema endpoint flags")? != 0;
1331        let name = decode_string(payload, &mut cursor, "discovery schema endpoint name")?;
1332        #[cfg(feature = "std")]
1333        let description = if version >= 2 {
1334            decode_string(
1335                payload,
1336                &mut cursor,
1337                "discovery schema endpoint description",
1338            )?
1339        } else {
1340            String::new()
1341        };
1342        #[cfg(not(feature = "std"))]
1343        let description = {
1344            skip_string(
1345                payload,
1346                &mut cursor,
1347                "discovery schema endpoint description",
1348            )?;
1349            String::new()
1350        };
1351        endpoints.push(OwnedEndpointDefinition {
1352            id,
1353            name,
1354            description,
1355            link_local_only,
1356        });
1357    }
1358
1359    let type_count = read_u32(payload, &mut cursor, "discovery schema type count")? as usize;
1360    let mut types = bounded_wire_vec(
1361        payload, cursor, type_count, 19, "discovery schema type count",
1362    )?;
1363    for _ in 0..type_count {
1364        let id = DataType(read_u32(payload, &mut cursor, "discovery schema type id")?);
1365        let name = decode_string(payload, &mut cursor, "discovery schema type name")?;
1366        #[cfg(feature = "std")]
1367        let description = if version >= 2 {
1368            decode_string(payload, &mut cursor, "discovery schema type description")?
1369        } else {
1370            String::new()
1371        };
1372        #[cfg(not(feature = "std"))]
1373        let description = {
1374            skip_string(payload, &mut cursor, "discovery schema type description")?;
1375            String::new()
1376        };
1377        let element_kind = read_u8(payload, &mut cursor, "discovery schema element kind")?;
1378        let count = read_u32(payload, &mut cursor, "discovery schema element count")? as usize;
1379        let data_type = message_data_type_from_code(read_u8(
1380            payload,
1381            &mut cursor,
1382            "discovery schema data type",
1383        )?)
1384        .ok_or(TelemetryError::Unpack("discovery schema data type"))?;
1385        let class =
1386            message_class_from_code(read_u8(payload, &mut cursor, "discovery schema class")?)
1387                .ok_or(TelemetryError::Unpack("discovery schema class"))?;
1388        let element = match element_kind {
1389            0 => MessageElement::Static(count, data_type, class),
1390            1 => MessageElement::Dynamic(data_type, class),
1391            _ => return Err(TelemetryError::Unpack("discovery schema element kind")),
1392        };
1393        let reliable =
1394            reliable_from_code(read_u8(payload, &mut cursor, "discovery schema reliable")?)
1395                .ok_or(TelemetryError::Unpack("discovery schema reliable"))?;
1396        let priority = read_u8(payload, &mut cursor, "discovery schema priority")?;
1397        #[cfg(feature = "std")]
1398        let e2e_encryption = if version >= 3 {
1399            e2e_encryption_policy_from_code(read_u8(
1400                payload,
1401                &mut cursor,
1402                "discovery schema e2e cryptography",
1403            )?)
1404            .ok_or(TelemetryError::Unpack("discovery schema e2e cryptography"))?
1405        } else {
1406            E2eEncryptionPolicy::PreferOff
1407        };
1408        #[cfg(not(feature = "std"))]
1409        let e2e_encryption = e2e_encryption_policy_from_code(read_u8(
1410            payload,
1411            &mut cursor,
1412            "discovery schema e2e cryptography",
1413        )?)
1414        .ok_or(TelemetryError::Unpack("discovery schema e2e cryptography"))?;
1415        let endpoint_count =
1416            read_u32(payload, &mut cursor, "discovery schema type endpoint count")? as usize;
1417        let mut type_endpoints = bounded_wire_vec(
1418            payload, cursor, endpoint_count, 4,
1419            "discovery schema type endpoint count",
1420        )?;
1421        for _ in 0..endpoint_count {
1422            type_endpoints.push(DataEndpoint(read_u32(
1423                payload,
1424                &mut cursor,
1425                "discovery schema type endpoint",
1426            )?));
1427        }
1428        types.push(OwnedDataTypeDefinition {
1429            id,
1430            name,
1431            description,
1432            element,
1433            endpoints: type_endpoints,
1434            reliable,
1435            priority,
1436            e2e_encryption,
1437        });
1438    }
1439
1440    if cursor != payload.len() {
1441        return Err(TelemetryError::Unpack("discovery schema trailing bytes"));
1442    }
1443    Ok(OwnedRuntimeSchemaSnapshot { endpoints, types })
1444}
1445
1446#[cfg(test)]
1447mod bounded_decode_tests {
1448    use super::{
1449        decode_discovery_schema_payload, decode_discovery_timesync_sources_payload,
1450        decode_discovery_topology_payload,
1451    };
1452
1453    #[test]
1454    fn corrupt_timesync_count_is_rejected_without_reserving() {
1455        assert!(decode_discovery_timesync_sources_payload(&u32::MAX.to_le_bytes()).is_err());
1456    }
1457
1458    #[test]
1459    fn corrupt_topology_count_is_rejected_without_reserving() {
1460        assert!(decode_discovery_topology_payload(&u32::MAX.to_le_bytes()).is_err());
1461    }
1462
1463    #[test]
1464    fn corrupt_schema_counts_are_rejected_without_reserving() {
1465        let mut endpoints = 3u32.to_le_bytes().to_vec();
1466        endpoints.extend_from_slice(&u32::MAX.to_le_bytes());
1467        assert!(decode_discovery_schema_payload(&endpoints).is_err());
1468
1469        let mut types = 3u32.to_le_bytes().to_vec();
1470        types.extend_from_slice(&0u32.to_le_bytes());
1471        types.extend_from_slice(&u32::MAX.to_le_bytes());
1472        assert!(decode_discovery_schema_payload(&types).is_err());
1473    }
1474}