From 989a0286e9f0b746fd68c11b5a2507396312c6df Mon Sep 17 00:00:00 2001 From: caoqianming Date: Wed, 25 Mar 2026 11:08:38 +0800 Subject: [PATCH] feat(sim): simulate RUN signal feedback when SIMULATE_PLC=true After a successful start/stop command, write run=true/false directly into the point monitor cache and broadcast PointNewValue via WebSocket. Gated by SIMULATE_PLC=true env var; real OPC-UA values override it. Co-Authored-By: Claude Sonnet 4.6 --- src/config.rs | 8 ++++ src/handler/control.rs | 84 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/src/config.rs b/src/config.rs index 84da369..dfa38e4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,6 +6,9 @@ pub struct AppConfig { pub server_host: String, pub server_port: u16, pub write_api_key: Option, + /// When true, simulate RUN signal feedback after start/stop commands. + /// Set SIMULATE_PLC=true in .env for use with OPC UA proxy simulators. + pub simulate_plc: bool, } @@ -22,11 +25,16 @@ impl AppConfig { .ok() .or_else(|| env::var("WRITE_KEY").ok()); + let simulate_plc = env::var("SIMULATE_PLC") + .unwrap_or_default() + .to_lowercase() == "true"; + Ok(Self { database_url, server_host, server_port, write_api_key, + simulate_plc, }) } diff --git a/src/handler/control.rs b/src/handler/control.rs index 7963f12..32c7109 100644 --- a/src/handler/control.rs +++ b/src/handler/control.rs @@ -63,6 +63,86 @@ pub async fn stop_equipment( send_equipment_command(state, equipment_id, ControlAction::Stop).await } +async fn simulate_run_feedback(state: &AppState, equipment_id: Uuid, action: ControlAction) { + let role_points = + match crate::service::get_equipment_role_points(&state.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, + }; + + let run_on = matches!(action, ControlAction::Start); + + let (value, value_type, value_text) = { + let guard = state + .connection_manager + .get_point_monitor_data_read_guard() + .await; + match guard + .get(&run_point.point_id) + .and_then(|m| m.value_type.as_ref()) + { + Some(crate::telemetry::ValueType::Int) => ( + crate::telemetry::DataValue::Int(if run_on { 1 } else { 0 }), + Some(crate::telemetry::ValueType::Int), + Some(if run_on { "1" } else { "0" }.to_string()), + ), + Some(crate::telemetry::ValueType::UInt) => ( + crate::telemetry::DataValue::UInt(if run_on { 1 } else { 0 }), + Some(crate::telemetry::ValueType::UInt), + Some(if run_on { "1" } else { "0" }.to_string()), + ), + _ => ( + crate::telemetry::DataValue::Bool(run_on), + Some(crate::telemetry::ValueType::Bool), + Some(run_on.to_string()), + ), + } + }; + + let monitor = crate::telemetry::PointMonitorInfo { + protocol: "simulation".to_string(), + source_id: Uuid::nil(), + point_id: run_point.point_id, + client_handle: 0, + scan_mode: crate::model::ScanMode::Poll, + timestamp: Some(chrono::Utc::now()), + quality: crate::telemetry::PointQuality::Good, + value: Some(value), + value_type, + value_text, + old_value: None, + old_timestamp: None, + value_changed: true, + }; + + if let Err(e) = state + .connection_manager + .update_point_monitor_data(monitor.clone()) + .await + { + tracing::warn!("simulate_run_feedback: cache update failed: {}", e); + return; + } + + let _ = state + .ws_manager + .send_to_public(crate::websocket::WsMessage::PointNewValue(monitor)) + .await; + + tracing::info!( + "simulate_run_feedback: equipment={} run={}", + equipment_id, + run_on + ); +} + async fn send_equipment_command( state: AppState, equipment_id: Uuid, @@ -80,6 +160,10 @@ async fn send_equipment_command( .await .map_err(|e| ApiErr::Internal(e, None))?; + if state.config.simulate_plc { + simulate_run_feedback(&state, equipment_id, action).await; + } + let event = match action { ControlAction::Start => crate::event::AppEvent::EquipmentStartCommandSent { equipment_id,