Flip T3 to substrate-initiated actuator commands
This commit is contained in:
96
simulator/src/commands.rs
Normal file
96
simulator/src/commands.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod client;
|
||||
pub mod commands;
|
||||
pub mod emitters;
|
||||
pub mod profile;
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user