First test kinda working
This commit is contained in:
189
simulator/src/client.rs
Normal file
189
simulator/src/client.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use quinn::{ClientConfig, Connection, Endpoint};
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use rustls::{DigitallySignedStruct, SignatureScheme};
|
||||
use substrate::transport::QuicMessage;
|
||||
|
||||
/// QUIC client for driving the substrate from tests, smoke runs, and
|
||||
/// (eventually) the full Bevy-driven sensor generator.
|
||||
///
|
||||
/// `connect` trusts the server's PEM cert by **exact byte match** — using a
|
||||
/// custom `ServerCertVerifier` that compares the leaf against the cert at
|
||||
/// `cert_path`. This sidesteps rustls' `CaUsedAsEndEntity` rejection of our
|
||||
/// self-signed cert (which acts as both trust anchor and leaf) without
|
||||
/// disabling signature verification or weakening the handshake.
|
||||
pub struct SimulatorClient {
|
||||
pub endpoint: Endpoint,
|
||||
pub conn: Connection,
|
||||
}
|
||||
|
||||
impl SimulatorClient {
|
||||
pub async fn connect(
|
||||
server_addr: SocketAddr,
|
||||
server_name: &str,
|
||||
cert_path: impl AsRef<Path>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let cert_path = cert_path.as_ref();
|
||||
let cert_pem = std::fs::read(cert_path)
|
||||
.with_context(|| format!("read trust cert at {}", cert_path.display()))?;
|
||||
|
||||
let parsed: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut cert_pem.as_slice())
|
||||
.collect::<Result<_, _>>()
|
||||
.with_context(|| format!("parse PEM certs at {}", cert_path.display()))?;
|
||||
let expected = parsed
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("no certificates found in {}", cert_path.display()))?;
|
||||
|
||||
// Reuse the process-wide rustls provider that `install_crypto_provider`
|
||||
// (or substrate's main) already installed. Failing to find one here
|
||||
// means nobody installed a default — caller error.
|
||||
let provider = rustls::crypto::CryptoProvider::get_default()
|
||||
.ok_or_else(|| anyhow!("no rustls default crypto provider installed"))?
|
||||
.clone();
|
||||
|
||||
let verifier = Arc::new(TrustExactCert {
|
||||
expected,
|
||||
provider: provider.clone(),
|
||||
});
|
||||
|
||||
let rustls_cfg = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.context("rustls client builder")?
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(verifier)
|
||||
.with_no_client_auth();
|
||||
|
||||
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
|
||||
.context("wrap rustls config for QUIC")?;
|
||||
let client_cfg = ClientConfig::new(Arc::new(quic_cfg));
|
||||
|
||||
let bind: SocketAddr = if server_addr.is_ipv6() {
|
||||
"[::]:0".parse().unwrap()
|
||||
} else {
|
||||
"0.0.0.0:0".parse().unwrap()
|
||||
};
|
||||
let mut endpoint = Endpoint::client(bind).context("Endpoint::client bind")?;
|
||||
endpoint.set_default_client_config(client_cfg);
|
||||
|
||||
let connecting = endpoint
|
||||
.connect(server_addr, server_name)
|
||||
.with_context(|| format!("client connect to {server_addr} as {server_name}"))?;
|
||||
let conn = connecting.await.context("client TLS handshake")?;
|
||||
|
||||
tracing::info!(remote = %conn.remote_address(), "simulator client connected");
|
||||
Ok(Self { endpoint, conn })
|
||||
}
|
||||
|
||||
/// T1 — send one `QuicMessage` over a QUIC datagram (38 B fixed).
|
||||
pub fn send_datagram(&self, msg: &QuicMessage) -> anyhow::Result<()> {
|
||||
let bytes = bytes::Bytes::copy_from_slice(&msg.to_bytes());
|
||||
self.conn.send_datagram(bytes).context("send_datagram")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// T2 — open a unidirectional stream, write each message as 38 B back-to-back,
|
||||
/// then `finish()` the stream. The substrate sees one or many events per
|
||||
/// stream, ordered within the stream.
|
||||
pub async fn send_uni_stream(&self, msgs: &[QuicMessage]) -> anyhow::Result<()> {
|
||||
let mut send = self.conn.open_uni().await.context("open_uni")?;
|
||||
for msg in msgs {
|
||||
send.write_all(&msg.to_bytes())
|
||||
.await
|
||||
.context("write QuicMessage to uni stream")?;
|
||||
}
|
||||
send.finish().context("finish uni stream")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// T3 — open a bidirectional stream, write the command (38 B), finish the
|
||||
/// send half, then read the substrate's ack (38 B). Errors if the
|
||||
/// substrate resets the stream (e.g. no handler installed yet) or if the
|
||||
/// connection drops mid-exchange.
|
||||
pub async fn request(&self, command: &QuicMessage) -> anyhow::Result<QuicMessage> {
|
||||
let (mut send, mut recv) = self.conn.open_bi().await.context("open_bi")?;
|
||||
send.write_all(&command.to_bytes())
|
||||
.await
|
||||
.context("write T3 command")?;
|
||||
send.finish().context("finish T3 send half")?;
|
||||
|
||||
let mut buf = [0u8; QuicMessage::WIRE_SIZE];
|
||||
recv.read_exact(&mut buf)
|
||||
.await
|
||||
.context("read T3 ack")?;
|
||||
let ack = QuicMessage::decode(&buf).context("decode T3 ack")?;
|
||||
Ok(ack)
|
||||
}
|
||||
|
||||
/// Close the connection gracefully. Use before dropping in tests so the
|
||||
/// peer's `conn.closed()` resolves cleanly instead of via timeout.
|
||||
pub async fn close(&self) {
|
||||
self.conn.close(0u32.into(), b"client done");
|
||||
self.endpoint.wait_idle().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerCertVerifier` that accepts exactly one specific cert by byte
|
||||
/// equality. Signature verification still runs through the default provider —
|
||||
/// only the chain-validity check is replaced.
|
||||
#[derive(Debug)]
|
||||
struct TrustExactCert {
|
||||
expected: CertificateDer<'static>,
|
||||
provider: Arc<rustls::crypto::CryptoProvider>,
|
||||
}
|
||||
|
||||
impl ServerCertVerifier for TrustExactCert {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
if end_entity.as_ref() == self.expected.as_ref() {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
} else {
|
||||
Err(rustls::Error::General(
|
||||
"server cert does not match trusted dev cert".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
self.provider.signature_verification_algorithms.supported_schemes()
|
||||
}
|
||||
}
|
||||
147
simulator/src/emitters.rs
Normal file
147
simulator/src/emitters.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
//! Async emitter tasks for T2 (uni streams) and T3 (bi streams + ack).
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
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;
|
||||
use tokio::time::MissedTickBehavior;
|
||||
|
||||
use crate::profile::{SensorSlot, generate_value};
|
||||
|
||||
/// UNIX-epoch microseconds — the wall-clock timestamp the simulator stamps
|
||||
/// into every outgoing `QuicMessage`. Substrate-side latency is computed as
|
||||
/// `substrate_now_us - msg.timestamp_us`, so this needs to be a real wall
|
||||
/// clock both ends share (NTP for two-machine; loopback otherwise).
|
||||
pub fn now_us() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_micros() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// T2 emitter — opens a fresh uni stream per event, writes one
|
||||
/// `QuicMessage`, and `finish`es. Returns the count of events successfully
|
||||
/// delivered when `interrupted` is raised.
|
||||
pub async fn run_t2_emitter(
|
||||
conn: quinn::Connection,
|
||||
mut slot: SensorSlot,
|
||||
rate_hz: f64,
|
||||
interrupted: Arc<AtomicBool>,
|
||||
counter: Arc<AtomicU64>,
|
||||
) -> 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::Delay);
|
||||
let mut sent: u64 = 0;
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if interrupted.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
let msg = 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 conn.open_uni().await {
|
||||
Ok(mut send) => {
|
||||
if let Err(e) = send.write_all(&msg.to_bytes()).await {
|
||||
tracing::warn!(error = %e, "T2 write_all failed");
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = send.finish() {
|
||||
tracing::warn!(error = %e, "T2 finish failed");
|
||||
continue;
|
||||
}
|
||||
sent += 1;
|
||||
counter.store(sent, Ordering::Relaxed);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "T2 open_uni failed; emitter exiting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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::Delay);
|
||||
let mut sent: u64 = 0;
|
||||
let mut timeouts: u64 = 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);
|
||||
}
|
||||
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")
|
||||
}
|
||||
12
simulator/src/lib.rs
Normal file
12
simulator/src/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
pub mod client;
|
||||
pub mod emitters;
|
||||
pub mod profile;
|
||||
|
||||
/// Install rustls' default crypto provider. Idempotent: safe to call from
|
||||
/// every test, every binary entry, and the substrate process. The `aws_lc_rs`
|
||||
/// provider matches what the substrate installs in `main.rs`.
|
||||
pub fn install_crypto_provider() {
|
||||
// Returns Err if a provider is already installed; that's the expected
|
||||
// case in any process that's already booted substrate or a sibling test.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
}
|
||||
@@ -1,3 +1,320 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
//! Manual smoke runner / load driver for the substrate.
|
||||
//!
|
||||
//! Parses the CLI, builds the per-device sensor layout, then drives T1
|
||||
//! datagrams in the foreground while T2 and T3 emitters run as background
|
||||
//! tokio tasks. Helpers live in the simulator library:
|
||||
//!
|
||||
//! - `simulator::profile` — `SensorProfile`, `SensorSlot`, waveform generator
|
||||
//! - `simulator::emitters` — `run_t2_emitter`, `run_t3_emitter`, `now_us`
|
||||
//! - `simulator::client` — Quinn client + TLS trust-by-cert verifier
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
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::profile::{SensorProfile, build_slots, generate_value};
|
||||
use substrate::transport::{QuicMessage, SensorType};
|
||||
use tokio::time::MissedTickBehavior;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "simulator", about, long_about = None)]
|
||||
struct Cli {
|
||||
/// Substrate address (host:port).
|
||||
#[arg(long, default_value = "127.0.0.1:9000")]
|
||||
addr: SocketAddr,
|
||||
|
||||
/// SNI name presented during the TLS handshake.
|
||||
#[arg(long, default_value = "localhost")]
|
||||
server_name: String,
|
||||
|
||||
/// Path to the substrate's PEM cert; used as the exact-match trust anchor.
|
||||
#[arg(long, default_value = "certs/server.crt")]
|
||||
cert: PathBuf,
|
||||
|
||||
/// Sensor mix per device.
|
||||
///
|
||||
/// - `single` (default): one sensor per device of `--sensor-type`, on
|
||||
/// `--sensor-id`. Lowest-cardinality, easiest to reason about.
|
||||
/// - `industrial`: five sensors per device on ids 0..4 — Temperature,
|
||||
/// Humidity, Pressure, Voltage, Current. Lights up every dashboard
|
||||
/// panel.
|
||||
#[arg(long, value_enum, default_value_t = SensorProfile::Single)]
|
||||
profile: SensorProfile,
|
||||
|
||||
/// Sensor type for the `single` profile. Ignored by `industrial`.
|
||||
#[arg(long, value_enum, default_value_t = CliSensorType::Generic)]
|
||||
sensor_type: CliSensorType,
|
||||
|
||||
/// T1 datagram rate across all (device, sensor) slots (Hz). `0` disables T1.
|
||||
#[arg(long, default_value_t = 20.0)]
|
||||
rate_hz: f64,
|
||||
|
||||
/// T2 uni-stream event rate (Hz). `0` disables T2 (default).
|
||||
#[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,
|
||||
|
||||
/// Number of distinct device UUIDs to round-robin.
|
||||
#[arg(long, default_value_t = 1)]
|
||||
devices: u32,
|
||||
|
||||
/// Sensor index for the `single` profile. Ignored by `industrial`.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
sensor_id: u16,
|
||||
}
|
||||
|
||||
#[derive(ValueEnum, Clone, Copy, Debug, Default)]
|
||||
enum CliSensorType {
|
||||
#[default]
|
||||
Generic,
|
||||
Temperature,
|
||||
Humidity,
|
||||
Pressure,
|
||||
Voltage,
|
||||
Current,
|
||||
}
|
||||
|
||||
impl From<CliSensorType> for SensorType {
|
||||
fn from(c: CliSensorType) -> Self {
|
||||
match c {
|
||||
CliSensorType::Generic => SensorType::Generic,
|
||||
CliSensorType::Temperature => SensorType::Temperature,
|
||||
CliSensorType::Humidity => SensorType::Humidity,
|
||||
CliSensorType::Pressure => SensorType::Pressure,
|
||||
CliSensorType::Voltage => SensorType::Voltage,
|
||||
CliSensorType::Current => SensorType::Current,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(cli: &Cli) -> anyhow::Result<()> {
|
||||
if cli.rate_hz < 0.0 {
|
||||
return Err(anyhow!("--rate-hz must be >= 0"));
|
||||
}
|
||||
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 {
|
||||
return Err(anyhow!(
|
||||
"at least one of --rate-hz / --t2-rate-hz / --t3-rate-hz must be > 0"
|
||||
));
|
||||
}
|
||||
if cli.devices == 0 {
|
||||
return Err(anyhow!("--devices must be >= 1"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
validate(&cli)?;
|
||||
|
||||
simulator::install_crypto_provider();
|
||||
|
||||
let mut slots = build_slots(
|
||||
cli.profile,
|
||||
cli.devices,
|
||||
cli.sensor_type.into(),
|
||||
cli.sensor_id,
|
||||
);
|
||||
|
||||
tracing::info!(
|
||||
?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(),
|
||||
profile = ?cli.profile,
|
||||
"simulator launching"
|
||||
);
|
||||
|
||||
let client = SimulatorClient::connect(cli.addr, &cli.server_name, &cli.cert)
|
||||
.await
|
||||
.context("connect to substrate")?;
|
||||
|
||||
let interrupted = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let flag = interrupted.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!("Ctrl-C received, draining…");
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
// T2 / T3 emitters target slot[0] for their device/sensor identity.
|
||||
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 {
|
||||
let conn = client.conn.clone();
|
||||
let rate = cli.t2_rate_hz;
|
||||
let interrupted = interrupted.clone();
|
||||
let counter = t2_sent.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
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let started = Instant::now();
|
||||
let mut t1_sent: u64 = 0;
|
||||
let mut send_errors: u64 = 0;
|
||||
|
||||
if cli.rate_hz > 0.0 {
|
||||
let period = Duration::from_nanos((1.0e9 / cli.rate_hz) as u64);
|
||||
let mut ticker = tokio::time::interval(period);
|
||||
ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
||||
|
||||
let unlimited = cli.count == 0;
|
||||
let mut last_progress = started;
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if interrupted.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
if !unlimited && t1_sent >= cli.count {
|
||||
break;
|
||||
}
|
||||
|
||||
let slot_idx = (t1_sent as usize) % slots.len();
|
||||
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),
|
||||
timestamp_us: now_us(),
|
||||
sequence_number: slot.seq,
|
||||
sensor_type: slot.sensor_type.as_u8(),
|
||||
};
|
||||
slot.seq = slot.seq.wrapping_add(1);
|
||||
t1_sent += 1;
|
||||
|
||||
if let Err(e) = client.send_datagram(&msg) {
|
||||
send_errors += 1;
|
||||
tracing::warn!(error = %e, "send_datagram failed");
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
if now.duration_since(last_progress) >= Duration::from_secs(1) {
|
||||
let elapsed = now.duration_since(started).as_secs_f64();
|
||||
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);
|
||||
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),
|
||||
"progress"
|
||||
);
|
||||
last_progress = now;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
while !interrupted.load(Ordering::SeqCst) {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
interrupted.store(true, Ordering::SeqCst);
|
||||
let t2_total: u64 = match t2_handle {
|
||||
Some(h) => h.await.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "T2 emitter task ended unexpectedly");
|
||||
0
|
||||
}),
|
||||
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"
|
||||
);
|
||||
|
||||
client.close().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
88
simulator/src/profile.rs
Normal file
88
simulator/src/profile.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
//! Per-device sensor layout (the `--profile` CLI flag's runtime form) and the
|
||||
//! type-appropriate waveform generators that feed the substrate's Grafana
|
||||
//! dashboard with believable numbers.
|
||||
|
||||
use clap::ValueEnum;
|
||||
use substrate::transport::SensorType;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Per-device sensor layout selected by the `--profile` CLI flag.
|
||||
///
|
||||
/// - `Single`: one sensor per device of a chosen `SensorType`. Lowest
|
||||
/// cardinality; the right pick for throughput / latency benchmarks.
|
||||
/// - `Industrial`: five sensors per device on ids 0..4 — Temperature,
|
||||
/// Humidity, Pressure, Voltage, Current. Lights up every sensor-type
|
||||
/// panel in the operator dashboard.
|
||||
#[derive(ValueEnum, Clone, Copy, Debug)]
|
||||
pub enum SensorProfile {
|
||||
Single,
|
||||
Industrial,
|
||||
}
|
||||
|
||||
/// A single emitter slot: the `(device, sensor, type)` triple plus the
|
||||
/// per-slot monotonic sequence counter that the simulator advances on every
|
||||
/// outgoing message.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SensorSlot {
|
||||
pub device_id: Uuid,
|
||||
pub sensor_id: u16,
|
||||
pub sensor_type: SensorType,
|
||||
pub seq: u32,
|
||||
}
|
||||
|
||||
/// Expand a `(profile, num_devices)` choice into the flat list of slots
|
||||
/// the T1 emitter rotates through. Each device gets a fresh UUID.
|
||||
pub fn build_slots(
|
||||
profile: SensorProfile,
|
||||
num_devices: u32,
|
||||
default_type: SensorType,
|
||||
default_sensor_id: u16,
|
||||
) -> Vec<SensorSlot> {
|
||||
let mut slots = Vec::new();
|
||||
for _ in 0..num_devices {
|
||||
let device_id = Uuid::new_v4();
|
||||
match profile {
|
||||
SensorProfile::Single => {
|
||||
slots.push(SensorSlot {
|
||||
device_id,
|
||||
sensor_id: default_sensor_id,
|
||||
sensor_type: default_type,
|
||||
seq: 0,
|
||||
});
|
||||
}
|
||||
SensorProfile::Industrial => {
|
||||
for (sensor_id, sensor_type) in [
|
||||
(0u16, SensorType::Temperature),
|
||||
(1, SensorType::Humidity),
|
||||
(2, SensorType::Pressure),
|
||||
(3, SensorType::Voltage),
|
||||
(4, SensorType::Current),
|
||||
] {
|
||||
slots.push(SensorSlot {
|
||||
device_id,
|
||||
sensor_id,
|
||||
sensor_type,
|
||||
seq: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
slots
|
||||
}
|
||||
|
||||
/// Type-appropriate waveform so the dashboard has something believable to
|
||||
/// 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 {
|
||||
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(),
|
||||
SensorType::Voltage => 230.0 + 0.5 * (t_phase / 3.0).sin(),
|
||||
SensorType::Current => 10.0 + 2.0 * (t_phase / 5.0).cos(),
|
||||
SensorType::Generic => t_phase.sin(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user