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:
parent
989a0286e9
commit
b832d98196
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ? "自动控制运行中,请先停止自动" : "";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue