Flip T3 to substrate-initiated actuator commands

This commit is contained in:
Valère Plantevin
2026-05-13 15:03:23 -04:00
parent 272d3b3c59
commit baa075fe0f
22 changed files with 1003 additions and 749 deletions

96
simulator/src/commands.rs Normal file
View File

@@ -0,0 +1,96 @@
//! Substrate → simulator T3 receiver.
//!
//! The substrate is the brain: when its `automation_system` decides to
//! actuate, it opens a QUIC bidirectional stream to one of its connected
//! devices. The simulator side accepts those streams here, decodes the
//! 39-byte command, applies it to local actuator state, and writes a 39-byte
//! ack back. This closes the loop the paper's three-tier model describes.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use substrate::transport::{QuicMessage, SensorType};
/// Convenience constructor used by `main.rs` and integration tests.
/// `true` means the simulated engine is running normally.
pub fn new_engine_state() -> Arc<AtomicBool> {
Arc::new(AtomicBool::new(true))
}
/// Loop accepting substrate-initiated bidirectional streams until the
/// connection drops. Each stream is one (command, ack) round-trip:
/// the simulator reads a 39-byte `QuicMessage`, mutates `engine_running` if
/// the command targets the Relay actuator, then writes a 39-byte ack back
/// (echoes the command with the simulator's local timestamp).
pub async fn run_command_receiver(conn: quinn::Connection, engine_running: Arc<AtomicBool>) {
let remote = conn.remote_address();
let mut streams_seen: u64 = 0;
loop {
let (send, recv) = match conn.accept_bi().await {
Ok(s) => s,
Err(e) => {
tracing::debug!(
?remote,
streams_seen,
error = %e,
"command receiver: accept_bi loop ended"
);
return;
}
};
streams_seen += 1;
let engine_running = engine_running.clone();
tokio::spawn(handle_one_command(remote, send, recv, engine_running));
}
}
async fn handle_one_command(
remote: std::net::SocketAddr,
mut send: quinn::SendStream,
mut recv: quinn::RecvStream,
engine_running: Arc<AtomicBool>,
) {
let mut buf = [0u8; QuicMessage::WIRE_SIZE];
if let Err(e) = recv.read_exact(&mut buf).await {
tracing::trace!(?remote, error = %e, "command receiver: short read; closing stream");
return;
}
let cmd = match QuicMessage::decode(&buf) {
Ok(m) => m,
Err(e) => {
tracing::warn!(?remote, error = %e, "command receiver: decode failed");
let _ = send.reset(0u32.into());
return;
}
};
if cmd.typ() == SensorType::Relay {
// raw_value == 1.0 ⇒ stop the engine; 0.0 ⇒ resume.
let now_running = cmd.raw_value < 0.5;
let was_running = engine_running.swap(now_running, Ordering::SeqCst);
if now_running != was_running {
if now_running {
tracing::info!(device = %cmd.device_id, "Relay=0 received — engine resuming");
} else {
tracing::info!(device = %cmd.device_id, "Relay=1 received — engine stopping");
}
}
} else {
tracing::debug!(
?remote,
sensor_type = cmd.sensor_type,
"command receiver: ignoring non-Relay command"
);
}
// Ack by echoing the command — the substrate's outbound drain measures
// latency from open_bi() to ack receipt.
if let Err(e) = send.write_all(&cmd.to_bytes()).await {
tracing::warn!(?remote, error = %e, "command receiver: ack write failed");
return;
}
if let Err(e) = send.finish() {
tracing::warn!(?remote, error = %e, "command receiver: ack finish failed");
}
}

View File

