Add hold/value dispatch modes, cancel_on_fault, and SIMULATE_PLC injection
step_executor gains three dispatch modes: pulse (default), hold (hold_until_confirm), and value (transfer_move_to writes the target station's code). The engine now sends step.stop_command_role whenever cancel_on_fault is true on Faulted entry, and threads a target-station lookup ahead of dispatch. A new simulate module patches the resolved confirm signal after a short delay when SIMULATE_PLC is set, so segments can be driven end-to-end without a real PLC. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
63683a24c8
commit
aaf48a336d
|
|
@ -18,12 +18,13 @@ use crate::{
|
||||||
control::{
|
control::{
|
||||||
interlock::{self, InterlockContext},
|
interlock::{self, InterlockContext},
|
||||||
runtime::{SegmentRuntime, SegmentRuntimeStore},
|
runtime::{SegmentRuntime, SegmentRuntimeStore},
|
||||||
|
simulate,
|
||||||
state::SegmentState,
|
state::SegmentState,
|
||||||
step_executor::{self, CommandPointIndex, DispatchOutcome},
|
step_executor::{self, CommandPointIndex, DispatchInputs, DispatchOutcome},
|
||||||
},
|
},
|
||||||
event::AppEvent,
|
event::AppEvent,
|
||||||
model::{ProcessSegment, SegmentInterlock, SegmentResource, SegmentStep},
|
model::{ProcessSegment, SegmentInterlock, SegmentResource, SegmentStep},
|
||||||
service::segment as segment_service,
|
service::{segment as segment_service, station as station_service},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -222,6 +223,28 @@ async fn tick(
|
||||||
.filter(|i| i.applies_to == "run_halt")
|
.filter(|i| i.applies_to == "run_halt")
|
||||||
.collect();
|
.collect();
|
||||||
if let Err(reason) = interlock::evaluate_all(&run_halt, ctx, monitor) {
|
if let Err(reason) = interlock::evaluate_all(&run_halt, ctx, monitor) {
|
||||||
|
// Honor cancel_on_fault for the current step before locking out.
|
||||||
|
if let Some(step_no) = runtime.current_step_no {
|
||||||
|
if let Some(step) = steps.iter().find(|s| s.step_no == step_no) {
|
||||||
|
if step.cancel_on_fault {
|
||||||
|
if let Err(err) = step_executor::send_stop_command(
|
||||||
|
step,
|
||||||
|
&state.platform.connection_manager,
|
||||||
|
cmd_index,
|
||||||
|
monitor,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
"Engine: segment {} run-halt stop for step {} failed: {}",
|
||||||
|
segment.id,
|
||||||
|
step_no,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let _ = state.event_manager.send(AppEvent::SegmentFaultLocked {
|
let _ = state.event_manager.send(AppEvent::SegmentFaultLocked {
|
||||||
segment_id: segment.id,
|
segment_id: segment.id,
|
||||||
message: reason.clone(),
|
message: reason.clone(),
|
||||||
|
|
@ -321,11 +344,31 @@ async fn tick(
|
||||||
runtime.fault_message = Some(format!("step {} not found", step_no));
|
runtime.fault_message = Some(format!("step {} not found", step_no));
|
||||||
return Some(runtime);
|
return Some(runtime);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Resolve transfer_move_to inputs ahead of dispatch.
|
||||||
|
let station_code = if step.action_kind == "transfer_move_to" {
|
||||||
|
match step.target_station_id {
|
||||||
|
Some(id) => match station_service::get_station_by_id(&state.platform.pool, id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(s)) => Some(s.code),
|
||||||
|
Ok(None) | Err(_) => None,
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let inputs = DispatchInputs {
|
||||||
|
target_station_code: station_code.as_deref(),
|
||||||
|
};
|
||||||
|
|
||||||
let outcome = step_executor::dispatch(
|
let outcome = step_executor::dispatch(
|
||||||
step,
|
step,
|
||||||
&state.platform.connection_manager,
|
&state.platform.connection_manager,
|
||||||
cmd_index,
|
cmd_index,
|
||||||
monitor,
|
monitor,
|
||||||
|
&inputs,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
match outcome {
|
match outcome {
|
||||||
|
|
@ -336,9 +379,34 @@ async fn tick(
|
||||||
segment_id: segment.id,
|
segment_id: segment.id,
|
||||||
step_no,
|
step_no,
|
||||||
});
|
});
|
||||||
|
// SIMULATE_PLC: schedule the confirm signal to arrive so the
|
||||||
|
// engine can drive the segment end-to-end without a PLC.
|
||||||
|
if simulate::enabled() {
|
||||||
|
if let Some((pid, invert, expected)) = resolve_confirm_point(step, ctx) {
|
||||||
|
let logical_value = expected ^ invert;
|
||||||
|
simulate::schedule_confirm(state.clone(), pid, logical_value, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(runtime)
|
Some(runtime)
|
||||||
}
|
}
|
||||||
DispatchOutcome::Misconfigured(msg) | DispatchOutcome::WriteError(msg) => {
|
DispatchOutcome::Misconfigured(msg) | DispatchOutcome::WriteError(msg) => {
|
||||||
|
if step.cancel_on_fault {
|
||||||
|
if let Err(err) = step_executor::send_stop_command(
|
||||||
|
step,
|
||||||
|
&state.platform.connection_manager,
|
||||||
|
cmd_index,
|
||||||
|
monitor,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
"Engine: segment {} stop on fault for step {} failed: {}",
|
||||||
|
segment.id,
|
||||||
|
step_no,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
let _ = state.event_manager.send(AppEvent::SegmentFaultLocked {
|
let _ = state.event_manager.send(AppEvent::SegmentFaultLocked {
|
||||||
segment_id: segment.id,
|
segment_id: segment.id,
|
||||||
message: msg.clone(),
|
message: msg.clone(),
|
||||||
|
|
@ -426,6 +494,23 @@ async fn tick(
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// "fault" or unknown
|
// "fault" or unknown
|
||||||
|
if step.cancel_on_fault {
|
||||||
|
if let Err(err) = step_executor::send_stop_command(
|
||||||
|
step,
|
||||||
|
&state.platform.connection_manager,
|
||||||
|
cmd_index,
|
||||||
|
monitor,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
"Engine: segment {} timeout stop for step {} failed: {}",
|
||||||
|
segment.id,
|
||||||
|
step_no,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
runtime.state = SegmentState::Faulted;
|
runtime.state = SegmentState::Faulted;
|
||||||
runtime.fault_message = Some(format!("step {} timeout", step_no));
|
runtime.fault_message = Some(format!("step {} timeout", step_no));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ pub mod engine;
|
||||||
pub mod interlock;
|
pub mod interlock;
|
||||||
pub mod resource;
|
pub mod resource;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
|
pub mod simulate;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod step_executor;
|
pub mod step_executor;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
//! Dev-time signal injection so segments can be driven end-to-end without a real PLC.
|
||||||
|
//!
|
||||||
|
//! Activated via `SIMULATE_PLC=true|1` (matches the feeder convention). When
|
||||||
|
//! enabled, the engine schedules a `patch_signal` after dispatching each step's
|
||||||
|
//! command so the confirm signal arrives at `expected_value` after a short
|
||||||
|
//! delay, advancing the state machine.
|
||||||
|
//!
|
||||||
|
//! When OPC UA writes succeed they propagate normally. The fallback updates the
|
||||||
|
//! monitor cache directly and broadcasts `WsMessage::PointNewValue`, so the
|
||||||
|
//! engine + frontend see the same change.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use plc_platform_core::{
|
||||||
|
connection::{BatchSetPointValueReq, SetPointValueReqItem},
|
||||||
|
telemetry::{DataValue, PointMonitorInfo, PointQuality, ValueType},
|
||||||
|
websocket::WsMessage,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub fn enabled() -> bool {
|
||||||
|
matches!(
|
||||||
|
std::env::var("SIMULATE_PLC").ok().as_deref(),
|
||||||
|
Some("true") | Some("1")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a background task that, after `delay_ms`, patches `confirm_point_id`
|
||||||
|
/// to `expected_value`. No-op if simulate is disabled.
|
||||||
|
pub fn schedule_confirm(
|
||||||
|
state: AppState,
|
||||||
|
confirm_point_id: Uuid,
|
||||||
|
expected_value: bool,
|
||||||
|
delay_ms: u64,
|
||||||
|
) {
|
||||||
|
if !enabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||||
|
patch_signal(&state, confirm_point_id, expected_value).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patch a point: prefer OPC UA write, fall back to direct cache update + WS push.
|
||||||
|
pub async fn patch_signal(state: &AppState, point_id: Uuid, value_on: bool) {
|
||||||
|
let write_json = serde_json::json!(if value_on { 1 } else { 0 });
|
||||||
|
let write_ok = match state
|
||||||
|
.platform
|
||||||
|
.connection_manager
|
||||||
|
.write_point_values_batch(BatchSetPointValueReq {
|
||||||
|
items: vec![SetPointValueReqItem {
|
||||||
|
point_id,
|
||||||
|
value: write_json,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(res) => res.success,
|
||||||
|
Err(_) => false,
|
||||||
|
};
|
||||||
|
if write_ok {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (value, value_type, value_text) = {
|
||||||
|
let guard = state
|
||||||
|
.platform
|
||||||
|
.connection_manager
|
||||||
|
.get_point_monitor_data_read_guard()
|
||||||
|
.await;
|
||||||
|
match guard.get(&point_id).and_then(|m| m.value_type.as_ref()) {
|
||||||
|
Some(ValueType::Int) => (
|
||||||
|
DataValue::Int(if value_on { 1 } else { 0 }),
|
||||||
|
Some(ValueType::Int),
|
||||||
|
Some(if value_on { "1" } else { "0" }.to_string()),
|
||||||
|
),
|
||||||
|
Some(ValueType::UInt) => (
|
||||||
|
DataValue::UInt(if value_on { 1 } else { 0 }),
|
||||||
|
Some(ValueType::UInt),
|
||||||
|
Some(if value_on { "1" } else { "0" }.to_string()),
|
||||||
|
),
|
||||||
|
_ => (
|
||||||
|
DataValue::Bool(value_on),
|
||||||
|
Some(ValueType::Bool),
|
||||||
|
Some(value_on.to_string()),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let monitor = PointMonitorInfo {
|
||||||
|
protocol: "simulation".to_string(),
|
||||||
|
source_id: Uuid::nil(),
|
||||||
|
point_id,
|
||||||
|
client_handle: 0,
|
||||||
|
scan_mode: plc_platform_core::model::ScanMode::Poll,
|
||||||
|
timestamp: Some(Utc::now()),
|
||||||
|
quality: PointQuality::Good,
|
||||||
|
value: Some(value),
|
||||||
|
value_type,
|
||||||
|
value_text,
|
||||||
|
old_value: None,
|
||||||
|
old_timestamp: None,
|
||||||
|
value_changed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = state
|
||||||
|
.platform
|
||||||
|
.connection_manager
|
||||||
|
.update_point_monitor_data(monitor.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("[ops-sim] cache update failed for {}: {}", point_id, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = state
|
||||||
|
.platform
|
||||||
|
.ws_manager
|
||||||
|
.send_to_public(WsMessage::PointNewValue(monitor))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enabled_responds_to_env_flag() {
|
||||||
|
// Snapshot whatever the parent process set, restore at the end so the
|
||||||
|
// env touch doesn't leak between tests.
|
||||||
|
let prev = std::env::var("SIMULATE_PLC").ok();
|
||||||
|
|
||||||
|
std::env::remove_var("SIMULATE_PLC");
|
||||||
|
assert!(!enabled());
|
||||||
|
|
||||||
|
std::env::set_var("SIMULATE_PLC", "1");
|
||||||
|
assert!(enabled());
|
||||||
|
|
||||||
|
std::env::set_var("SIMULATE_PLC", "true");
|
||||||
|
assert!(enabled());
|
||||||
|
|
||||||
|
std::env::set_var("SIMULATE_PLC", "no");
|
||||||
|
assert!(!enabled());
|
||||||
|
|
||||||
|
match prev {
|
||||||
|
Some(v) => std::env::set_var("SIMULATE_PLC", v),
|
||||||
|
None => std::env::remove_var("SIMULATE_PLC"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,29 @@
|
||||||
//! Step executor (design doc §5.4).
|
//! Step executor (design doc §5.4).
|
||||||
//!
|
//!
|
||||||
//! Resolves a `segment_step.action_kind` to a concrete write on a command point
|
//! Resolves a `segment_step.action_kind` to a concrete write on a command point.
|
||||||
//! using `plc_platform_core::control::command::send_pulse_command`. Confirmation
|
//! Three dispatch modes:
|
||||||
//! is handled by the engine's `Confirming` state; this module only sends the
|
//!
|
||||||
//! initial command.
|
//! - Pulse (default): write high → wait `pulse_ms` → write low. Matches short
|
||||||
|
//! commands such as `open_door` / `robot_permit`.
|
||||||
|
//! - Hold (`step.hold_until_confirm = true`): write high once and leave it
|
||||||
|
//! asserted; engine emits the configured `stop_command_role` once the confirm
|
||||||
|
//! signal arrives or the step transitions to fault.
|
||||||
|
//! - Value (action `transfer_move_to`): write the target station's `code` to the
|
||||||
|
//! move-command point so the field translates the target position itself.
|
||||||
|
//!
|
||||||
|
//! Confirmation reads still live in the engine's `Confirming` state.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use plc_platform_core::{
|
use plc_platform_core::{
|
||||||
connection::ConnectionManager,
|
connection::{BatchSetPointValueReq, ConnectionManager, SetPointValueReqItem},
|
||||||
control::command::send_pulse_command,
|
control::command::send_pulse_command,
|
||||||
service::EquipmentSignalRole,
|
service::EquipmentSignalRole,
|
||||||
telemetry::PointMonitorInfo,
|
telemetry::{PointMonitorInfo, ValueType},
|
||||||
};
|
};
|
||||||
|
use serde_json::json;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::model::SegmentStep;
|
use crate::model::SegmentStep;
|
||||||
|
|
@ -46,6 +55,13 @@ impl CommandPointIndex {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optional inputs the engine resolves ahead of dispatch.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DispatchInputs<'a> {
|
||||||
|
/// Target station's `code`, used by `transfer_move_to` as the value to write.
|
||||||
|
pub target_station_code: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Outcome of dispatching a step's command.
|
/// Outcome of dispatching a step's command.
|
||||||
pub enum DispatchOutcome {
|
pub enum DispatchOutcome {
|
||||||
/// A command was issued (or skipped because the action is wait-only).
|
/// A command was issued (or skipped because the action is wait-only).
|
||||||
|
|
@ -58,15 +74,13 @@ pub enum DispatchOutcome {
|
||||||
WriteError(String),
|
WriteError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch `step.action_kind`. For first-pass the executor supports both pulse
|
/// Dispatch `step.action_kind`. See module docs for the three dispatch modes.
|
||||||
/// commands and "hold until confirm" commands — pulse is the default unless
|
|
||||||
/// `step.hold_until_confirm` is true, in which case we send a single high value
|
|
||||||
/// and let the engine emit the stop command after confirmation.
|
|
||||||
pub async fn dispatch(
|
pub async fn dispatch(
|
||||||
step: &SegmentStep,
|
step: &SegmentStep,
|
||||||
connection: &Arc<ConnectionManager>,
|
connection: &Arc<ConnectionManager>,
|
||||||
command_points: &CommandPointIndex,
|
command_points: &CommandPointIndex,
|
||||||
monitor: &HashMap<Uuid, PointMonitorInfo>,
|
monitor: &HashMap<Uuid, PointMonitorInfo>,
|
||||||
|
inputs: &DispatchInputs<'_>,
|
||||||
) -> DispatchOutcome {
|
) -> DispatchOutcome {
|
||||||
if step.action_kind == "wait_signal" {
|
if step.action_kind == "wait_signal" {
|
||||||
return DispatchOutcome::Issued;
|
return DispatchOutcome::Issued;
|
||||||
|
|
@ -105,9 +119,30 @@ pub async fn dispatch(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if step.action_kind == "transfer_move_to" {
|
||||||
|
let Some(code) = inputs.target_station_code else {
|
||||||
|
return DispatchOutcome::Misconfigured(format!(
|
||||||
|
"step {} transfer_move_to missing target_station_id",
|
||||||
|
step.step_no
|
||||||
|
));
|
||||||
|
};
|
||||||
let value_type = monitor.get(&point_id).and_then(|m| m.value_type.clone());
|
let value_type = monitor.get(&point_id).and_then(|m| m.value_type.clone());
|
||||||
let pulse_ms = step.pulse_ms.unwrap_or(default_pulse_ms(&step.action_kind)) as u64;
|
return match write_station_target(connection, point_id, value_type.as_ref(), code).await {
|
||||||
|
Ok(()) => DispatchOutcome::Issued,
|
||||||
|
Err(err) => DispatchOutcome::WriteError(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let value_type = monitor.get(&point_id).and_then(|m| m.value_type.clone());
|
||||||
|
|
||||||
|
if step.hold_until_confirm {
|
||||||
|
return match write_high(connection, point_id, value_type.as_ref()).await {
|
||||||
|
Ok(()) => DispatchOutcome::Issued,
|
||||||
|
Err(err) => DispatchOutcome::WriteError(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let pulse_ms = step.pulse_ms.unwrap_or(default_pulse_ms(&step.action_kind)) as u64;
|
||||||
if let Err(err) = send_pulse_command(connection, point_id, value_type.as_ref(), pulse_ms).await
|
if let Err(err) = send_pulse_command(connection, point_id, value_type.as_ref(), pulse_ms).await
|
||||||
{
|
{
|
||||||
return DispatchOutcome::WriteError(err);
|
return DispatchOutcome::WriteError(err);
|
||||||
|
|
@ -116,8 +151,8 @@ pub async fn dispatch(
|
||||||
DispatchOutcome::Issued
|
DispatchOutcome::Issued
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send the configured stop command (used when `hold_until_confirm` is true or
|
/// Send the configured stop command. Used after `hold_until_confirm` steps and
|
||||||
/// on fault cleanup). No-op if no stop role is configured.
|
/// on `cancel_on_fault` cleanup. No-op when no stop role is configured.
|
||||||
pub async fn send_stop_command(
|
pub async fn send_stop_command(
|
||||||
step: &SegmentStep,
|
step: &SegmentStep,
|
||||||
connection: &Arc<ConnectionManager>,
|
connection: &Arc<ConnectionManager>,
|
||||||
|
|
@ -141,6 +176,60 @@ pub async fn send_stop_command(
|
||||||
send_pulse_command(connection, point_id, value_type.as_ref(), 300).await
|
send_pulse_command(connection, point_id, value_type.as_ref(), 300).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write `1` (or `true`) to a command point exactly once.
|
||||||
|
async fn write_high(
|
||||||
|
connection: &Arc<ConnectionManager>,
|
||||||
|
point_id: Uuid,
|
||||||
|
value_type: Option<&ValueType>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let value = match value_type {
|
||||||
|
Some(ValueType::Bool) => json!(true),
|
||||||
|
_ => json!(1),
|
||||||
|
};
|
||||||
|
let res = connection
|
||||||
|
.write_point_values_batch(BatchSetPointValueReq {
|
||||||
|
items: vec![SetPointValueReqItem { point_id, value }],
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
if res.success {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("hold write failed: {:?}", res.err_msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the target station code as the command value.
|
||||||
|
async fn write_station_target(
|
||||||
|
connection: &Arc<ConnectionManager>,
|
||||||
|
point_id: Uuid,
|
||||||
|
value_type: Option<&ValueType>,
|
||||||
|
code: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Treat numeric station codes as integer writes when the command point is
|
||||||
|
// an int/uint; otherwise fall through to a text write.
|
||||||
|
let value = match value_type {
|
||||||
|
Some(ValueType::Int) | Some(ValueType::UInt) => code
|
||||||
|
.parse::<i64>()
|
||||||
|
.map(|n| json!(n))
|
||||||
|
.unwrap_or_else(|_| json!(code)),
|
||||||
|
Some(ValueType::Float) => code
|
||||||
|
.parse::<f64>()
|
||||||
|
.map(|n| json!(n))
|
||||||
|
.unwrap_or_else(|_| json!(code)),
|
||||||
|
_ => json!(code),
|
||||||
|
};
|
||||||
|
let res = connection
|
||||||
|
.write_point_values_batch(BatchSetPointValueReq {
|
||||||
|
items: vec![SetPointValueReqItem { point_id, value }],
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
if res.success {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("transfer_move_to write failed: {:?}", res.err_msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Default command-role mapping per design doc §4.2.4 table.
|
/// Default command-role mapping per design doc §4.2.4 table.
|
||||||
fn default_command_role(action_kind: &str) -> Option<&'static str> {
|
fn default_command_role(action_kind: &str) -> Option<&'static str> {
|
||||||
match action_kind {
|
match action_kind {
|
||||||
|
|
@ -223,17 +312,64 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wait_signal_step_is_dispatched_without_command_role() {
|
fn wait_signal_step_is_dispatched_without_command_role() {
|
||||||
// wait_signal returns Issued even if no command_role / equipment configured.
|
|
||||||
let step = make_step("wait_signal", None, None);
|
let step = make_step("wait_signal", None, None);
|
||||||
// Build a sync runtime to drive the async call.
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let connection = Arc::new(ConnectionManager::new());
|
let connection = Arc::new(ConnectionManager::new());
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let outcome = dispatch(&step, &connection, &CommandPointIndex::default(), &HashMap::new()).await;
|
let outcome = dispatch(
|
||||||
|
&step,
|
||||||
|
&connection,
|
||||||
|
&CommandPointIndex::default(),
|
||||||
|
&HashMap::new(),
|
||||||
|
&DispatchInputs::default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert!(matches!(outcome, DispatchOutcome::Issued));
|
assert!(matches!(outcome, DispatchOutcome::Issued));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transfer_move_to_without_station_code_is_misconfigured() {
|
||||||
|
let step = make_step("transfer_move_to", Some(Uuid::new_v4()), Some("move_cmd"));
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let connection = Arc::new(ConnectionManager::new());
|
||||||
|
rt.block_on(async {
|
||||||
|
let outcome = dispatch(
|
||||||
|
&step,
|
||||||
|
&connection,
|
||||||
|
&CommandPointIndex::default(),
|
||||||
|
&HashMap::new(),
|
||||||
|
&DispatchInputs::default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(outcome, DispatchOutcome::Misconfigured(_)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn misconfigured_when_command_role_missing_default() {
|
||||||
|
let step = make_step("pulse_cmd", Some(Uuid::new_v4()), None);
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let connection = Arc::new(ConnectionManager::new());
|
||||||
|
rt.block_on(async {
|
||||||
|
let outcome = dispatch(
|
||||||
|
&step,
|
||||||
|
&connection,
|
||||||
|
&CommandPointIndex::default(),
|
||||||
|
&HashMap::new(),
|
||||||
|
&DispatchInputs::default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(outcome, DispatchOutcome::Misconfigured(_)));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue