First test kinda working

This commit is contained in:
Valère Plantevin
2026-05-12 11:21:40 -04:00
parent cac6c9ac02
commit d3f09ee062
36 changed files with 3903 additions and 102 deletions

189
simulator/src/client.rs Normal file
View 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
View 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
View 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();
}

View File

@@ -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
View 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(),
}
}