@@ -1,16 +1,18 @@
//! Async emitter tasks for T2 (uni streams) and T3 (bi streams + ack).
//! Async emitter task for T2 (uni streams).
//!
//! Each emitter ticks at its own rate, opens a fresh stream per event, and
//! shares a `Connection` with the rest of the simulator. T1 (datagrams) is
//! driven inline by the main loop so the foreground task owns the progress
//! reporting; the reliable tiers run as `tokio::spawn`ed background tasks.
//! Ticks at its own rate, opens a fresh stream per event, and shares a
//! `Connection` with the rest of the simulator. T1 (datagrams) is driven
//! inline by the main loop so the foreground task owns the progress
//! reporting; T2 runs as a `tokio::spawn`ed background task.
//!
//! T3 (actuator commands) is substrate-initiated — the receiver lives in
//! `crate::commands`, not here.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::Context;
use substrate::transport::{QuicMessage, SensorType};
use substrate::transport::QuicMessage;
use tokio::time::MissedTickBehavior;
use crate::profile::{SensorSlot, generate_value};
@@ -34,6 +36,7 @@ pub async fn run_t2_emitter(
mut slot: SensorSlot,
rate_hz: f64,
interrupted: Arc<AtomicBool>,
engine_running: Arc<AtomicBool>,
counter: Arc<AtomicU64>,
) -> u64 {
let period = Duration::from_nanos((1.0e9 / rate_hz) as u64);
@@ -55,10 +58,11 @@ pub async fn run_t2_emitter(
break;
}
let running = engine_running.load(Ordering::Relaxed);
let msg = QuicMessage {
device_id: slot.device_id,
sensor_id: slot.sensor_id,
raw_value: generate_value(slot.sensor_type, slot.seq),
raw_value: generate_value(slot.sensor_type, slot.seq, running),
timestamp_us: now_us(),
sequence_number: slot.seq,
sensor_type: slot.sensor_type.as_u8(),
@@ -77,85 +81,6 @@ pub async fn run_t2_emitter(
if let Err(e) = send.finish() {
tracing::warn!(error = %e, "T2 finish failed");
}
sent
}
/// T3 emitter — opens a fresh bi-stream per command, writes the command,
/// awaits the ack with a bounded timeout. Returns `(acks_received, timeouts)`.
pub async fn run_t3_emitter(
conn: quinn::Connection,
mut slot: SensorSlot,
rate_hz: f64,
timeout: Duration,
interrupted: Arc<AtomicBool>,
sent_counter: Arc<AtomicU64>,
timeout_counter: Arc<AtomicU64>,
) -> (u64, u64) {
let period = Duration::from_nanos((1.0e9 / rate_hz) as u64);
let mut ticker = tokio::time::interval(period);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut sent: u64 = 0;
let mut timeouts: u64 = 0;
let mut last_relay_state = 0.0;
loop {
ticker.tick().await;
if interrupted.load(Ordering::SeqCst) {
break;
}
let cmd = QuicMessage {
device_id: slot.device_id,
sensor_id: slot.sensor_id,
raw_value: generate_value(slot.sensor_type, slot.seq),
timestamp_us: now_us(),
sequence_number: slot.seq,
sensor_type: slot.sensor_type.as_u8(),
};
slot.seq = slot.seq.wrapping_add(1);
match tokio::time::timeout(timeout, t3_one_request(&conn, &cmd)).await {
Ok(Ok(ack)) => {
sent += 1;
sent_counter.store(sent, Ordering::Relaxed);
if ack.sensor_type == SensorType::Relay.as_u8() {
let is_on = ack.raw_value > 0.5;
let was_on = last_relay_state > 0.5;
if is_on && !was_on {
tracing::info!(device = %ack.device_id, "Relay triggered ON (machine stopped)!");
} else if !is_on && was_on {
tracing::info!(device = %ack.device_id, "Relay turned OFF.");
}
last_relay_state = ack.raw_value;
}
}
Ok(Err(e)) => {
tracing::warn!(error = %e, "T3 request failed");
}
Err(_) => {
timeouts += 1;
timeout_counter.store(timeouts, Ordering::Relaxed);
tracing::warn!(?timeout, "T3 ack timed out");
}
}
}
(sent, timeouts)
}
/// Single T3 round-trip: open bi-stream, write 38 B command, `finish` the
/// send half, read 38 B ack. Used by `run_t3_emitter`.
async fn t3_one_request(
conn: &quinn::Connection,
cmd: &QuicMessage,
) -> anyhow::Result<QuicMessage> {
let (mut send, mut recv) = conn.open_bi().await.context("T3 open_bi")?;
send.write_all(&cmd.to_bytes())
.await
.context("T3 write command")?;
send.finish().context("T3 finish send half")?;
let mut buf = [0u8; QuicMessage::WIRE_SIZE];
recv.read_exact(&mut buf).await.context("T3 read ack")?;
QuicMessage::decode(&buf).context("T3 decode ack")
}

View File

@@ -1,4 +1,5 @@
pub mod client;
pub mod commands;
pub mod emitters;
pub mod profile;

View File

@@ -17,7 +17,8 @@ use std::time::{Duration, Instant};
use anyhow::{Context, anyhow};
use clap::{Parser, ValueEnum};
use simulator::client::SimulatorClient;
use simulator::emitters::{now_us, run_t2_emitter, run_t3_emitter};
use simulator::commands::{new_engine_state, run_command_receiver};
use simulator::emitters::{now_us, run_t2_emitter};
use simulator::profile::{SensorProfile, build_slots, generate_value};
use substrate::transport::{QuicMessage, SensorType};
use tokio::time::MissedTickBehavior;
@@ -60,14 +61,6 @@ struct Cli {
#[arg(long, default_value_t = 0.0)]
t2_rate_hz: f64,
/// T3 bidirectional command rate (Hz). `0` disables T3 (default).
#[arg(long, default_value_t = 0.0)]
t3_rate_hz: f64,
/// Per-command timeout for T3 ack waits (milliseconds).
#[arg(long, default_value_t = 2000)]
t3_timeout_ms: u64,
/// Number of T1 datagrams to send. `0` runs until Ctrl-C.
#[arg(long, default_value_t = 10)]
count: u64,
@@ -112,12 +105,9 @@ fn validate(cli: &Cli) -> anyhow::Result<()> {
if cli.t2_rate_hz < 0.0 {
return Err(anyhow!("--t2-rate-hz must be >= 0"));
}
if cli.t3_rate_hz < 0.0 {
return Err(anyhow!("--t3-rate-hz must be >= 0"));
}
if cli.rate_hz == 0.0 && cli.t2_rate_hz == 0.0 && cli.t3_rate_hz == 0.0 {
if cli.rate_hz == 0.0 && cli.t2_rate_hz == 0.0 {
return Err(anyhow!(
"at least one of --rate-hz / --t2-rate-hz / --t3-rate-hz must be > 0"
"at least one of --rate-hz / --t2-rate-hz must be > 0"
));
}
if cli.devices == 0 {
@@ -150,7 +140,6 @@ async fn main() -> anyhow::Result<()> {
?cli.addr,
rate_hz = cli.rate_hz,
t2_rate_hz = cli.t2_rate_hz,
t3_rate_hz = cli.t3_rate_hz,
count = cli.count,
devices = cli.devices,
slots = slots.len(),
@@ -172,9 +161,20 @@ async fn main() -> anyhow::Result<()> {
});
}
// T2 / T3 emitters target slot[0] for their device/sensor identity.
// Engine state: starts running. Flipped by `run_command_receiver` when
// the substrate's automation_system sends a Relay actuator command.
let engine_running = new_engine_state();
{
let conn = client.conn.clone();
let engine_running = engine_running.clone();
tokio::spawn(async move {
run_command_receiver(conn, engine_running).await;
});
}
// T2 emitter targets slot[0] for its device/sensor identity. T3 commands
// are substrate-initiated; there's no simulator-side emitter for them.
let t2_slot = slots[0].clone();
let t3_slot = slots[0].clone();
let t2_sent = Arc::new(AtomicU64::new(0));
let t2_handle = if cli.t2_rate_hz > 0.0 {
@@ -182,33 +182,9 @@ async fn main() -> anyhow::Result<()> {
let rate = cli.t2_rate_hz;
let interrupted = interrupted.clone();
let counter = t2_sent.clone();
let engine_running = engine_running.clone();
Some(tokio::spawn(async move {
run_t2_emitter(conn, t2_slot, rate, interrupted, counter).await
}))
} else {
None
};
let t3_sent = Arc::new(AtomicU64::new(0));
let t3_timeouts = Arc::new(AtomicU64::new(0));
let t3_handle = if cli.t3_rate_hz > 0.0 {
let conn = client.conn.clone();
let rate = cli.t3_rate_hz;
let timeout = Duration::from_millis(cli.t3_timeout_ms);
let interrupted = interrupted.clone();
let sent_counter = t3_sent.clone();
let to_counter = t3_timeouts.clone();
Some(tokio::spawn(async move {
run_t3_emitter(
conn,
t3_slot,
rate,
timeout,
interrupted,
sent_counter,
to_counter,
)
.await
run_t2_emitter(conn, t2_slot, rate, interrupted, engine_running, counter).await
}))
} else {
None
@@ -280,11 +256,12 @@ async fn main() -> anyhow::Result<()> {
}
let slot_idx = (t1_sent as usize) % slots.len();
let running = engine_running.load(Ordering::Relaxed);
let slot = &mut slots[slot_idx];
let msg = QuicMessage {
device_id: slot.device_id,
sensor_id: slot.sensor_id,
raw_value: generate_value(slot.sensor_type, slot.seq),
raw_value: generate_value(slot.sensor_type, slot.seq, running),
timestamp_us: now_us(),
sequence_number: slot.seq,
sensor_type: slot.sensor_type.as_u8(),
@@ -303,18 +280,18 @@ async fn main() -> anyhow::Result<()> {
let t1_hz = (t1_sent as f64) / elapsed.max(1e-9);
let t2_now = t2_sent.load(Ordering::Relaxed);
let t2_hz = (t2_now as f64) / elapsed.max(1e-9);
let t3_now = t3_sent.load(Ordering::Relaxed);
let t3_hz = (t3_now as f64) / elapsed.max(1e-9);
let t3_to = t3_timeouts.load(Ordering::Relaxed);
let engine_state = if engine_running.load(Ordering::Relaxed) {
"running"
} else {
"stopped"
};
tracing::info!(
t1_sent,
t2_sent = t2_now,
t3_sent = t3_now,
t3_timeouts = t3_to,
send_errors,
t1_hz = format_args!("{:.1}", t1_hz),
t2_hz = format_args!("{:.1}", t2_hz),
t3_hz = format_args!("{:.1}", t3_hz),
engine = engine_state,
"progress"
);
last_progress = now;
@@ -334,28 +311,17 @@ async fn main() -> anyhow::Result<()> {
}),
None => 0,
};
let (t3_total, t3_timeouts_total): (u64, u64) = match t3_handle {
Some(h) => h.await.unwrap_or_else(|e| {
tracing::warn!(error = %e, "T3 emitter task ended unexpectedly");
(0, 0)
}),
None => (0, 0),
};
let elapsed = started.elapsed().as_secs_f64();
let t1_hz = (t1_sent as f64) / elapsed.max(1e-9);
let t2_hz = (t2_total as f64) / elapsed.max(1e-9);
let t3_hz = (t3_total as f64) / elapsed.max(1e-9);
tracing::info!(
t1_sent,
t2_sent = t2_total,
t3_sent = t3_total,
t3_timeouts = t3_timeouts_total,
send_errors,
elapsed_s = format_args!("{:.3}", elapsed),
t1_observed_hz = format_args!("{:.1}", t1_hz),
t2_observed_hz = format_args!("{:.1}", t2_hz),
t3_observed_hz = format_args!("{:.1}", t3_hz),
"simulator done"
);

View File

@@ -77,16 +77,30 @@ pub fn build_slots(
/// render. `seq` is the sample index — multiplying by 0.05 gives a
/// "seconds-like" wall-clock pacing inside the trig functions regardless of
/// the actual send rate, so panels animate over the same visible period.
pub fn generate_value(t: SensorType, seq: u32) -> f64 {
///
/// `engine_running` couples Voltage/Current to the simulated machine state.
/// When the substrate's `automation_system` sends a Relay=stop command, the
/// receiver flips the flag and the next current sample drops to ~0 A while
/// Voltage stays on mains — the dashboard sees the engine spin down within
/// one ECS tick.
pub fn generate_value(t: SensorType, seq: u32, engine_running: bool) -> f64 {
let t_phase = (seq as f64) * 0.05;
match t {
SensorType::Temperature => 20.0 + 5.0 * (t_phase / 10.0).sin(),
SensorType::Humidity => 50.0 + 20.0 * (t_phase / 15.0).sin(),
SensorType::Pressure => 1013.0 + 5.0 * (t_phase / 20.0).cos(),
// Voltage is the mains: stable at ~230 V regardless of motor state.
SensorType::Voltage => 230.0 + 0.5 * (t_phase / 3.0).sin(),
SensorType::Current => 10.0 + 2.0 * (t_phase / 5.0).cos(),
// Current reflects motor draw: ~10 A running, ~0 A stopped.
SensorType::Current => {
if engine_running {
10.0 + 2.0 * (t_phase / 5.0).cos()
} else {
0.05 + 0.05 * (t_phase / 5.0).cos().abs()
}
}
SensorType::Presence => 2.0 + 1.5 * (t_phase / 5.0).sin(), // Drops below 1.0 occasionally
SensorType::Relay => 0.0, // Relay always sends 0.0 as its command (a pure read request)
SensorType::Relay => 0.0, // Outbound is substrate-initiated; this is unused on the simulator side.
SensorType::Generic => t_phase.sin(),
}
}