diff --git a/src/control/command.rs b/src/control/command.rs index cd40d37..743a06b 100644 --- a/src/control/command.rs +++ b/src/control/command.rs @@ -1,6 +1,7 @@ use crate::{ connection::{BatchSetPointValueReq, ConnectionManager, SetPointValueReqItem}, telemetry::ValueType, + AppState, }; use serde_json::json; use std::sync::Arc; @@ -42,6 +43,87 @@ pub async fn send_pulse_command( Ok(()) } +/// Simulate RUN signal feedback after a command when SIMULATE_PLC=true. +/// Looks up the equipment's "run" role point and writes a synthetic Good-quality +/// monitor entry, then broadcasts PointNewValue over WebSocket. +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.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 (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::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 + ); +} + fn pulse_value(high: bool, value_type: Option<&ValueType>) -> serde_json::Value { match value_type { Some(ValueType::Bool) => serde_json::Value::Bool(high), diff --git a/src/control/engine.rs b/src/control/engine.rs index bb24868..326b04f 100644 --- a/src/control/engine.rs +++ b/src/control/engine.rs @@ -62,6 +62,8 @@ async fn tick_unit( // kind -> role -> EquipmentRolePoint (first equipment per kind wins) let mut kind_roles: HashMap> = HashMap::new(); + // kind -> equipment id (first equipment per kind) + let mut kind_eq_ids: HashMap = HashMap::new(); // all role maps for fault/comm scanning across all equipment let mut all_roles: Vec<(Uuid, HashMap)> = Vec::new(); @@ -82,6 +84,7 @@ async fn tick_unit( ); } else { kind_roles.insert(kind.clone(), role_map.clone()); + kind_eq_ids.insert(kind.clone(), equip.id); } } all_roles.push((equip.id, role_map)); @@ -182,7 +185,7 @@ async fn tick_unit( .unwrap_or(0); let prev_state = runtime.state.clone(); - tick_state_machine(state, &mut runtime, unit, &kind_roles, delta_ms).await; + tick_state_machine(state, &mut runtime, unit, &kind_roles, &kind_eq_ids, delta_ms).await; if runtime.state != prev_state { let _ = state.event_manager.send(AppEvent::UnitStateChanged { unit_id: unit.id, @@ -211,37 +214,46 @@ async fn tick_state_machine( runtime: &mut UnitRuntime, unit: &crate::model::ControlUnit, kind_roles: &HashMap>, + kind_eq_ids: &HashMap, delta_ms: i64, ) { let feeder_roles = kind_roles.get("coal_feeder"); let dist_roles = kind_roles.get("distributor"); + let feeder_eq_id = kind_eq_ids.get("coal_feeder").copied(); + let dist_eq_id = kind_eq_ids.get("distributor").copied(); match runtime.state { UnitRuntimeState::Stopped => { - if unit.stop_time_sec == 0 { - return; - } - runtime.current_stop_elapsed_sec += delta_ms; // field holds ms - if runtime.current_stop_elapsed_sec >= unit.stop_time_sec as i64 * 1000 { - let monitor = state - .connection_manager - .get_point_monitor_data_read_guard() - .await; - if let Some((pid, vt)) = - feeder_roles.and_then(|r| find_cmd(r, "start_cmd", &monitor)) - { - drop(monitor); - if let Err(e) = - send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await - { - tracing::warn!("Engine: auto start coal_feeder failed: {}", e); - return; - } - runtime.state = UnitRuntimeState::Running; - runtime.current_stop_elapsed_sec = 0; - runtime.current_run_elapsed_sec = 0; + // stop_time_sec == 0 means start immediately (no wait) + if unit.stop_time_sec > 0 { + runtime.current_stop_elapsed_sec += delta_ms; // field holds ms + if runtime.current_stop_elapsed_sec < unit.stop_time_sec as i64 * 1000 { + return; } } + let monitor = state + .connection_manager + .get_point_monitor_data_read_guard() + .await; + if let Some((pid, vt)) = + feeder_roles.and_then(|r| find_cmd(r, "start_cmd", &monitor)) + { + drop(monitor); + if let Err(e) = + send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await + { + tracing::warn!("Engine: auto start coal_feeder failed: {}", e); + return; + } + if state.config.simulate_plc { + if let Some(eq_id) = feeder_eq_id { + crate::control::command::simulate_run_feedback(state, eq_id, true).await; + } + } + runtime.state = UnitRuntimeState::Running; + runtime.current_stop_elapsed_sec = 0; + runtime.current_run_elapsed_sec = 0; + } } UnitRuntimeState::Running => { @@ -266,6 +278,12 @@ async fn tick_state_machine( tracing::warn!("Engine: auto stop coal_feeder failed: {}", e); return; } + if state.config.simulate_plc { + if let Some(eq_id) = feeder_eq_id { + crate::control::command::simulate_run_feedback(state, eq_id, false) + .await; + } + } runtime.state = UnitRuntimeState::Stopped; runtime.current_run_elapsed_sec = 0; runtime.current_stop_elapsed_sec = 0; @@ -298,6 +316,11 @@ async fn tick_state_machine( send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await { tracing::warn!("Engine: auto start distributor failed: {}", e); + } else if state.config.simulate_plc { + if let Some(eq_id) = dist_eq_id { + crate::control::command::simulate_run_feedback(state, eq_id, true) + .await; + } } } // Mark as "started" by advancing to 1ms so this branch won't re-fire @@ -324,6 +347,12 @@ async fn tick_state_machine( tracing::warn!("Engine: auto stop distributor failed: {}", e); return; } + if state.config.simulate_plc { + if let Some(eq_id) = dist_eq_id { + crate::control::command::simulate_run_feedback(state, eq_id, false) + .await; + } + } } runtime.accumulated_run_sec = 0; runtime.distributor_run_elapsed_sec = 0; diff --git a/src/control/validator.rs b/src/control/validator.rs index 1e7a608..cf59f5a 100644 --- a/src/control/validator.rs +++ b/src/control/validator.rs @@ -120,6 +120,12 @@ pub async fn validate_manual_control( // Runtime state checks — block commands if unit is locked if let Some(unit_id) = equipment.unit_id { if let Some(runtime) = state.control_runtime.get(unit_id).await { + if runtime.auto_enabled { + return Err(ApiErr::Forbidden( + "Auto control is active; disable auto first".to_string(), + Some(json!({ "unit_id": unit_id })), + )); + } if runtime.comm_locked { return Err(ApiErr::Forbidden( "Unit communication is locked".to_string(), diff --git a/src/handler/control.rs b/src/handler/control.rs index 32c7109..c35513a 100644 --- a/src/handler/control.rs +++ b/src/handler/control.rs @@ -63,85 +63,6 @@ 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, @@ -161,7 +82,12 @@ async fn send_equipment_command( .map_err(|e| ApiErr::Internal(e, None))?; if state.config.simulate_plc { - simulate_run_feedback(&state, equipment_id, action).await; + crate::control::command::simulate_run_feedback( + &state, + equipment_id, + matches!(action, ControlAction::Start), + ) + .await; } let event = match action { diff --git a/web/js/logs.js b/web/js/logs.js index 26122da..7dd0a27 100644 --- a/web/js/logs.js +++ b/web/js/logs.js @@ -98,7 +98,10 @@ export function startPointSocket() { state.runtimes.set(runtime.unit_id, runtime); renderUnits(); // lazy import to avoid circular dep (ops.js -> logs.js -> ops.js) - import("./ops.js").then(({ renderOpsUnits }) => renderOpsUnits()); + import("./ops.js").then(({ renderOpsUnits, syncEquipmentButtonsForUnit }) => { + renderOpsUnits(); + syncEquipmentButtonsForUnit(runtime.unit_id, runtime.auto_enabled); + }); return; } } catch { diff --git a/web/js/ops.js b/web/js/ops.js index 2deb666..cf3b25d 100644 --- a/web/js/ops.js +++ b/web/js/ops.js @@ -141,20 +141,25 @@ function renderOpsEquipments(equipments) { ${eq.kind || "--"}
${signalRowsHtml || '无绑定信号'}
- ${canControl ? '
' : ""} + ${canControl ? `
` : ""} `; if (canControl) { const actions = card.querySelector(".ops-eq-card-actions"); + const autoOn = !!(eq.unit_id && state.runtimes.get(eq.unit_id)?.auto_enabled); const startBtn = document.createElement("button"); startBtn.className = "secondary"; startBtn.textContent = "Start"; + startBtn.disabled = autoOn; + startBtn.title = autoOn ? "自动控制运行中,请先停止自动" : ""; startBtn.addEventListener("click", () => apiFetch(`/api/control/equipment/${eq.id}/start`, { method: "POST" }).catch(() => {}) ); const stopBtn = document.createElement("button"); stopBtn.className = "danger"; stopBtn.textContent = "Stop"; + stopBtn.disabled = autoOn; + stopBtn.title = autoOn ? "自动控制运行中,请先停止自动" : ""; stopBtn.addEventListener("click", () => apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {}) ); @@ -186,3 +191,16 @@ export function startOps() { renderOpsUnits(); loadAllEquipmentCards(); } + +/** Called by WS handler when a unit's runtime changes — syncs manual button disabled state. */ +export function syncEquipmentButtonsForUnit(unitId, autoEnabled) { + if (!dom.opsEquipmentArea) return; + dom.opsEquipmentArea + .querySelectorAll(`.ops-eq-card-actions[data-unit-id="${unitId}"]`) + .forEach((actions) => { + actions.querySelectorAll("button").forEach((btn) => { + btn.disabled = autoEnabled; + btn.title = autoEnabled ? "自动控制运行中,请先停止自动" : ""; + }); + }); +}