plc_control/crates/app_feeder_distributor/src/control/simulate.rs

214 lines
6.8 KiB
Rust

use tokio::time::Duration;
use uuid::Uuid;
use crate::{
connection::{BatchSetPointValueReq, SetPointValueReqItem},
telemetry::{DataValue, PointMonitorInfo, PointQuality, ValueType},
websocket::WsMessage,
AppState,
};
/// Start the chaos simulation task (only when SIMULATE_PLC=true).
/// Randomly disrupts `rem` or `flt` signals on equipment to exercise the control engine.
pub fn start(state: AppState) {
tokio::spawn(async move {
run(state).await;
});
}
async fn run(state: AppState) {
let mut rng = seed_rng();
loop {
// Wait a random 15-60 s between events.
let wait_secs = 15 + xorshift(&mut rng) % 46;
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
// Pick a random enabled unit.
let units = match crate::service::get_all_enabled_units(&state.platform.pool).await {
Ok(u) if !u.is_empty() => u,
_ => continue,
};
let unit = &units[xorshift(&mut rng) as usize % units.len()];
// Only target units with auto control running; otherwise the event is uninteresting.
let runtime = state.control_runtime.get(unit.id).await;
if runtime.map_or(true, |r| !r.auto_enabled) {
continue;
}
// Pick a random equipment in that unit.
let equipments =
match crate::service::get_equipment_by_unit_id(&state.platform.pool, unit.id).await {
Ok(e) if !e.is_empty() => e,
_ => continue,
};
let eq = &equipments[xorshift(&mut rng) as usize % equipments.len()];
// Find which of rem / flt this equipment has.
let role_points =
match crate::service::get_equipment_role_points(&state.platform.pool, eq.id).await {
Ok(rp) if !rp.is_empty() => rp,
_ => continue,
};
let candidates: Vec<&str> = ["flt", "rem"]
.iter()
.filter(|&&r| role_points.iter().any(|p| p.signal_role == r))
.copied()
.collect();
if candidates.is_empty() {
continue;
}
let target_role = candidates[xorshift(&mut rng) as usize % candidates.len()];
let target_point = role_points
.iter()
.find(|p| p.signal_role == target_role)
.unwrap();
// rem=false means the equipment is not in remote mode.
// flt=true means the equipment reports an active fault.
let trigger_value = target_role == "flt";
// Hold duration: 5-15 s for rem, 3-10 s for flt.
let hold_secs = if target_role == "flt" {
3 + xorshift(&mut rng) % 8
} else {
5 + xorshift(&mut rng) % 11
};
tracing::info!(
"[chaos] unit={} eq={} role={} -> {} (hold {}s)",
unit.code,
eq.code,
target_role,
if trigger_value { "FAULT" } else { "REM OFF" },
hold_secs
);
patch_signal(&state, target_point.point_id, trigger_value).await;
patch_signal(&state, target_point.point_id, trigger_value).await;
tokio::time::sleep(Duration::from_secs(hold_secs)).await;
patch_signal(&state, target_point.point_id, !trigger_value).await;
tracing::info!(
"[chaos] unit={} eq={} role={} -> RESTORED",
unit.code,
eq.code,
target_role
);
}
}
/// Simulate RUN signal feedback for an equipment after a manual start/stop command.
/// Called by the engine and control handler when SIMULATE_PLC=true.
pub async fn simulate_run_feedback(state: &AppState, equipment_id: Uuid, run_on: bool) {
let role_points =
match crate::service::get_equipment_role_points(&state.platform.pool, equipment_id).await {
Ok(v) => v,
Err(e) => {
tracing::warn!("simulate_run_feedback: db error: {}", e);
return;
}
};
let run_point = match role_points.iter().find(|p| p.signal_role == "run") {
Some(p) => p.clone(),
None => return,
};
patch_signal(state, run_point.point_id, run_on).await;
}
/// Patch a signal point value: try OPC UA write first, fall back to cache patch + WS push.
pub async fn patch_signal(state: &AppState, point_id: Uuid, value_on: bool) {
let write_json = serde_json::json!(if value_on { 1 } else { 0 });
let write_ok = match state
.platform.connection_manager
.write_point_values_batch(BatchSetPointValueReq {
items: vec![SetPointValueReqItem {
point_id,
value: write_json,
}],
})
.await
{
Ok(res) => res.success,
Err(_) => false,
};
if write_ok {
return;
}
// Fallback: patch the monitor cache directly and broadcast over WS.
let (value, value_type, value_text) = {
let guard = state
.platform.connection_manager
.get_point_monitor_data_read_guard()
.await;
match guard.get(&point_id).and_then(|m| m.value_type.as_ref()) {
Some(ValueType::Int) => (
DataValue::Int(if value_on { 1 } else { 0 }),
Some(ValueType::Int),
Some(if value_on { "1" } else { "0" }.to_string()),
),
Some(ValueType::UInt) => (
DataValue::UInt(if value_on { 1 } else { 0 }),
Some(ValueType::UInt),
Some(if value_on { "1" } else { "0" }.to_string()),
),
_ => (
DataValue::Bool(value_on),
Some(ValueType::Bool),
Some(value_on.to_string()),
),
}
};
let monitor = PointMonitorInfo {
protocol: "simulation".to_string(),
source_id: Uuid::nil(),
point_id,
client_handle: 0,
scan_mode: plc_platform_core::model::ScanMode::Poll,
timestamp: Some(chrono::Utc::now()),
quality: PointQuality::Good,
value: Some(value),
value_type,
value_text,
old_value: None,
old_timestamp: None,
value_changed: true,
};
if let Err(e) = state
.platform.connection_manager
.update_point_monitor_data(monitor.clone())
.await
{
tracing::warn!("[chaos] cache update failed for {}: {}", point_id, e);
return;
}
let _ = state
.platform.ws_manager
.send_to_public(WsMessage::PointNewValue(monitor))
.await;
}
// Minimal XorShift64 PRNG (no external crate needed).
fn seed_rng() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64 ^ d.as_secs().wrapping_mul(0x9e37_79b9_7f4a_7c15))
.unwrap_or(0xdeadbeef)
}
fn xorshift(s: &mut u64) -> u64 {
*s ^= *s << 13;
*s ^= *s >> 7;
*s ^= *s << 17;
*s
}