crates/installer/nordisk-pairing/src/signal.rs
//! **The signal: where a box is, said by the box.** One line for a log and a
//! value for a status door — the same reading, so an operator's `status`, the
//! laptop's `estate pairing` and the boot narration all say the same thing.
//! The owner (2026-09-24): the output while an estate pairs is not debug
//! narration; it is a pairing SIGNAL, and the signal is also a status.
use crate::pair::{Machine, State};
use crate::seed::TunnelKind;
use crate::Role;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Signal {
pub name: String,
pub tunnel: TunnelKind,
pub tunnel_up: bool,
pub joined: bool,
pub role: Option<Role>,
/// The step the box is on, in words, and how many times it has tried it.
pub step: String,
pub tries: u32,
pub last_transient: Option<String>,
pub paired: bool,
pub refused: Option<String>,
}
impl Signal {
pub fn of(name: &str, tunnel: TunnelKind, m: &Machine) -> Signal {
let (tunnel_up, joined, role) = match &m.state {
State::Booted => (false, false, None),
State::Joined { role, .. } => (false, true, Some(*role)),
State::RoleKnown { role, .. } => (true, true, Some(*role)),
State::TwinConferred { .. } | State::TwinCertified { .. } => (true, true, Some(Role::Twin)),
State::Paired { role } => (true, true, Some(*role)),
State::Refused { .. } => (false, false, None),
};
Signal {
name: name.to_string(),
tunnel,
tunnel_up: tunnel_up || tunnel == TunnelKind::None,
joined,
role,
step: m.next().name().to_string(),
tries: m.tries,
last_transient: m.last_transient.clone(),
paired: m.paired(),
refused: match &m.state {
State::Refused { why } => Some(why.clone()),
_ => None,
},
}
}
/// `pair main: tunnel wireguard up · joined · role twin · step twincertify (try 3: …) · not paired`
pub fn line(&self) -> String {
let mut parts = vec![format!(
"tunnel {} {}",
self.tunnel.as_str(),
if self.tunnel_up { "up" } else { "DOWN" }
)];
parts.push(if self.joined { "joined".into() } else { "not joined".into() });
if let Some(r) = self.role {
parts.push(format!("role {}", r.as_str()));
}
if let Some(why) = &self.refused {
parts.push(format!("REFUSED for good: {why}"));
} else if !self.paired {
let mut s = format!("step {}", self.step);
if self.tries > 0 {
s.push_str(&format!(" (try {}: {})", self.tries + 1, self.last_transient.as_deref().unwrap_or("")));
}
parts.push(s);
}
parts.push(if self.paired { "PAIRED".into() } else { "not paired".into() });
format!(" pair {}: {}", self.name, parts.join(" · "))
}
}
impl Signal {
/// The signal as a document (TOML), for a status file a reader can parse.
pub fn render(&self) -> Option<String> {
toml::to_string(self).ok()
}
/// The document back (what the installer reads off a VM).
pub fn parse(text: &str) -> Result<Signal, String> {
toml::from_str(text).map_err(|e| format!("not a pairing signal: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pair::Event;
#[test]
fn the_line_follows_the_machine() {
let mut m = Machine::default();
let s = Signal::of("twin", TunnelKind::Wireguard, &m);
assert!(s.line().contains("tunnel wireguard DOWN · not joined · step join · not paired"), "{}", s.line());
m.apply(Event::Joined { role: Role::Twin, primary: Some(crate::OVERLAY_BOX_A), peers_admitted: 2, peers_expected: 3 });
let s = Signal::of("twin", TunnelKind::Wireguard, &m);
assert!(s.line().contains("tunnel wireguard DOWN · joined · role twin · step tunnel-up"), "{}", s.line());
m.apply(Event::TunnelUp);
m.apply(Event::Transient("primary not listening".into()));
let s = Signal::of("twin", TunnelKind::Wireguard, &m);
assert!(s.line().contains("joined · role twin · step become-twin") && s.line().contains("try 2: primary not listening"), "{}", s.line());
m.apply(Event::TwinConferred);
m.apply(Event::TwinCertified);
m.apply(Event::Following { applied: 5, head: 5 });
let s = Signal::of("twin", TunnelKind::Wireguard, &m);
assert!(s.paired && s.line().ends_with("PAIRED"), "{}", s.line());
let none = Signal::of("main", TunnelKind::None, &Machine::default());
assert!(none.tunnel_up, "with no tunnel the wire is always up");
}
}