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::{
|
use crate::{
|
||||||
connection::{BatchSetPointValueReq, ConnectionManager, SetPointValueReqItem},
|
connection::{BatchSetPointValueReq, ConnectionManager, SetPointValueReqItem},
|
||||||
telemetry::ValueType,
|
telemetry::ValueType,
|
||||||
|
AppState,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -42,6 +43,87 @@ pub async fn send_pulse_command(
|
||||||
Ok(())
|
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 {
|
fn pulse_value(high: bool, value_type: Option<&ValueType>) -> serde_json::Value {
|
||||||
match value_type {
|
match value_type {
|
||||||
Some(ValueType::Bool) => serde_json::Value::Bool(high),
|
Some(ValueType::Bool) => serde_json::Value::Bool(high),
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ async fn tick_unit(
|
||||||
|
|
||||||
// kind -> role -> EquipmentRolePoint (first equipment per kind wins)
|
// kind -> role -> EquipmentRolePoint (first equipment per kind wins)
|
||||||
let mut kind_roles: HashMap<String, HashMap<String, EquipmentRolePoint>> = HashMap::new();
|
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
|
// all role maps for fault/comm scanning across all equipment
|
||||||
let mut all_roles: Vec<(Uuid, HashMap<String, EquipmentRolePoint>)> = Vec::new();
|
let mut all_roles: Vec<(Uuid, HashMap<String, EquipmentRolePoint>)> = Vec::new();
|
||||||
|
|
||||||
|
|
@ -82,6 +84,7 @@ async fn tick_unit(
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
kind_roles.insert(kind.clone(), role_map.clone());
|
kind_roles.insert(kind.clone(), role_map.clone());
|
||||||
|
kind_eq_ids.insert(kind.clone(), equip.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
all_roles.push((equip.id, role_map));
|
all_roles.push((equip.id, role_map));
|
||||||
|
|
@ -182,7 +185,7 @@ async fn tick_unit(
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let prev_state = runtime.state.clone();
|
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 {
|
if runtime.state != prev_state {
|
||||||
let _ = state.event_manager.send(AppEvent::UnitStateChanged {
|
let _ = state.event_manager.send(AppEvent::UnitStateChanged {
|
||||||
unit_id: unit.id,
|
unit_id: unit.id,
|
||||||
|
|
@ -211,37 +214,46 @@ async fn tick_state_machine(
|
||||||
runtime: &mut UnitRuntime,
|
runtime: &mut UnitRuntime,
|
||||||
unit: &crate::model::ControlUnit,
|
unit: &crate::model::ControlUnit,
|
||||||
kind_roles: &HashMap<String, HashMap<String, EquipmentRolePoint>>,
|
kind_roles: &HashMap<String, HashMap<String, EquipmentRolePoint>>,
|
||||||
|
kind_eq_ids: &HashMap<String, Uuid>,
|
||||||
delta_ms: i64,
|
delta_ms: i64,
|
||||||
) {
|
) {
|
||||||
let feeder_roles = kind_roles.get("coal_feeder");
|
let feeder_roles = kind_roles.get("coal_feeder");
|
||||||
let dist_roles = kind_roles.get("distributor");
|
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 {
|
match runtime.state {
|
||||||
UnitRuntimeState::Stopped => {
|
UnitRuntimeState::Stopped => {
|
||||||
if unit.stop_time_sec == 0 {
|
// stop_time_sec == 0 means start immediately (no wait)
|
||||||
return;
|
if unit.stop_time_sec > 0 {
|
||||||
}
|
runtime.current_stop_elapsed_sec += delta_ms; // field holds ms
|
||||||
runtime.current_stop_elapsed_sec += delta_ms; // field holds ms
|
if runtime.current_stop_elapsed_sec < unit.stop_time_sec as i64 * 1000 {
|
||||||
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;
|
|
||||||
}
|
|
||||||
runtime.state = UnitRuntimeState::Running;
|
|
||||||
runtime.current_stop_elapsed_sec = 0;
|
|
||||||
runtime.current_run_elapsed_sec = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 => {
|
UnitRuntimeState::Running => {
|
||||||
|
|
@ -266,6 +278,12 @@ async fn tick_state_machine(
|
||||||
tracing::warn!("Engine: auto stop coal_feeder failed: {}", e);
|
tracing::warn!("Engine: auto stop coal_feeder failed: {}", e);
|
||||||
return;
|
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.state = UnitRuntimeState::Stopped;
|
||||||
runtime.current_run_elapsed_sec = 0;
|
runtime.current_run_elapsed_sec = 0;
|
||||||
runtime.current_stop_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
|
send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await
|
||||||
{
|
{
|
||||||
tracing::warn!("Engine: auto start distributor failed: {}", e);
|
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
|
// 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);
|
tracing::warn!("Engine: auto stop distributor failed: {}", e);
|
||||||
return;
|
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.accumulated_run_sec = 0;
|
||||||
runtime.distributor_run_elapsed_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
|
// Runtime state checks — block commands if unit is locked
|
||||||
if let Some(unit_id) = equipment.unit_id {
|
if let Some(unit_id) = equipment.unit_id {
|
||||||
if let Some(runtime) = state.control_runtime.get(unit_id).await {
|
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 {
|
if runtime.comm_locked {
|
||||||
return Err(ApiErr::Forbidden(
|
return Err(ApiErr::Forbidden(
|
||||||
"Unit communication is locked".to_string(),
|
"Unit communication is locked".to_string(),
|
||||||
|
|
|
||||||
|
|
@ -63,85 +63,6 @@ pub async fn stop_equipment(
|
||||||
send_equipment_command(state, equipment_id, ControlAction::Stop).await
|
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(
|
async fn send_equipment_command(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
|
|
@ -161,7 +82,12 @@ async fn send_equipment_command(
|
||||||
.map_err(|e| ApiErr::Internal(e, None))?;
|
.map_err(|e| ApiErr::Internal(e, None))?;
|
||||||
|
|
||||||
if state.config.simulate_plc {
|
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 {
|
let event = match action {
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,10 @@ export function startPointSocket() {
|
||||||
state.runtimes.set(runtime.unit_id, runtime);
|
state.runtimes.set(runtime.unit_id, runtime);
|
||||||
renderUnits();
|
renderUnits();
|
||||||
// lazy import to avoid circular dep (ops.js -> logs.js -> ops.js)
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -141,20 +141,25 @@ function renderOpsEquipments(equipments) {
|
||||||
<span class="badge">${eq.kind || "--"}</span>
|
<span class="badge">${eq.kind || "--"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ops-signal-rows">${signalRowsHtml || '<span class="muted" style="font-size:11px;padding:2px 0">无绑定信号</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) {
|
if (canControl) {
|
||||||
const actions = card.querySelector(".ops-eq-card-actions");
|
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");
|
const startBtn = document.createElement("button");
|
||||||
startBtn.className = "secondary";
|
startBtn.className = "secondary";
|
||||||
startBtn.textContent = "Start";
|
startBtn.textContent = "Start";
|
||||||
|
startBtn.disabled = autoOn;
|
||||||
|
startBtn.title = autoOn ? "自动控制运行中,请先停止自动" : "";
|
||||||
startBtn.addEventListener("click", () =>
|
startBtn.addEventListener("click", () =>
|
||||||
apiFetch(`/api/control/equipment/${eq.id}/start`, { method: "POST" }).catch(() => {})
|
apiFetch(`/api/control/equipment/${eq.id}/start`, { method: "POST" }).catch(() => {})
|
||||||
);
|
);
|
||||||
const stopBtn = document.createElement("button");
|
const stopBtn = document.createElement("button");
|
||||||
stopBtn.className = "danger";
|
stopBtn.className = "danger";
|
||||||
stopBtn.textContent = "Stop";
|
stopBtn.textContent = "Stop";
|
||||||
|
stopBtn.disabled = autoOn;
|
||||||
|
stopBtn.title = autoOn ? "自动控制运行中,请先停止自动" : "";
|
||||||
stopBtn.addEventListener("click", () =>
|
stopBtn.addEventListener("click", () =>
|
||||||
apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {})
|
apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {})
|
||||||
);
|
);
|
||||||
|
|
@ -186,3 +191,16 @@ export function startOps() {
|
||||||
renderOpsUnits();
|
renderOpsUnits();
|
||||||
loadAllEquipmentCards();
|
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