//! The `mavlink.*` nodes in a generated program, against the vehicle twin
//! (`flowrs_connect::mavlink::peer`: a copter built from the same XML, speaking MAVLink 2 on
//! a TCP port as SITL does).
//!
//! One graph holds every node of the family on one link: `"mavlink.telemetry"` reads
//! `GLOBAL_POSITION_INT` (latitude and longitude in degrees, altitude in millimetres),
//! `"mavlink.heartbeat"` reads the vehicle's mode, `"mavlink.command"` sets GUIDED under an
//! approval naming its hash, and `"mavlink.setpoint"` sends a position given in ENU (ROS's
//! frame) under an approved envelope. The program is generated (`Graph::to_rust`), built
//! against flowrs-connect's `mavlink` runtime and run; the test reads what it printed and
//! what the vehicle received:
//!
//! * the telemetry is the twin's position, each value in its unit, exactly (a latitude of
//! -353632621 degE7 is -35.3632621 degrees);
//! * the command went once, under its approval, and the vehicle acknowledged it;
//! * the setpoint arrived as NED (north 5, east -2.5, down -10 from ENU east -2.5, north 5,
//! up 10), and only because the envelope admitted it: the same graph with no approval, and
//! with a position outside the envelope, sends nothing and says why;
//! * the heartbeat names the vehicle's mode (GUIDED, once the command has set it).
//!
//! A second graph carries a command from ROS to the vehicle: `"ros.subscribe"` reads a
//! `geometry_msgs/PointStamped` goal on `/drone/goal` (ENU, metres, REP 103) and wires its
//! point into `"mavlink.setpoint"`. The partitioner makes it one Linux program, the ROS 2 node
//! and the MAVLink link together; the test publishes the goal over DDS on loopback and
//! requires the vehicle to receive it as the NED target, under the approved envelope. Run
//! first with no time to open DDS (`FLOWROS_OPEN_TIMEOUT_MS=0`), the same program must exit
//! at once and say why.
//!
//! The programs build under `$TMPDIR/flowrs-mavlink-nodes*`, their target in
//! `$TMPDIR/flowrs-mavlink-nodes-target`, offline, against the workspace lockfile. Every
//! run has a wall-clock limit.
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use flowrs_connect::mavlink::gate::{ENVELOPE_KIND, KIND};
use flowrs_connect::mavlink::nodes;
use flowrs_connect::mavlink::peer::{Peer, PeerConfig};
use flowrs_core::graph::{Graph, NodeId, WireMode};
use flowrs_instruments::grammar::Match;
use flowrs_instruments::proposal::{Phase, PlannedCommand, Proposal, ProposalTable, Target};
fn add(g: &mut Graph, ty: &str, props: &[(&str, &str)]) -> NodeId {
let n = g.add_node(ty, 0.0, 0.0);
for (k, v) in props {
g.nodes
.get_mut(&n)
.unwrap()
.properties
.insert((*k).into(), (*v).into());
}
n
}
fn print(g: &mut Graph, from: NodeId, port: &str) {
let p = add(g, "PrintLn", &[]);
g.add_wire(from, port, p, "input", WireMode::Move).unwrap();
}
/// A proposal of `kind` with `lines`, approved by a person and claimed by the envelope, as
/// flowrs-server hands it to the program that executes it.
fn claimed(kind: &str, endpoint: &str, lines: &[String]) -> Proposal {
let p = Proposal::new(
kind,
"mavlink nodes test".into(),
"agent",
"now",
vec![Target {
model: "mavlink".into(),
endpoint: endpoint.into(),
mock: false,
}],
Vec::new(),
lines
.iter()
.map(|l| PlannedCommand {
target: 0,
line: l.clone(),
tier: flowrs_instruments::Tier::Energize,
matched: Match::Raw,
phase: Phase::Main,
query: false,
})
.collect(),
Vec::new(),
);
let mut t = ProposalTable::new();
let id = t.insert(p).unwrap().id.clone();
let hash = t.get(&id).unwrap().hash.clone();
t.decide(&id, &hash, true, "human", "now", None).unwrap();
t.claim(&id, &hash).unwrap();
t.get(&id).unwrap().clone()
}
/// A node of `ty` on `endpoint` (the link every mavlink node here shares), with `props`.
fn mav(g: &mut Graph, ty: &str, endpoint: &str, props: &[(&str, &str)]) -> NodeId {
let n = add(g, ty, props);
let p = &mut g.nodes.get_mut(&n).unwrap().properties;
p.insert("link".into(), endpoint.into());
p.insert("timeout_ms".into(), "8000".into());
n
}
/// The graph: every mavlink node on `endpoint`, the setpoint at ENU `position`. Ordered as
/// the program runs them: the command (GUIDED) first, then the setpoint, which the vehicle
/// takes only in GUIDED.
fn graph(endpoint: &str, position: &str, cmd_approval: &str, env_approval: &str) -> Graph {
let mut g = Graph::new();
let trigger = add(&mut g, "BoolLiteral", &[("value", "true")]);
let cmd = mav(
&mut g,
"mavlink.command",
endpoint,
&[
("system", "1"),
("command", "MAV_CMD_DO_SET_MODE"),
("param1", "1"),
("param2", "4"),
("approval", cmd_approval),
],
);
g.add_wire(trigger, "value", cmd, "trigger", WireMode::Copy)
.unwrap();
print(&mut g, cmd, "status");
let pos = add(
&mut g,
"Control",
&[("widget", "array"), ("value", position)],
);
let sp = mav(
&mut g,
"mavlink.setpoint",
endpoint,
&[
("system", "1"),
("frame", "enu"),
("approval", env_approval),
],
);
g.add_wire(pos, "value", sp, "position", WireMode::Move)
.unwrap();
print(&mut g, sp, "status");
let tel = mav(&mut g, "mavlink.telemetry", endpoint, &[("field3", "alt")]);
for p in ["value", "value2", "value3", "time"] {
print(&mut g, tel, p);
}
let hb = mav(&mut g, "mavlink.heartbeat", endpoint, &[]);
print(&mut g, hb, "mode");
g
}
/// The workspace checkout.
fn checkout() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap()
}
/// Build the crate `cargo_toml` + `src/main.rs` = `src` under `$TMPDIR/
`, offline
/// against the workspace lockfile; its binary `bin`.
fn build_crate(dir: &str, cargo_toml: &str, src: &str, bin: &str) -> PathBuf {
let dir = std::env::temp_dir().join(dir);
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/main.rs"), src).unwrap();
std::fs::write(dir.join("Cargo.toml"), cargo_toml).unwrap();
std::fs::copy(checkout().join("Cargo.lock"), dir.join("Cargo.lock")).unwrap();
let target = std::env::temp_dir().join("flowrs-mavlink-nodes-target");
let out = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
.args(["build", "--quiet", "--offline", "--manifest-path"])
.arg(dir.join("Cargo.toml"))
.env("CARGO_TARGET_DIR", &target)
.output()
.unwrap();
assert!(
out.status.success(),
"the generated program did not build:\n{}\n--- source ---\n{src}",
String::from_utf8_lossy(&out.stderr)
);
target.join("debug").join(bin)
}
/// Build the program `src` against flowrs-connect's `mavlink` runtime.
fn build(src: &str) -> PathBuf {
let co = checkout();
let toml = format!(
"[package]\nname = \"flowrs-mavlink-nodes\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\
publish = false\n\n[workspace]\n\n[dependencies]\n\
flowrs-connect = {{ path = {:?}, features = [\"mavlink\"] }}\n",
co.join("crates/flowrs-connect").display().to_string(),
);
build_crate("flowrs-mavlink-nodes", &toml, src, "flowrs-mavlink-nodes")
}
/// Run `bin` for at most `limit`, returning its exit success, stdout and stderr.
fn run(bin: &Path, limit: Duration) -> (bool, String, String) {
run_with(bin, limit, &[], &mut || {})
}
/// [`run`] with `env`, calling `meanwhile` every 20 ms while it runs.
fn run_with(
bin: &Path,
limit: Duration,
env: &[(&str, String)],
meanwhile: &mut dyn FnMut(),
) -> (bool, String, String) {
let mut child = Command::new(bin)
.envs(env.iter().map(|(k, v)| (*k, v.as_str())))
.env("FLOWRS_MAVLINK_WINDOW_MS", "8000")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let start = Instant::now();
let ok = loop {
if let Some(s) = child.try_wait().unwrap() {
break s.success();
}
if start.elapsed() > limit {
let _ = child.kill();
let _ = child.wait();
panic!("the generated program ran past {limit:?}");
}
meanwhile();
std::thread::sleep(Duration::from_millis(20));
};
let (mut o, mut e) = (String::new(), String::new());
child.stdout.take().unwrap().read_to_string(&mut o).unwrap();
child.stderr.take().unwrap().read_to_string(&mut e).unwrap();
(ok, o, e)
}
/// Build the generated program `src` and run it, for at most 90 s.
fn run_generated(src: &str) -> (bool, String, String) {
run(&build(src), Duration::from_secs(90))
}
#[test]
fn every_mavlink_node_runs_against_the_vehicle_twin() {
let d = nodes::dialect("ardupilotmega").unwrap();
let scratch = std::env::temp_dir().join(format!("flowrs-mavlink-nodes-{}", std::process::id()));
std::fs::create_dir_all(&scratch).unwrap();
let peer = Peer::spawn(PeerConfig::copter(d.clone())).unwrap();
let ep = peer.endpoint();
let count = |name: &str, command: Option| {
peer.log()
.received
.iter()
.filter(|g| {
g.name == name
&& command.is_none_or(|c| {
g.values.get("command").and_then(serde_json::Value::as_u64) == Some(c)
})
})
.count()
};
// Approvals, made on the twin's endpoint: GUIDED by its hash; the default envelope.
let guided = nodes::command_long(
&d,
(1, 1),
"MAV_CMD_DO_SET_MODE",
[1.0, 4.0, 0.0, 0.0, 0.0, 0.0, 0.0],
)
.unwrap();
let cmd_file = scratch.join("cmd.json");
let line = format!("MAV_CMD_DO_SET_MODE GUIDED {}", guided.hash(&ep, "").tag());
std::fs::write(
&cmd_file,
serde_json::to_string(&claimed(KIND, &ep, &[line])).unwrap(),
)
.unwrap();
let env = nodes::envelope(&ep, (1, 1), 20.0, 30.0, 20.0, 300.0, "").unwrap();
let env_file = scratch.join("env.json");
std::fs::write(
&env_file,
serde_json::to_string(&claimed(ENVELOPE_KIND, &ep, &[env.describe()])).unwrap(),
)
.unwrap();
let (cmd_path, env_path) = (
cmd_file.display().to_string(),
env_file.display().to_string(),
);
// 1. Approved, inside the envelope: GUIDED, then the setpoint, then what the vehicle says.
let src = graph(&ep, "-2.5,5,10", &cmd_path, &env_path)
.to_rust()
.unwrap();
assert!(
src.contains("flowrs_connect::mavlink::graph::command("),
"{src}"
);
assert!(
src.contains("flowrs_connect::mavlink::graph::setpoint("),
"{src}"
);
assert!(
src.contains("flowrs_connect::mavlink::graph::telemetry("),
"{src}"
);
assert!(
src.contains("flowrs_connect::mavlink::graph::heartbeat("),
"{src}"
);
let (ok, out, err) = run_generated(&src);
assert!(ok, "the program failed:\n{out}\n{err}");
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 7, "{out}\n{err}");
assert!(
lines[0].starts_with("MAV_CMD_DO_SET_MODE sent seq ") && lines[0].ends_with(": ACCEPTED"),
"{out}"
);
assert!(
lines[1].starts_with("sent seq ") && lines[1].ends_with(" north 5 east -2.5 down -10 m"),
"{out}"
);
// The twin's position, each value in its unit: degE7 to degrees, mm as mm.
assert_eq!(lines[2], "-35.3632621", "{out}");
assert_eq!(lines[3], "149.1652374", "{out}");
assert_eq!(lines[4], "584090", "{out}");
assert!(
lines[5].parse::().is_ok_and(|t| t >= 0.0),
"time_boot_ms: {out}"
);
// The newest heartbeat may predate the mode change; it is named either way.
assert!(matches!(lines[6], "STABILIZE" | "GUIDED"), "{out}");
let log = peer.log();
assert!(log.guided);
assert_eq!(log.target, Some([5.0, -2.5, -10.0]), "{log:?}");
assert_eq!(count("COMMAND_LONG", Some(176)), 1, "the command went once");
let setpoints = count("SET_POSITION_TARGET_LOCAL_NED", None);
assert_eq!(setpoints, 1, "{log:?}");
// 2. The same graph with no approval: the command and the setpoint send nothing and say
// why. The vehicle is still in GUIDED, and its heartbeat says so.
let src = graph(&ep, "-2.5,5,10", "", "").to_rust().unwrap();
let (ok, out, err) = run_generated(&src);
assert!(ok, "{out}\n{err}");
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 7, "{out}\n{err}");
assert!(lines[0].starts_with("refused: no approval"), "{out}");
assert!(lines[1].starts_with("refused: no approval"), "{out}");
assert_eq!(lines[6], "GUIDED", "{out}");
assert_eq!(
count("COMMAND_LONG", Some(176)),
1,
"nothing unapproved went"
);
assert_eq!(count("SET_POSITION_TARGET_LOCAL_NED", None), setpoints);
// 3. Approved, but outside the envelope (up 31 m of 30): refused, unsent.
let src = graph(&ep, "0,0,31", "", &env_path).to_rust().unwrap();
let (ok, out, err) = run_generated(&src);
assert!(ok, "{out}\n{err}");
let lines: Vec<&str> = out.lines().collect();
assert!(
lines[1].starts_with("refused: down -31 m is outside the approved -30..0 m"),
"{out}"
);
assert_eq!(count("SET_POSITION_TARGET_LOCAL_NED", None), setpoints);
assert_eq!(peer.log().target, Some([5.0, -2.5, -10.0]));
let _ = std::fs::remove_dir_all(&scratch);
}
#[test]
fn a_ros_goal_reaches_the_vehicle_as_an_approved_setpoint() {
use flowrs_connect::ros2::EdgeClass;
use flowrs_connect::ros2::node::{Distro, EndpointSpec, NodeSpec};
use flowrs_connect::ros2::rt::RosNode;
use flowrs_core::codegen::partition::{Options, partition};
let d = nodes::dialect("ardupilotmega").unwrap();
let peer = Peer::spawn(PeerConfig::copter(d)).unwrap();
let ep = peer.endpoint();
let scratch = std::env::temp_dir().join(format!("flowrs-mavlink-goal-{}", std::process::id()));
std::fs::create_dir_all(&scratch).unwrap();
let env = nodes::envelope(&ep, (1, 1), 20.0, 30.0, 20.0, 300.0, "").unwrap();
let env_file = scratch.join("env.json");
std::fs::write(
&env_file,
serde_json::to_string(&claimed(ENVELOPE_KIND, &ep, &[env.describe()])).unwrap(),
)
.unwrap();
// ros.subscribe (a PointStamped goal, ENU) -> mavlink.setpoint (frame enu) -> its status.
let mut g = Graph::new();
let goal = add(
&mut g,
"ros.subscribe",
&[
("topic", "goal"),
("type", "geometry_msgs/msg/PointStamped"),
("field", "point"),
("domain", "ros(/drone)"),
],
);
let env_path = env_file.display().to_string();
let sp = mav(
&mut g,
"mavlink.setpoint",
&ep,
&[("system", "1"), ("frame", "enu"), ("approval", &env_path)],
);
g.add_wire(goal, "value", sp, "position", WireMode::Move)
.unwrap();
print(&mut g, sp, "status");
let co = checkout().display().to_string();
let p = partition(
&g,
&Options {
name: "flowrs_goal".into(),
host_passes: 1,
timeout_ms: 30_000,
checkout: co,
distro: Distro::Jazzy,
},
)
.unwrap_or_else(|e| panic!("{e:?}"));
let linux = p.linux().unwrap();
assert!(
linux.cargo_toml.contains("\"ros2\", \"mavlink\""),
"{}",
linux.cargo_toml
);
let calls = flowrs_connect::ros2::xcdr_gen::dynamic_codec_calls(&linux.source);
assert!(calls.is_empty(), "{calls:?}");
let bin = build_crate(
"flowrs-mavlink-goal",
&linux.cargo_toml,
&linux.source,
&linux.crate_name.replace('_', "-"),
);
let domain = 150 + (std::process::id() % 50) as u16;
// A program whose ROS 2 node cannot open DDS in time exits at once with the reason, not
// waiting for ever with nothing said (a stuck airportd did that on the build host,
// 2026-10-30). No participant opens in no time at all.
let start = Instant::now();
let (ok, out, err) = run_with(
&bin,
Duration::from_secs(60),
&[
("ROS_DOMAIN_ID", domain.to_string()),
("ROS_AUTOMATIC_DISCOVERY_RANGE", "LOCALHOST".into()),
("FLOWROS_OPEN_TIMEOUT_MS", "0".into()),
],
&mut || {},
);
assert!(
!ok,
"the program ran with no time to open DDS:\n{out}\n{err}"
);
// main returns the error, and Rust prints it: `Error: "DDS: opening the node /drone/…`.
assert!(
err.contains("Error: \"DDS: opening the node /drone/"),
"{out}\n{err}"
);
assert!(
err.contains(&format!(
"on ROS_DOMAIN_ID {domain} did not finish within 0 ms; the thread still waiting on \
it cannot be stopped, so the program must exit"
)),
"{out}\n{err}"
);
assert!(
start.elapsed() < Duration::from_secs(30),
"{:?}",
start.elapsed()
);
// The goal, published until the program has read one and exited.
let mut publisher = RosNode::open_with(
&NodeSpec {
name: "goal_source".into(),
namespace: "/drone".into(),
distro: Distro::Jazzy,
endpoints: vec![EndpointSpec {
name: "GOAL".into(),
node: 0,
topic: "/drone/goal".into(),
type_name: "geometry_msgs/msg/PointStamped".into(),
rihs01: flowrs_iface::ros::catalog::rihs01("geometry_msgs/msg/PointStamped")
.unwrap(),
publish: true,
class: EdgeClass::auto("geometry_msgs/msg/PointStamped"),
qos: EdgeClass::auto("geometry_msgs/msg/PointStamped").qos(),
paths: vec!["point".into()],
constants: String::new(),
frame_id: "map".into(),
field: String::new(),
device_clock: String::new(),
}],
},
domain,
true,
)
.unwrap();
let mut last = Instant::now() - Duration::from_secs(1);
let mut sent = 0u32;
let (ok, out, err) = run_with(
&bin,
Duration::from_secs(90),
&[
("ROS_DOMAIN_ID", domain.to_string()),
("ROS_AUTOMATIC_DISCOVERY_RANGE", "LOCALHOST".into()),
("FLOWROS_WAIT_MATCHED_MS", "30000".into()),
],
&mut || {
if last.elapsed() >= Duration::from_millis(100) {
let m = publisher
.compose(0, &[serde_json::json!([-2.5, 5.0, 10.0])])
.unwrap();
publisher.publish(0, m, None).unwrap();
sent += 1;
last = Instant::now();
}
},
);
assert!(ok, "the program failed after {sent} goals:\n{out}\n{err}");
assert!(
out.lines()
.any(|l| l.starts_with("sent seq ") && l.ends_with(" north 5 east -2.5 down -10 m")),
"{out}\n{err}"
);
let got: Vec<_> = peer
.log()
.received
.into_iter()
.filter(|g| g.name == "SET_POSITION_TARGET_LOCAL_NED")
.collect();
assert_eq!(got.len(), 1, "{got:?}");
let v = |k: &str| got[0].values[k].as_f64().unwrap();
assert_eq!((v("x"), v("y"), v("z")), (5.0, -2.5, -10.0));
let _ = std::fs::remove_dir_all(&scratch);
}