fix(control): block manual commands during auto, fix engine stop_time=0 bug, add sim feedback

- validator: reject equipment start/stop when unit auto_enabled
- engine: fix stop_time_sec==0 causing infinite Stopped state (never starts)
- engine: call simulate_run_feedback after auto commands when SIMULATE_PLC=true
- command: extract simulate_run_feedback to shared module (was private in handler)
- web: disable Start/Stop buttons when unit auto is active; sync on WS runtime update

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-03-25 12:37:43 +08:00
parent 989a0286e9
commit b832d98196
6 changed files with 169 additions and 105 deletions

View File

@ -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),

View File

@ -62,6 +62,8 @@ async fn tick_unit(
// kind -> role -> EquipmentRolePoint (first equipment per kind wins)
let mut kind_roles: HashMap<String, HashMap<String, EquipmentRolePoint>> = HashMap::new();
// kind -> equipment id (first equipment per kind)
let mut kind_eq_ids: HashMap<String, Uuid> = HashMap::new();
// all role maps for fault/comm scanning across all equipment
let mut all_roles: Vec<(Uuid, HashMap<String, EquipmentRolePoint>)> = 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<String, HashMap<String, EquipmentRolePoint>>,
kind_eq_ids: &HashMap<String, Uuid>,
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;

View File

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

View File

@ -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 {

View File

@ -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 {

View File

@ -141,20 +141,25 @@ function renderOpsEquipments(equipments) {
<span class="badge">${eq.kind || "--"}</span>
</div>
<div class="ops-signal-rows">${signalRowsHtml || '<span class="muted" style="font-size:11px;padding:2px 0">无绑定信号</span>'}</div>
${canControl ? '<div class="ops-eq-card-actions"></div>' : ""}
${canControl ? `<div class="ops-eq-card-actions" data-unit-id="${eq.unit_id || ""}"></div>` : ""}
`;
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 ? "自动控制运行中,请先停止自动" : "";
});
});
}