use crate::{BehaviorNode, Context, NodeStatus, TreeStatusSnapshot};
use async_trait::async_trait;
use mecha10_core::Receiver;
use serde::{Deserialize, Serialize};
use serde_json::Value;
fn default_topic() -> String {
"/teleop/cmd".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct EmergencyStopSignal {
#[serde(default)]
emergency_stop: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct EstopActiveConfig {
#[serde(default = "default_topic")]
topic: String,
}
pub struct EstopActiveNode {
topic: String,
receiver: Option<Receiver<Value>>,
active: bool,
last_status: Option<NodeStatus>,
}
impl std::fmt::Debug for EstopActiveNode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EstopActiveNode")
.field("topic", &self.topic)
.field("subscribed", &self.receiver.is_some())
.field("active", &self.active)
.finish()
}
}
impl EstopActiveNode {
pub fn from_json(config: Value) -> anyhow::Result<Self> {
let cfg: EstopActiveConfig = if config.is_null() {
EstopActiveConfig { topic: default_topic() }
} else {
serde_json::from_value(config)?
};
Ok(Self {
topic: cfg.topic,
receiver: None,
active: false,
last_status: None,
})
}
#[cfg(test)]
fn with_receiver(receiver: Receiver<Value>) -> Self {
Self {
topic: "test/topic".to_string(),
receiver: Some(receiver),
active: false,
last_status: None,
}
}
fn poll(&mut self) {
let Some(receiver) = self.receiver.as_mut() else {
return;
};
let mut latest = None;
while let Ok(value) = receiver.try_recv() {
match serde_json::from_value::<EmergencyStopSignal>(value) {
Ok(signal) => latest = Some(signal.emergency_stop),
Err(e) => tracing::warn!("EstopActiveNode: dropping malformed teleop message: {}", e),
}
}
if let Some(active) = latest {
self.active = active;
}
}
}
#[async_trait]
impl BehaviorNode for EstopActiveNode {
async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
if self.receiver.is_none() {
self.receiver = Some(ctx.subscribe_raw::<Value>(&self.topic).await?);
}
self.poll();
let status = if self.active {
NodeStatus::Running
} else {
NodeStatus::Failure
};
self.last_status = Some(status);
Ok(status)
}
fn name(&self) -> &str {
"EstopActiveNode"
}
fn status_snapshot(&self) -> TreeStatusSnapshot {
TreeStatusSnapshot {
name: self.name().to_string(),
status: self.last_status,
active_capability: self.active_capability().map(String::from),
children: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mecha10_core::teleop::{TeleopCommand, TeleopInput};
use tokio::sync::mpsc;
fn node_with_channel() -> (EstopActiveNode, mpsc::UnboundedSender<Value>) {
let (tx, rx) = mpsc::unbounded_channel();
(EstopActiveNode::with_receiver(Receiver::from(rx)), tx)
}
#[test]
fn no_message_yet_defaults_to_inactive() {
let (mut node, _tx) = node_with_channel();
node.poll();
assert!(!node.active);
}
#[test]
fn emergency_stop_true_marks_active() {
let (mut node, tx) = node_with_channel();
let input = TeleopInput::emergency_stop("safety", 123);
tx.send(serde_json::to_value(&input).unwrap()).unwrap();
node.poll();
assert!(node.active);
}
#[test]
fn emergency_stop_false_marks_inactive() {
let (mut node, tx) = node_with_channel();
let input = TeleopInput::new("gamepad", 1.0, 0.0, 123);
tx.send(serde_json::to_value(&input).unwrap()).unwrap();
node.poll();
assert!(!node.active);
}
#[test]
fn later_message_overrides_earlier_one() {
let (mut node, tx) = node_with_channel();
tx.send(serde_json::to_value(TeleopInput::emergency_stop("safety", 1)).unwrap())
.unwrap();
tx.send(serde_json::to_value(TeleopInput::new("gamepad", 0.0, 0.0, 2)).unwrap())
.unwrap();
node.poll();
assert!(!node.active);
}
#[test]
fn ignores_malformed_messages_and_keeps_last_known_state() {
let (mut node, tx) = node_with_channel();
tx.send(serde_json::to_value(TeleopInput::emergency_stop("safety", 1)).unwrap())
.unwrap();
node.poll();
assert!(node.active);
tx.send(serde_json::json!("not an object")).unwrap();
node.poll();
assert!(
node.active,
"a malformed message must not reset a previously-known state"
);
}
#[tokio::test]
#[ignore = "Requires Redis infrastructure"]
async fn tick_returns_failure_before_any_message_then_running_once_estop_fires() {
let ctx = crate::test_helpers::create_test_context("estop_active_tick").await;
let mut node = EstopActiveNode::from_json(serde_json::json!({ "topic": "/teleop/cmd" })).unwrap();
assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
let input = TeleopInput::emergency_stop("safety", 1);
ctx.publish_to_path("/teleop/cmd", &input).await.unwrap();
assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
}
#[test]
fn does_not_override_active_capability() {
let (node, _tx) = node_with_channel();
assert_eq!(node.active_capability(), None);
}
#[test]
fn from_json_defaults_topic() {
let node = EstopActiveNode::from_json(serde_json::json!({})).unwrap();
assert_eq!(node.topic, "/teleop/cmd");
}
#[test]
fn from_json_defaults_topic_for_a_bare_null_config() {
let node = EstopActiveNode::from_json(Value::Null).unwrap();
assert_eq!(node.topic, "/teleop/cmd");
}
#[test]
fn from_json_reads_explicit_topic() {
let node = EstopActiveNode::from_json(serde_json::json!({ "topic": "/custom/teleop" })).unwrap();
assert_eq!(node.topic, "/custom/teleop");
}
#[test]
fn teleop_command_variant_is_irrelevant_to_the_signal() {
let input = TeleopInput::emergency_stop("safety", 1);
assert!(matches!(input.command, TeleopCommand::Drivetrain { .. }));
let signal: EmergencyStopSignal = serde_json::from_value(serde_json::to_value(&input).unwrap()).unwrap();
assert!(signal.emergency_stop);
}
}