Flip T3 to substrate-initiated actuator commands
This commit is contained in:
@@ -13,9 +13,10 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use metrics::{counter, gauge, histogram};
|
||||
use tokio::sync::mpsc::error::TrySendError;
|
||||
|
||||
use crate::transport::ecs::{BridgeReceivers, BridgeSenders};
|
||||
use crate::transport::{QuicMessage, SensorType};
|
||||
use crate::transport::{OutboundT3, QuicMessage, SensorType};
|
||||
|
||||
use super::components::{
|
||||
Asset, DeviceId, RawSensorData, SensorId, SensorTypeTag, SmoothedValue, threshold_for,
|
||||
@@ -26,12 +27,11 @@ use super::resources::{DiagnosticsState, ExportSampleState, SensorRegistry};
|
||||
/// either drains next tick or gets dropped on full (T1's contract is lossy).
|
||||
const T1_INGEST_BATCH: usize = 1024;
|
||||
const T2_INGEST_BATCH: usize = 512;
|
||||
const T3_INGEST_BATCH: usize = 256;
|
||||
|
||||
/// Drain the three tier channels into ECS state.
|
||||
///
|
||||
/// T1: bounded batch (lossy); T2: full drain (reliable); T3: full drain, with
|
||||
/// each command answered by an ack carrying the device's current sensor value.
|
||||
/// Drain the two inbound tier channels (T1 datagrams, T2 uni streams) into
|
||||
/// ECS state. T1 is bounded-batch and lossy; T2 is fully drained per tick.
|
||||
/// T3 is *outbound* (substrate → device, actuator commands) and lives in
|
||||
/// the tokio runtime — see `transport::server::drain_outbound_t3`.
|
||||
pub(super) fn ingest_system(
|
||||
bridge: Res<BridgeReceivers>,
|
||||
mut registry: ResMut<SensorRegistry>,
|
||||
@@ -69,39 +69,6 @@ pub(super) fn ingest_system(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T3 — bidirectional commands. Reply with the device's most recent
|
||||
// sensor value (NaN if we've never seen this (device, sensor) before).
|
||||
{
|
||||
let mut t3 = bridge.t3.lock().unwrap();
|
||||
for _ in 0..T3_INGEST_BATCH {
|
||||
match t3.try_recv() {
|
||||
Ok(inbound) => {
|
||||
histogram!("substrate_latency_us", "tier" => "t3")
|
||||
.record(now.saturating_sub(inbound.command.timestamp_us) as f64);
|
||||
let key = (inbound.command.device_id, inbound.command.sensor_id);
|
||||
let current_value = registry
|
||||
.map
|
||||
.get(&key)
|
||||
.and_then(|&e| q.get(e).ok())
|
||||
.map(|d| d.raw_value)
|
||||
.unwrap_or(f64::NAN);
|
||||
let ack = QuicMessage {
|
||||
device_id: inbound.command.device_id,
|
||||
sensor_id: inbound.command.sensor_id,
|
||||
raw_value: current_value,
|
||||
timestamp_us: now_us(),
|
||||
sequence_number: inbound.command.sequence_number,
|
||||
sensor_type: inbound.command.sensor_type,
|
||||
};
|
||||
// Ignore send errors: the demux task may have given up if the
|
||||
// connection died while we were processing.
|
||||
let _ = inbound.reply.send(ack);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_reading(
|
||||
@@ -144,8 +111,17 @@ fn upsert_reading(
|
||||
registry.map.insert(key, entity);
|
||||
}
|
||||
|
||||
/// Closed-loop automation triggered by T1/T2 sensor data, affecting a T3 actuator.
|
||||
/// Closed-loop automation: Presence threshold crossings trigger a T3 actuator
|
||||
/// command going *out* to the originating device (substrate → simulator), and
|
||||
/// a parallel local Relay-entity update so the operator dashboard reflects the
|
||||
/// dispatched setpoint immediately (Grafana panels read the local ECS state).
|
||||
///
|
||||
/// The Relay actuator id is fixed at `6` in the industrial profile — see
|
||||
/// `simulator/src/profile.rs::build_slots`.
|
||||
const RELAY_SENSOR_ID: u16 = 6;
|
||||
|
||||
pub(super) fn automation_system(
|
||||
senders: Res<BridgeSenders>,
|
||||
mut registry: ResMut<SensorRegistry>,
|
||||
mut commands: Commands,
|
||||
mut p: ParamSet<(
|
||||
@@ -156,7 +132,8 @@ pub(super) fn automation_system(
|
||||
let mut triggers = Vec::new();
|
||||
for (dev_id, tag, data) in p.p0().iter() {
|
||||
if tag.0 == SensorType::Presence {
|
||||
// Trigger threshold: 1.0 seconds
|
||||
// Presence > 1.0 s ⇒ no occupancy detected ⇒ motor may run (relay 0).
|
||||
// Presence < 1.0 s ⇒ occupancy detected ⇒ stop motor (relay 1).
|
||||
let relay_state = if data.raw_value < 1.0 { 1.0 } else { 0.0 };
|
||||
triggers.push((dev_id.0, relay_state));
|
||||
}
|
||||
@@ -164,15 +141,36 @@ pub(super) fn automation_system(
|
||||
|
||||
let mut q = p.p1();
|
||||
for (device_id, relay_state) in triggers {
|
||||
let msg = QuicMessage {
|
||||
// 1) Dispatch the real actuator command to the device over T3.
|
||||
let cmd = OutboundT3 {
|
||||
target_device: device_id,
|
||||
sensor_id: RELAY_SENSOR_ID,
|
||||
raw_value: relay_state,
|
||||
sensor_type: SensorType::Relay.as_u8(),
|
||||
};
|
||||
match senders.t3_out.try_send(cmd) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(_)) => {
|
||||
counter!("substrate_t3_outbound_dropped_total").increment(1);
|
||||
tracing::warn!(device = %device_id, "outbound T3 channel full; setpoint dropped");
|
||||
}
|
||||
Err(TrySendError::Closed(_)) => {
|
||||
// Drain task is gone — substrate shutting down. Quiet log.
|
||||
tracing::debug!("outbound T3 channel closed");
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Mirror the setpoint into the local Relay entity so the dashboard
|
||||
// sees automation activity without waiting for the device ack.
|
||||
let mirror = QuicMessage {
|
||||
device_id,
|
||||
sensor_id: 6, // Relay is always 6 in our industrial profile
|
||||
sensor_id: RELAY_SENSOR_ID,
|
||||
raw_value: relay_state,
|
||||
timestamp_us: now_us(),
|
||||
sequence_number: 0,
|
||||
sensor_type: SensorType::Relay.as_u8(),
|
||||
};
|
||||
upsert_reading(&mut registry, &mut commands, &mut q, msg);
|
||||
upsert_reading(&mut registry, &mut commands, &mut q, mirror);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,11 +220,11 @@ pub(super) fn export_system(
|
||||
|
||||
gauge!("substrate_channel_depth", "tier" => "t1").set(senders.t1.depth() as f64);
|
||||
gauge!("substrate_channel_depth", "tier" => "t2").set(senders.t2.depth() as f64);
|
||||
gauge!("substrate_channel_depth", "tier" => "t3").set(senders.t3.depth() as f64);
|
||||
gauge!("substrate_channel_depth", "tier" => "t3").set(senders.t3_out.depth() as f64);
|
||||
|
||||
gauge!("substrate_channel_capacity", "tier" => "t1").set(senders.t1.capacity() as f64);
|
||||
gauge!("substrate_channel_capacity", "tier" => "t2").set(senders.t2.capacity() as f64);
|
||||
gauge!("substrate_channel_capacity", "tier" => "t3").set(senders.t3.capacity() as f64);
|
||||
gauge!("substrate_channel_capacity", "tier" => "t3").set(senders.t3_out.capacity() as f64);
|
||||
|
||||
if let Some(stats) = memory_stats::memory_stats() {
|
||||
gauge!("substrate_rss_bytes").set(stats.physical_mem as f64);
|
||||
|
||||
@@ -8,12 +8,12 @@ use std::sync::Mutex;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::state::app::StatesPlugin;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::transport::ecs::{BridgeReceivers, BridgeSenders};
|
||||
use crate::transport::state::ServerState;
|
||||
use crate::transport::{QuicMessage, SensorType, T1Sender, T2Sender, T3Inbound, T3Sender};
|
||||
use crate::transport::{OutboundT3, QuicMessage, SensorType, T1Sender, T2Sender, T3OutboundSender};
|
||||
|
||||
use super::WorldPlugin;
|
||||
use super::components::{RawSensorData, SMOOTHED_WINDOW, SmoothedValue, threshold_for};
|
||||
@@ -21,20 +21,22 @@ use super::resources::SensorRegistry;
|
||||
|
||||
/// Build a Bevy app with just enough plugins/resources to run the world
|
||||
/// systems against test-owned channels. No QUIC, no tokio runtime.
|
||||
///
|
||||
/// Returns the app plus the T1/T2 send halves and the outbound-T3 receive
|
||||
/// half — the latter so tests can observe `automation_system` dispatching.
|
||||
fn make_test_app() -> (
|
||||
App,
|
||||
mpsc::Sender<QuicMessage>,
|
||||
mpsc::Sender<QuicMessage>,
|
||||
mpsc::Sender<T3Inbound>,
|
||||
mpsc::Receiver<OutboundT3>,
|
||||
) {
|
||||
let (t1_tx, t1_rx) = mpsc::channel::<QuicMessage>(64);
|
||||
let (t2_tx, t2_rx) = mpsc::channel::<QuicMessage>(64);
|
||||
let (t3_tx, t3_rx) = mpsc::channel::<T3Inbound>(64);
|
||||
let (t3_out_tx, t3_out_rx) = mpsc::channel::<OutboundT3>(64);
|
||||
|
||||
let bridge = BridgeReceivers {
|
||||
t1: Mutex::new(t1_rx),
|
||||
t2: Mutex::new(t2_rx),
|
||||
t3: Mutex::new(t3_rx),
|
||||
};
|
||||
// export_system samples channel depth/capacity from the senders; it
|
||||
// requires the resource even when the test pushes via the raw senders
|
||||
@@ -42,7 +44,7 @@ fn make_test_app() -> (
|
||||
let senders = BridgeSenders {
|
||||
t1: T1Sender::new(t1_tx.clone()),
|
||||
t2: T2Sender::new(t2_tx.clone()),
|
||||
t3: T3Sender::new(t3_tx.clone()),
|
||||
t3_out: T3OutboundSender::new(t3_out_tx),
|
||||
};
|
||||
|
||||
let mut app = App::new();
|
||||
@@ -60,14 +62,14 @@ fn make_test_app() -> (
|
||||
// Process the state transition before tests push messages.
|
||||
app.update();
|
||||
|
||||
(app, t1_tx, t2_tx, t3_tx)
|
||||
(app, t1_tx, t2_tx, t3_out_rx)
|
||||
}
|
||||
|
||||
// ---- ingest_system: entity lifecycle and T3 ack semantics ----
|
||||
// ---- ingest_system: entity lifecycle ----
|
||||
|
||||
#[test]
|
||||
fn ingest_t1_creates_entity_and_writes_raw_data() {
|
||||
let (mut app, t1_tx, _t2_tx, _t3_tx) = make_test_app();
|
||||
let (mut app, t1_tx, _t2_tx, _t3_out_rx) = make_test_app();
|
||||
|
||||
let device = Uuid::from_u128(0xa1a2_a3a4_a5a6_a7a8_a9aa_abac_adae_afb0);
|
||||
let msg = QuicMessage {
|
||||
@@ -103,7 +105,7 @@ fn ingest_t1_creates_entity_and_writes_raw_data() {
|
||||
|
||||
#[test]
|
||||
fn ingest_t1_repeated_messages_update_in_place() {
|
||||
let (mut app, t1_tx, _t2_tx, _t3_tx) = make_test_app();
|
||||
let (mut app, t1_tx, _t2_tx, _t3_out_rx) = make_test_app();
|
||||
let device = Uuid::new_v4();
|
||||
|
||||
// First reading.
|
||||
@@ -143,54 +145,46 @@ fn ingest_t1_repeated_messages_update_in_place() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_t3_replies_with_current_sensor_value() {
|
||||
let (mut app, t1_tx, _t2_tx, t3_tx) = make_test_app();
|
||||
fn automation_dispatches_relay_stop_when_presence_drops() {
|
||||
// The automation_system runs after simulation_system, which only emits a
|
||||
// crossing when the *smoothed* mean transitions; for this test we just
|
||||
// confirm that a Presence reading below threshold ends up enqueued as an
|
||||
// OutboundT3 Relay=stop command. Repeated below-threshold pushes prime
|
||||
// the rolling mean.
|
||||
let (mut app, t1_tx, _t2_tx, mut t3_out_rx) = make_test_app();
|
||||
let device = Uuid::new_v4();
|
||||
|
||||
// Seed a T1 reading so the (device, sensor) entity exists.
|
||||
t1_tx
|
||||
.try_send(QuicMessage {
|
||||
device_id: device,
|
||||
sensor_id: 9,
|
||||
raw_value: 42.0,
|
||||
timestamp_us: 1,
|
||||
sequence_number: 1,
|
||||
sensor_type: SensorType::Temperature.as_u8(),
|
||||
})
|
||||
.unwrap();
|
||||
app.update();
|
||||
app.update();
|
||||
|
||||
// Send a T3 command and capture the ack via the oneshot.
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
t3_tx
|
||||
.try_send(T3Inbound {
|
||||
command: QuicMessage {
|
||||
for seq in 0..SMOOTHED_WINDOW as u32 {
|
||||
t1_tx
|
||||
.try_send(QuicMessage {
|
||||
device_id: device,
|
||||
sensor_id: 9,
|
||||
raw_value: 0.0,
|
||||
timestamp_us: 0,
|
||||
sequence_number: 7,
|
||||
sensor_type: SensorType::Temperature.as_u8(),
|
||||
},
|
||||
reply: reply_tx,
|
||||
})
|
||||
.unwrap();
|
||||
app.update();
|
||||
sensor_id: 5,
|
||||
raw_value: 0.5, // below the 1.0 s threshold
|
||||
timestamp_us: u64::from(seq),
|
||||
sequence_number: seq,
|
||||
sensor_type: SensorType::Presence.as_u8(),
|
||||
})
|
||||
.unwrap();
|
||||
app.update();
|
||||
app.update();
|
||||
}
|
||||
|
||||
let ack = reply_rx
|
||||
.blocking_recv()
|
||||
.expect("ECS handler should have replied");
|
||||
assert_eq!(ack.device_id, device);
|
||||
assert_eq!(ack.sensor_id, 9);
|
||||
assert_eq!(ack.sequence_number, 7, "ack preserves correlation id");
|
||||
assert_eq!(ack.raw_value, 42.0, "ack carries the latest sensor reading");
|
||||
assert_eq!(
|
||||
ack.typ(),
|
||||
SensorType::Temperature,
|
||||
"ack preserves sensor type"
|
||||
// Drain whatever automation dispatched. We expect at least one Relay=stop
|
||||
// command targeting the device.
|
||||
let mut saw_stop = false;
|
||||
while let Ok(cmd) = t3_out_rx.try_recv() {
|
||||
if cmd.target_device == device
|
||||
&& cmd.sensor_type == SensorType::Relay.as_u8()
|
||||
&& cmd.raw_value > 0.5
|
||||
{
|
||||
saw_stop = true;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_stop,
|
||||
"automation_system should have enqueued an outbound Relay=stop \
|
||||
command for {device} after sustained sub-threshold Presence readings"
|
||||
);
|
||||
assert!(ack.timestamp_us > 0, "ack stamped with server time");
|
||||
}
|
||||
|
||||
// ---- SmoothedValue unit tests ----
|
||||
@@ -240,7 +234,7 @@ fn smoothed_value_ignores_nonfinite() {
|
||||
|
||||
#[test]
|
||||
fn simulation_smoothes_and_detects_threshold_crossing() {
|
||||
let (mut app, t1_tx, _t2_tx, _t3_tx) = make_test_app();
|
||||
let (mut app, t1_tx, _t2_tx, _t3_out_rx) = make_test_app();
|
||||
let device = Uuid::new_v4();
|
||||
let threshold = threshold_for(SensorType::Temperature); // 22.0 °C
|
||||
|
||||
|
||||
Reference in New Issue
Block a user