zenkey_fleet/scout.rs
1//! Raw scouting: the Hello layer, below sessions and liveliness (#116).
2//!
3//! `discover` answers "which deployments hold liveliness tokens or storages"
4//! — a question that presumes a working session. Scouting answers the two
5//! questions that come *before* one: "is anything out there at all" and "is
6//! multicast scouting working on this segment". It is a third, independent
7//! signal, not a replacement for either.
8//!
9//! Multicast is deliberately **on** here — scouting is what this module *is*.
10//! The session-opening default stays off (see `session::open`'s contamination
11//! warning): a scout only listens for Hellos and joins nothing.
12
13use anyhow::{Context, Result};
14use zenoh::config::WhatAmIMatcher;
15use zenoh::handlers::FifoChannelHandler;
16use zenoh::scouting::Hello;
17
18/// One Hello, owned — zenoh's [`Hello`] is a wrapper we flatten so callers
19/// (a CLI row, a widget) hold plain strings, serialized as they render.
20#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
21pub struct HelloView {
22 /// The node's Zenoh id, hex-formatted.
23 pub zid: String,
24 /// What the node says it is: `router`, `peer`, or `client`.
25 pub whatami: String,
26 /// The locators the node advertises, verbatim.
27 pub locators: Vec<String>,
28}
29
30impl HelloView {
31 fn of(hello: &Hello) -> Self {
32 HelloView {
33 zid: hello.zid().to_string(),
34 whatami: hello.whatami().to_string(),
35 locators: hello.locators().iter().map(|l| l.to_string()).collect(),
36 }
37 }
38}
39
40/// A running scout. Hellos arrive as the segment answers; the caller owns the
41/// deadline (wrap [`ScoutStream::recv`] in a timeout), because how long to
42/// listen is a question about the network, not about this crate.
43pub struct ScoutStream {
44 inner: zenoh::scouting::Scout<FifoChannelHandler<Hello>>,
45}
46
47impl std::fmt::Debug for ScoutStream {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 f.debug_struct("ScoutStream").finish_non_exhaustive()
50 }
51}
52
53impl ScoutStream {
54 /// The next Hello, or `None` once the scout has stopped.
55 pub async fn recv(&self) -> Option<HelloView> {
56 self.inner
57 .recv_async()
58 .await
59 .ok()
60 .map(|h| HelloView::of(&h))
61 }
62
63 /// Stop scouting, explicitly — a drop would stop it too, but silently.
64 pub fn stop(self) {
65 self.inner.stop();
66 }
67}
68
69/// Listen for scouting Hellos: multicast on, plus gossip via any `connect`
70/// endpoints, so a segment with filtered multicast can still answer.
71///
72/// `what` filters by advertised kind; combine with `|`
73/// (`WhatAmI::Router | WhatAmI::Peer`) or pass a parsed [`WhatAmIMatcher`].
74pub async fn scout(
75 what: WhatAmIMatcher,
76 connect: &[String],
77 listen: &[String],
78) -> Result<ScoutStream> {
79 let config = crate::session::explorer_config(connect, listen, true);
80 let inner = zenoh::scout(what, config)
81 .await
82 .map_err(|e| anyhow::anyhow!("{e}"))
83 .context("failed to start scouting")?;
84 Ok(ScoutStream { inner })
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 /// The CLI's ndjson row and the GUI both hang off this exact shape; a
92 /// field rename here is a wire-format change for every script piping
93 /// `zenctl scout --format ndjson`.
94 #[test]
95 fn a_hello_serializes_flat_and_stable() {
96 let view = HelloView {
97 zid: "a1b2".into(),
98 whatami: "router".into(),
99 locators: vec!["tcp/10.0.0.1:7447".into()],
100 };
101 let json = serde_json::to_value(&view).unwrap();
102 assert_eq!(
103 json,
104 serde_json::json!({
105 "zid": "a1b2",
106 "whatami": "router",
107 "locators": ["tcp/10.0.0.1:7447"],
108 })
109 );
110 }
111}