Harden operation segment control
This commit is contained in:
parent
ed638eadb2
commit
5613c9f0d5
|
|
@ -96,30 +96,33 @@ async fn segment_task(state: AppState, store: Arc<SegmentRuntimeStore>, segment_
|
|||
|
||||
loop {
|
||||
// 1. Reload segment config; exit when disabled or removed.
|
||||
let segment = match segment_service::get_segment_by_id(&state.platform.pool, segment_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(s)) if s.enabled && s.mode != "disabled" => s,
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"Engine: segment {} disabled or removed, task exiting",
|
||||
segment_id
|
||||
);
|
||||
state.resource_registry.release_all_for(segment_id).await;
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Engine: segment {} reload failed: {}", segment_id, err);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let segment =
|
||||
match segment_service::get_segment_by_id(&state.platform.pool, segment_id).await {
|
||||
Ok(Some(s)) if s.enabled && s.mode != "disabled" => s,
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"Engine: segment {} disabled or removed, task exiting",
|
||||
segment_id
|
||||
);
|
||||
state.resource_registry.release_all_for(segment_id).await;
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Engine: segment {} reload failed: {}", segment_id, err);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Reload steps + interlocks + resource keys.
|
||||
let steps = match segment_service::list_steps(&state.platform.pool, segment_id).await {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
tracing::error!("Engine: segment {} steps reload failed: {}", segment_id, err);
|
||||
tracing::error!(
|
||||
"Engine: segment {} steps reload failed: {}",
|
||||
segment_id,
|
||||
err
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -163,20 +166,21 @@ async fn segment_task(state: AppState, store: Arc<SegmentRuntimeStore>, segment_
|
|||
continue;
|
||||
}
|
||||
};
|
||||
let ctx = match InterlockContext::load_for_interlocks(&state.platform.pool, &interlocks)
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Engine: segment {} interlock-context load failed: {}",
|
||||
segment_id,
|
||||
err
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let ctx =
|
||||
match InterlockContext::load_for_segment(&state.platform.pool, &steps, &interlocks)
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Engine: segment {} interlock-context load failed: {}",
|
||||
segment_id,
|
||||
err
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Snapshot the monitor map for the rest of this tick.
|
||||
let monitor_guard = state
|
||||
|
|
@ -240,6 +244,38 @@ async fn tick(
|
|||
monitor: &HashMap<Uuid, PointMonitorInfo>,
|
||||
mut runtime: SegmentRuntime,
|
||||
) -> Option<SegmentRuntime> {
|
||||
if matches!(
|
||||
runtime.state,
|
||||
SegmentState::Executing | SegmentState::Confirming | SegmentState::Resetting
|
||||
) && !runtime.auto_enabled
|
||||
{
|
||||
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 {} auto-stop command for step {} failed: {}",
|
||||
segment.id,
|
||||
step_no,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime.manual_ack_required = true;
|
||||
runtime.blocked_reason = Some("auto stopped during active step".to_string());
|
||||
runtime.state = SegmentState::ManualAckRequired;
|
||||
return Some(runtime);
|
||||
}
|
||||
|
||||
// Run-halt interlocks apply once we're past Checking.
|
||||
if matches!(
|
||||
runtime.state,
|
||||
|
|
@ -337,10 +373,7 @@ async fn tick(
|
|||
}
|
||||
for rule in interlocks.iter().filter(|i| i.applies_to == "start_deny") {
|
||||
if interlock::evaluate(rule, ctx, monitor).is_ok() {
|
||||
let reason = format!(
|
||||
"start denied by rule {} ({})",
|
||||
rule.id, rule.rule_kind
|
||||
);
|
||||
let reason = format!("start denied by rule {} ({})", rule.id, rule.rule_kind);
|
||||
let _ = state.event_manager.send(AppEvent::SegmentBlocked {
|
||||
segment_id: segment.id,
|
||||
reason: reason.clone(),
|
||||
|
|
@ -366,8 +399,7 @@ async fn tick(
|
|||
resource_key: res.resource_key.clone(),
|
||||
});
|
||||
runtime.state = SegmentState::Blocked;
|
||||
runtime.blocked_reason =
|
||||
Some(format!("resource_busy: {}", res.resource_key));
|
||||
runtime.blocked_reason = Some(format!("resource_busy: {}", res.resource_key));
|
||||
return Some(runtime);
|
||||
}
|
||||
acquired.push(res.resource_key.clone());
|
||||
|
|
@ -401,12 +433,12 @@ async fn tick(
|
|||
// 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,
|
||||
},
|
||||
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 {
|
||||
|
|
@ -435,7 +467,8 @@ async fn tick(
|
|||
// 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) {
|
||||
if let Ok(Some((pid, invert, expected))) = resolve_confirm_point(step, ctx)
|
||||
{
|
||||
let logical_value = expected ^ invert;
|
||||
simulate::schedule_confirm(state.clone(), pid, logical_value, 200);
|
||||
}
|
||||
|
|
@ -482,7 +515,18 @@ async fn tick(
|
|||
return Some(runtime);
|
||||
};
|
||||
|
||||
let confirm = resolve_confirm_point(step, ctx);
|
||||
let confirm = match resolve_confirm_point(step, ctx) {
|
||||
Ok(confirm) => confirm,
|
||||
Err(message) => {
|
||||
let _ = state.event_manager.send(AppEvent::SegmentFaultLocked {
|
||||
segment_id: segment.id,
|
||||
message: message.clone(),
|
||||
});
|
||||
runtime.state = SegmentState::Faulted;
|
||||
runtime.fault_message = Some(message);
|
||||
return Some(runtime);
|
||||
}
|
||||
};
|
||||
let confirmed = match confirm {
|
||||
Some((pid, invert, expected)) => check_confirm(monitor, pid, invert, expected),
|
||||
None => {
|
||||
|
|
@ -527,9 +571,7 @@ async fn tick(
|
|||
|
||||
// Not yet confirmed: check timeout.
|
||||
if let Some(started) = runtime.step_started_at {
|
||||
let elapsed_ms = Utc::now()
|
||||
.signed_duration_since(started)
|
||||
.num_milliseconds();
|
||||
let elapsed_ms = Utc::now().signed_duration_since(started).num_milliseconds();
|
||||
if elapsed_ms >= step.timeout_ms as i64 {
|
||||
let _ = state.event_manager.send(AppEvent::AlarmActionTimeout {
|
||||
segment_id: segment.id,
|
||||
|
|
@ -542,8 +584,7 @@ async fn tick(
|
|||
}
|
||||
"block" => {
|
||||
runtime.state = SegmentState::Blocked;
|
||||
runtime.blocked_reason =
|
||||
Some(format!("step {} timeout", step_no));
|
||||
runtime.blocked_reason = Some(format!("step {} timeout", step_no));
|
||||
}
|
||||
_ => {
|
||||
// "fault" or unknown
|
||||
|
|
@ -585,9 +626,9 @@ async fn tick(
|
|||
runtime.held_resources.clear();
|
||||
runtime.last_completed_at = Some(Utc::now());
|
||||
runtime.current_step_no = None;
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(AppEvent::SegmentCompleted { segment_id: segment.id });
|
||||
let _ = state.event_manager.send(AppEvent::SegmentCompleted {
|
||||
segment_id: segment.id,
|
||||
});
|
||||
runtime.state = SegmentState::Idle;
|
||||
Some(runtime)
|
||||
}
|
||||
|
|
@ -651,21 +692,36 @@ fn next_sequential(steps: &[SegmentStep], current: i32) -> Option<i32> {
|
|||
}
|
||||
|
||||
/// Returns `(point_id, invert, expected_value)` if a confirm signal is configured.
|
||||
/// Missing bindings for an explicitly configured role are configuration faults,
|
||||
/// not optional confirms.
|
||||
fn resolve_confirm_point(
|
||||
step: &SegmentStep,
|
||||
ctx: &InterlockContext,
|
||||
) -> Option<(Uuid, bool, bool)> {
|
||||
) -> Result<Option<(Uuid, bool, bool)>, String> {
|
||||
if let Some(point_id) = step.confirm_point_id {
|
||||
return Some((point_id, false, step.expected_value));
|
||||
return Ok(Some((point_id, false, step.expected_value)));
|
||||
}
|
||||
let role = step.confirm_signal_role.as_deref()?;
|
||||
let station_id = step.target_station_id?;
|
||||
let Some(role) = step.confirm_signal_role.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let station_id = step.target_station_id.ok_or_else(|| {
|
||||
format!(
|
||||
"step {} confirm signal role '{}' requires target_station_id",
|
||||
step.step_no, role
|
||||
)
|
||||
})?;
|
||||
let (pid, invert) = ctx
|
||||
.station_role_points
|
||||
.get(&station_id)
|
||||
.and_then(|m| m.get(role))
|
||||
.copied()?;
|
||||
Some((pid, invert, step.expected_value))
|
||||
.copied()
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"step {} confirm signal role '{}' could not be resolved",
|
||||
step.step_no, role
|
||||
)
|
||||
})?;
|
||||
Ok(Some((pid, invert, step.expected_value)))
|
||||
}
|
||||
|
||||
fn check_confirm(
|
||||
|
|
@ -687,9 +743,7 @@ fn should_wait(runtime: &SegmentRuntime, mode: &str) -> bool {
|
|||
match runtime.state {
|
||||
SegmentState::Idle => !runtime.auto_enabled || mode != "auto",
|
||||
SegmentState::Confirming => true,
|
||||
SegmentState::Blocked
|
||||
| SegmentState::Faulted
|
||||
| SegmentState::ManualAckRequired => true,
|
||||
SegmentState::Blocked | SegmentState::Faulted | SegmentState::ManualAckRequired => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
|
@ -712,3 +766,153 @@ async fn push_runtime_change(state: &AppState, runtime: &SegmentRuntime) {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
|
||||
fn test_segment() -> ProcessSegment {
|
||||
ProcessSegment {
|
||||
id: Uuid::new_v4(),
|
||||
code: "SEG-TEST".to_string(),
|
||||
name: "Test Segment".to_string(),
|
||||
segment_type: "test".to_string(),
|
||||
line_code: None,
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
mode: "auto".to_string(),
|
||||
require_manual_ack_after_fault: true,
|
||||
description: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_step(station_id: Uuid) -> SegmentStep {
|
||||
SegmentStep {
|
||||
id: Uuid::new_v4(),
|
||||
segment_id: Uuid::new_v4(),
|
||||
step_no: 1,
|
||||
step_code: "WAIT_ARRIVED".to_string(),
|
||||
action_kind: "wait_signal".to_string(),
|
||||
target_equipment_id: None,
|
||||
target_station_id: Some(station_id),
|
||||
confirm_signal_role: Some("arrived".to_string()),
|
||||
confirm_point_id: None,
|
||||
expected_value: true,
|
||||
timeout_ms: 30_000,
|
||||
command_role: None,
|
||||
stop_command_role: None,
|
||||
pulse_ms: None,
|
||||
hold_until_confirm: false,
|
||||
cancel_on_fault: true,
|
||||
next_step_no_on_success: None,
|
||||
next_step_no_on_failure: None,
|
||||
on_timeout: "fault".to_string(),
|
||||
description: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_step_with_confirm_point(point_id: Uuid) -> SegmentStep {
|
||||
let mut step = test_step(Uuid::new_v4());
|
||||
step.confirm_signal_role = None;
|
||||
step.confirm_point_id = Some(point_id);
|
||||
step.hold_until_confirm = true;
|
||||
step
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirming_faults_when_configured_confirm_role_cannot_resolve() {
|
||||
let state = crate::app::test_state();
|
||||
let segment = test_segment();
|
||||
let station_id = Uuid::new_v4();
|
||||
let steps = vec![test_step(station_id)];
|
||||
let ctx = InterlockContext {
|
||||
equipment_role_points: HashMap::new(),
|
||||
station_role_points: HashMap::new(),
|
||||
};
|
||||
let runtime = SegmentRuntime {
|
||||
segment_id: segment.id,
|
||||
state: SegmentState::Confirming,
|
||||
auto_enabled: true,
|
||||
current_step_no: Some(1),
|
||||
step_started_at: Some(Utc::now()),
|
||||
last_completed_at: None,
|
||||
blocked_reason: None,
|
||||
fault_message: None,
|
||||
manual_ack_required: false,
|
||||
comm_locked: false,
|
||||
rem_local: false,
|
||||
held_resources: Vec::new(),
|
||||
};
|
||||
|
||||
let updated = tick(
|
||||
&state,
|
||||
&segment,
|
||||
&steps,
|
||||
&[],
|
||||
&[],
|
||||
&ctx,
|
||||
&CommandPointIndex::default(),
|
||||
&HashMap::new(),
|
||||
runtime,
|
||||
)
|
||||
.await
|
||||
.expect("missing configured confirm point should change runtime");
|
||||
|
||||
assert_eq!(updated.state, SegmentState::Faulted);
|
||||
assert_eq!(
|
||||
updated.fault_message.as_deref(),
|
||||
Some("step 1 confirm signal role 'arrived' could not be resolved")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_segment_moves_to_manual_ack_when_auto_is_stopped() {
|
||||
let state = crate::app::test_state();
|
||||
let segment = test_segment();
|
||||
let steps = vec![test_step_with_confirm_point(Uuid::new_v4())];
|
||||
let ctx = InterlockContext {
|
||||
equipment_role_points: HashMap::new(),
|
||||
station_role_points: HashMap::new(),
|
||||
};
|
||||
let runtime = SegmentRuntime {
|
||||
segment_id: segment.id,
|
||||
state: SegmentState::Confirming,
|
||||
auto_enabled: false,
|
||||
current_step_no: Some(1),
|
||||
step_started_at: Some(Utc::now()),
|
||||
last_completed_at: None,
|
||||
blocked_reason: None,
|
||||
fault_message: None,
|
||||
manual_ack_required: false,
|
||||
comm_locked: false,
|
||||
rem_local: false,
|
||||
held_resources: Vec::new(),
|
||||
};
|
||||
|
||||
let updated = tick(
|
||||
&state,
|
||||
&segment,
|
||||
&steps,
|
||||
&[],
|
||||
&[],
|
||||
&ctx,
|
||||
&CommandPointIndex::default(),
|
||||
&HashMap::new(),
|
||||
runtime,
|
||||
)
|
||||
.await
|
||||
.expect("active segment should react to auto stop");
|
||||
|
||||
assert_eq!(updated.state, SegmentState::ManualAckRequired);
|
||||
assert_eq!(updated.current_step_no, Some(1));
|
||||
assert!(updated.manual_ack_required);
|
||||
assert_eq!(
|
||||
updated.blocked_reason.as_deref(),
|
||||
Some("auto stopped during active step")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use plc_platform_core::telemetry::PointMonitorInfo;
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::SegmentStep;
|
||||
use crate::model::{SegmentInterlock, StationSignal};
|
||||
|
||||
use super::{monitor_quality_good, monitor_value_as_bool};
|
||||
|
|
@ -35,6 +36,25 @@ impl InterlockContext {
|
|||
Self::load(pool, &equipment_ids, &station_ids).await
|
||||
}
|
||||
|
||||
pub async fn load_for_segment(
|
||||
pool: &PgPool,
|
||||
steps: &[SegmentStep],
|
||||
interlocks: &[SegmentInterlock],
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
let mut equipment_ids: Vec<Uuid> =
|
||||
interlocks.iter().filter_map(|i| i.equipment_id).collect();
|
||||
equipment_ids.extend(steps.iter().filter_map(|s| s.target_equipment_id));
|
||||
equipment_ids.sort();
|
||||
equipment_ids.dedup();
|
||||
|
||||
let mut station_ids: Vec<Uuid> = interlocks.iter().filter_map(|i| i.station_id).collect();
|
||||
station_ids.extend(steps.iter().filter_map(|s| s.target_station_id));
|
||||
station_ids.sort();
|
||||
station_ids.dedup();
|
||||
|
||||
Self::load(pool, &equipment_ids, &station_ids).await
|
||||
}
|
||||
|
||||
pub async fn load(
|
||||
pool: &PgPool,
|
||||
equipment_ids: &[Uuid],
|
||||
|
|
@ -42,7 +62,9 @@ impl InterlockContext {
|
|||
) -> Result<Self, sqlx::Error> {
|
||||
let mut equipment_role_points: HashMap<Uuid, HashMap<String, Uuid>> = HashMap::new();
|
||||
if !equipment_ids.is_empty() {
|
||||
let rows = plc_platform_core::service::get_signal_role_points_batch(pool, equipment_ids).await?;
|
||||
let rows =
|
||||
plc_platform_core::service::get_signal_role_points_batch(pool, equipment_ids)
|
||||
.await?;
|
||||
for row in rows {
|
||||
equipment_role_points
|
||||
.entry(row.equipment_id)
|
||||
|
|
@ -137,23 +159,28 @@ pub fn evaluate(
|
|||
.ok_or_else(|| format!("station_vacant rule {} missing station_id", rule.id))?;
|
||||
// Prefer explicit vacancy signal; fall back to !presence.
|
||||
if let Some((pid, invert)) = resolve_station_point(ctx, station_id, "vacancy") {
|
||||
let v = read_logical_bool(monitor, pid, invert)
|
||||
.ok_or_else(|| format!("vacancy signal for station {} unavailable", station_id))?;
|
||||
let v = read_logical_bool(monitor, pid, invert).ok_or_else(|| {
|
||||
format!("vacancy signal for station {} unavailable", station_id)
|
||||
})?;
|
||||
if v {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("station {} occupied (vacancy=false)", station_id))
|
||||
}
|
||||
} else if let Some((pid, invert)) = resolve_station_point(ctx, station_id, "presence") {
|
||||
let v = read_logical_bool(monitor, pid, invert)
|
||||
.ok_or_else(|| format!("presence signal for station {} unavailable", station_id))?;
|
||||
let v = read_logical_bool(monitor, pid, invert).ok_or_else(|| {
|
||||
format!("presence signal for station {} unavailable", station_id)
|
||||
})?;
|
||||
if !v {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("station {} occupied (presence=true)", station_id))
|
||||
}
|
||||
} else {
|
||||
Err(format!("station {} has no presence/vacancy binding", station_id))
|
||||
Err(format!(
|
||||
"station {} has no presence/vacancy binding",
|
||||
station_id
|
||||
))
|
||||
}
|
||||
}
|
||||
"station_occupied" => {
|
||||
|
|
@ -161,8 +188,9 @@ pub fn evaluate(
|
|||
.station_id
|
||||
.ok_or_else(|| format!("station_occupied rule {} missing station_id", rule.id))?;
|
||||
if let Some((pid, invert)) = resolve_station_point(ctx, station_id, "presence") {
|
||||
let v = read_logical_bool(monitor, pid, invert)
|
||||
.ok_or_else(|| format!("presence signal for station {} unavailable", station_id))?;
|
||||
let v = read_logical_bool(monitor, pid, invert).ok_or_else(|| {
|
||||
format!("presence signal for station {} unavailable", station_id)
|
||||
})?;
|
||||
if v {
|
||||
Ok(())
|
||||
} else {
|
||||
|
|
@ -172,7 +200,9 @@ pub fn evaluate(
|
|||
Err(format!("station {} has no presence binding", station_id))
|
||||
}
|
||||
}
|
||||
"equipment_origin" => check_equipment_role(rule, ctx, monitor, "home", true, "not at origin"),
|
||||
"equipment_origin" => {
|
||||
check_equipment_role(rule, ctx, monitor, "home", true, "not at origin")
|
||||
}
|
||||
"equipment_no_fault" => {
|
||||
check_equipment_role(rule, ctx, monitor, "flt", false, "fault active")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,10 +93,7 @@ impl ResourceRegistry {
|
|||
///
|
||||
/// Recovery path from design doc §7 — a panicked or stuck segment task can
|
||||
/// otherwise keep a public resource locked indefinitely.
|
||||
pub async fn sweep_stale(
|
||||
&self,
|
||||
max_age: chrono::Duration,
|
||||
) -> Vec<(String, Uuid)> {
|
||||
pub async fn sweep_stale(&self, max_age: chrono::Duration) -> Vec<(String, Uuid)> {
|
||||
let cutoff = Utc::now() - max_age;
|
||||
let mut reclaimed = Vec::new();
|
||||
let mut inner = self.inner.write().await;
|
||||
|
|
|
|||
|
|
@ -66,18 +66,12 @@ impl SegmentRuntimeStore {
|
|||
}
|
||||
|
||||
let runtime = SegmentRuntime::new(segment_id);
|
||||
self.inner
|
||||
.write()
|
||||
.await
|
||||
.insert(segment_id, runtime.clone());
|
||||
self.inner.write().await.insert(segment_id, runtime.clone());
|
||||
runtime
|
||||
}
|
||||
|
||||
pub async fn upsert(&self, runtime: SegmentRuntime) {
|
||||
self.inner
|
||||
.write()
|
||||
.await
|
||||
.insert(runtime.segment_id, runtime);
|
||||
self.inner.write().await.insert(runtime.segment_id, runtime);
|
||||
}
|
||||
|
||||
pub async fn get_or_create_notify(&self, segment_id: Uuid) -> Arc<Notify> {
|
||||
|
|
|
|||
|
|
@ -163,9 +163,12 @@ pub async fn send_stop_command(
|
|||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let equipment_id = step
|
||||
.target_equipment_id
|
||||
.ok_or_else(|| format!("step {} stop command missing target_equipment_id", step.step_no))?;
|
||||
let equipment_id = step.target_equipment_id.ok_or_else(|| {
|
||||
format!(
|
||||
"step {} stop command missing target_equipment_id",
|
||||
step.step_no
|
||||
)
|
||||
})?;
|
||||
let point_id = command_points.lookup(equipment_id, role).ok_or_else(|| {
|
||||
format!(
|
||||
"equipment {} has no '{}' stop-role binding",
|
||||
|
|
|
|||
|
|
@ -3,17 +3,18 @@
|
|||
//! These endpoints flip flags on the in-memory `SegmentRuntime` and notify the
|
||||
//! segment task. The engine task picks up the change on its next tick.
|
||||
|
||||
use axum::{extract::{Path, State}, response::IntoResponse, Json};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use plc_platform_core::util::response::ApiErr;
|
||||
|
||||
use crate::{
|
||||
control::state::SegmentState,
|
||||
event::AppEvent,
|
||||
service::segment as segment_service,
|
||||
AppState,
|
||||
control::state::SegmentState, event::AppEvent, service::segment as segment_service, AppState,
|
||||
};
|
||||
|
||||
async fn require_segment(
|
||||
|
|
@ -159,9 +160,7 @@ pub async fn reset_segment(
|
|||
))
|
||||
}
|
||||
|
||||
pub async fn batch_start_auto(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
pub async fn batch_start_auto(State(state): State<AppState>) -> Result<impl IntoResponse, ApiErr> {
|
||||
let segments = segment_service::list_segments(&state.platform.pool, None).await?;
|
||||
let mut started = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
|
|
@ -182,9 +181,9 @@ pub async fn batch_start_auto(
|
|||
runtime.auto_enabled = true;
|
||||
state.segment_runtime.upsert(runtime).await;
|
||||
state.segment_runtime.notify_segment(segment.id).await;
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(AppEvent::SegmentAutoStarted { segment_id: segment.id });
|
||||
let _ = state.event_manager.send(AppEvent::SegmentAutoStarted {
|
||||
segment_id: segment.id,
|
||||
});
|
||||
started.push(segment.id);
|
||||
}
|
||||
Ok(Json(json!({ "started": started, "skipped": skipped })))
|
||||
|
|
@ -201,9 +200,9 @@ pub async fn batch_stop_auto(State(state): State<AppState>) -> Result<impl IntoR
|
|||
runtime.auto_enabled = false;
|
||||
state.segment_runtime.upsert(runtime).await;
|
||||
state.segment_runtime.notify_segment(segment.id).await;
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(AppEvent::SegmentAutoStopped { segment_id: segment.id });
|
||||
let _ = state.event_manager.send(AppEvent::SegmentAutoStopped {
|
||||
segment_id: segment.id,
|
||||
});
|
||||
stopped.push(segment.id);
|
||||
}
|
||||
Ok(Json(json!({ "stopped": stopped })))
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
//! migration so the front-end can show a per-segment / per-station timeline
|
||||
//! without parsing event_type strings.
|
||||
|
||||
use axum::{extract::{Query, State}, response::IntoResponse, Json};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
//! Runtime read endpoints (design doc §9.3).
|
||||
|
||||
use axum::{extract::{Path, State}, response::IntoResponse, Json};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -78,7 +82,9 @@ pub async fn get_station_runtime(
|
|||
let signal_payload: Vec<_> = signals
|
||||
.iter()
|
||||
.map(|sig| {
|
||||
let monitor = sig.point_id.and_then(|pid| monitor_guard.get(&pid).cloned());
|
||||
let monitor = sig
|
||||
.point_id
|
||||
.and_then(|pid| monitor_guard.get(&pid).cloned());
|
||||
json!({
|
||||
"signal": sig,
|
||||
"point_monitor": monitor,
|
||||
|
|
|
|||
|
|
@ -11,10 +11,7 @@ use validator::Validate;
|
|||
|
||||
use plc_platform_core::util::response::ApiErr;
|
||||
|
||||
use crate::{
|
||||
service::segment as segment_service,
|
||||
AppState,
|
||||
};
|
||||
use crate::{service::segment as segment_service, AppState};
|
||||
|
||||
const SEGMENT_TYPES: &[&str] = &[
|
||||
"front_load",
|
||||
|
|
@ -106,10 +103,8 @@ pub async fn get_segment_detail(
|
|||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Segment not found".to_string(), None))?;
|
||||
let steps = segment_service::list_steps(&state.platform.pool, segment_id).await?;
|
||||
let interlocks =
|
||||
segment_service::list_interlocks(&state.platform.pool, segment_id).await?;
|
||||
let resources =
|
||||
segment_service::list_resources(&state.platform.pool, segment_id).await?;
|
||||
let interlocks = segment_service::list_interlocks(&state.platform.pool, segment_id).await?;
|
||||
let resources = segment_service::list_resources(&state.platform.pool, segment_id).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"segment": segment,
|
||||
|
|
@ -436,8 +431,7 @@ pub async fn list_interlocks(
|
|||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let interlocks =
|
||||
segment_service::list_interlocks(&state.platform.pool, segment_id).await?;
|
||||
let interlocks = segment_service::list_interlocks(&state.platform.pool, segment_id).await?;
|
||||
Ok(Json(interlocks))
|
||||
}
|
||||
|
||||
|
|
@ -506,8 +500,7 @@ pub async fn list_resources(
|
|||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let resources =
|
||||
segment_service::list_resources(&state.platform.pool, segment_id).await?;
|
||||
let resources = segment_service::list_resources(&state.platform.pool, segment_id).await?;
|
||||
Ok(Json(resources))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,12 +27,7 @@ const STATION_TYPES: &[&str] = &[
|
|||
];
|
||||
|
||||
const SIGNAL_ROLES: &[&str] = &[
|
||||
"presence",
|
||||
"vacancy",
|
||||
"arrived",
|
||||
"allow_in",
|
||||
"done",
|
||||
"fault",
|
||||
"presence", "vacancy", "arrived", "allow_in", "done", "fault",
|
||||
];
|
||||
|
||||
fn validate_station_type(value: &str) -> Result<(), ApiErr> {
|
||||
|
|
@ -80,8 +75,7 @@ pub async fn get_station(
|
|||
let station = station_service::get_station_by_id(&state.platform.pool, station_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Station not found".to_string(), None))?;
|
||||
let signals =
|
||||
station_service::list_station_signals(&state.platform.pool, station_id).await?;
|
||||
let signals = station_service::list_station_signals(&state.platform.pool, station_id).await?;
|
||||
Ok(Json(json!({
|
||||
"station": station,
|
||||
"signals": signals,
|
||||
|
|
|
|||
|
|
@ -48,13 +48,11 @@ pub fn build_router(state: AppState) -> Router {
|
|||
)
|
||||
.route(
|
||||
"/api/segment/{segment_id}/step",
|
||||
get(crate::handler::segment::list_steps)
|
||||
.post(crate::handler::segment::create_step),
|
||||
get(crate::handler::segment::list_steps).post(crate::handler::segment::create_step),
|
||||
)
|
||||
.route(
|
||||
"/api/segment/{segment_id}/step/{step_no}",
|
||||
put(crate::handler::segment::update_step)
|
||||
.delete(crate::handler::segment::delete_step),
|
||||
put(crate::handler::segment::update_step).delete(crate::handler::segment::delete_step),
|
||||
)
|
||||
.route(
|
||||
"/api/segment/{segment_id}/interlock",
|
||||
|
|
@ -110,10 +108,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||
"/api/runtime/station/{station_id}",
|
||||
get(crate::handler::runtime::get_station_runtime),
|
||||
)
|
||||
.route(
|
||||
"/api/event",
|
||||
get(crate::handler::event::get_event_list),
|
||||
);
|
||||
.route("/api/event", get(crate::handler::event::get_event_list));
|
||||
|
||||
let ops_routes = Router::new()
|
||||
.route("/api/health", get(health_check))
|
||||
|
|
|
|||
|
|
@ -69,7 +69,9 @@ pub async fn ensure_default_templates(pool: &PgPool) -> Result<TemplateReport, s
|
|||
.bind(segment.code)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(segment_id) = segment_id else { continue };
|
||||
let Some(segment_id) = segment_id else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Resource declarations are idempotent at the row level (UNIQUE
|
||||
// constraint). Insert each declared key.
|
||||
|
|
@ -182,8 +184,18 @@ fn public_template_stations() -> Vec<StationTemplate> {
|
|||
let stations: &[(&'static str, &'static str, &'static str, &'static str)] = &[
|
||||
// (code, name, segment_code, station_type)
|
||||
("ST-FRONT-LOAD", "前端码车位", "FRONT_LOAD", "load"),
|
||||
("ST-FRONT-TRANSFER", "前端摆渡接车位", "FRONT_TRANSFER", "transfer"),
|
||||
("ST-TAIL-TRANSFER", "窑尾摆渡接车位", "TAIL_TRANSFER", "transfer"),
|
||||
(
|
||||
"ST-FRONT-TRANSFER",
|
||||
"前端摆渡接车位",
|
||||
"FRONT_TRANSFER",
|
||||
"transfer",
|
||||
),
|
||||
(
|
||||
"ST-TAIL-TRANSFER",
|
||||
"窑尾摆渡接车位",
|
||||
"TAIL_TRANSFER",
|
||||
"transfer",
|
||||
),
|
||||
("ST-UNLOAD", "卸砖机位", "UNLOAD", "unload"),
|
||||
("ST-RETURN-IN", "回车线入口位", "RETURN", "return"),
|
||||
];
|
||||
|
|
@ -220,24 +232,62 @@ fn default_template_segments() -> Vec<SegmentTemplate> {
|
|||
fn kiln_template_segments() -> Vec<SegmentTemplate> {
|
||||
let entries: &[(&'static str, &'static str, &'static str, &'static str, i32)] = &[
|
||||
// (line, code, name, segment_type, priority)
|
||||
("KILN_1", "SEG-DRY1-INFEED", "1 号干燥窑进口段", "kiln_infeed", 10),
|
||||
("KILN_1", "SEG-DRY1-STEP", "1 号干燥窑内前移段", "kiln_step", 5),
|
||||
("KILN_1", "SEG-DRY1-OUTFEED", "1 号干燥窑出口段", "kiln_outfeed", 10),
|
||||
("KILN_2", "SEG-DRY2-INFEED", "2 号干燥窑进口段", "kiln_infeed", 10),
|
||||
("KILN_2", "SEG-DRY2-STEP", "2 号干燥窑内前移段", "kiln_step", 5),
|
||||
("KILN_2", "SEG-DRY2-OUTFEED", "2 号干燥窑出口段", "kiln_outfeed", 10),
|
||||
(
|
||||
"KILN_1",
|
||||
"SEG-DRY1-INFEED",
|
||||
"1 号干燥窑进口段",
|
||||
"kiln_infeed",
|
||||
10,
|
||||
),
|
||||
(
|
||||
"KILN_1",
|
||||
"SEG-DRY1-STEP",
|
||||
"1 号干燥窑内前移段",
|
||||
"kiln_step",
|
||||
5,
|
||||
),
|
||||
(
|
||||
"KILN_1",
|
||||
"SEG-DRY1-OUTFEED",
|
||||
"1 号干燥窑出口段",
|
||||
"kiln_outfeed",
|
||||
10,
|
||||
),
|
||||
(
|
||||
"KILN_2",
|
||||
"SEG-DRY2-INFEED",
|
||||
"2 号干燥窑进口段",
|
||||
"kiln_infeed",
|
||||
10,
|
||||
),
|
||||
(
|
||||
"KILN_2",
|
||||
"SEG-DRY2-STEP",
|
||||
"2 号干燥窑内前移段",
|
||||
"kiln_step",
|
||||
5,
|
||||
),
|
||||
(
|
||||
"KILN_2",
|
||||
"SEG-DRY2-OUTFEED",
|
||||
"2 号干燥窑出口段",
|
||||
"kiln_outfeed",
|
||||
10,
|
||||
),
|
||||
];
|
||||
entries
|
||||
.iter()
|
||||
.map(|(line, code, name, segment_type, priority)| SegmentTemplate {
|
||||
code,
|
||||
name,
|
||||
segment_type,
|
||||
line_code: line,
|
||||
priority: *priority,
|
||||
mode: "disabled",
|
||||
description: "Seeded skeleton; bind equipment + station signals to enable.",
|
||||
})
|
||||
.map(
|
||||
|(line, code, name, segment_type, priority)| SegmentTemplate {
|
||||
code,
|
||||
name,
|
||||
segment_type,
|
||||
line_code: line,
|
||||
priority: *priority,
|
||||
mode: "disabled",
|
||||
description: "Seeded skeleton; bind equipment + station signals to enable.",
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
|
@ -503,11 +553,23 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn resource_keys_match_design_doc_section_7() {
|
||||
assert_eq!(default_segment_resources("SEG-FRONT-TRANSFER"), vec!["transfer_front"]);
|
||||
assert_eq!(default_segment_resources("SEG-TAIL-TRANSFER"), vec!["transfer_tail"]);
|
||||
assert_eq!(default_segment_resources("SEG-UNLOAD"), vec!["unload_position"]);
|
||||
assert_eq!(
|
||||
default_segment_resources("SEG-FRONT-TRANSFER"),
|
||||
vec!["transfer_front"]
|
||||
);
|
||||
assert_eq!(
|
||||
default_segment_resources("SEG-TAIL-TRANSFER"),
|
||||
vec!["transfer_tail"]
|
||||
);
|
||||
assert_eq!(
|
||||
default_segment_resources("SEG-UNLOAD"),
|
||||
vec!["unload_position"]
|
||||
);
|
||||
assert_eq!(default_segment_resources("SEG-RETURN"), vec!["return_line"]);
|
||||
assert_eq!(default_segment_resources("SEG-FRONT-LOAD"), vec!["robot_arm"]);
|
||||
assert_eq!(
|
||||
default_segment_resources("SEG-FRONT-LOAD"),
|
||||
vec!["robot_arm"]
|
||||
);
|
||||
assert!(default_segment_resources("SEG-DRY1-INFEED").is_empty());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ pub async fn list_segments(
|
|||
.bind(line)
|
||||
.fetch_all(pool)
|
||||
.await,
|
||||
None => sqlx::query_as::<_, ProcessSegment>(
|
||||
r#"SELECT * FROM process_segment ORDER BY priority DESC, code"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await,
|
||||
None => {
|
||||
sqlx::query_as::<_, ProcessSegment>(
|
||||
r#"SELECT * FROM process_segment ORDER BY priority DESC, code"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -143,10 +145,7 @@ pub async fn delete_segment(pool: &PgPool, segment_id: Uuid) -> Result<bool, sql
|
|||
|
||||
// segment_step
|
||||
|
||||
pub async fn list_steps(
|
||||
pool: &PgPool,
|
||||
segment_id: Uuid,
|
||||
) -> Result<Vec<SegmentStep>, sqlx::Error> {
|
||||
pub async fn list_steps(pool: &PgPool, segment_id: Uuid) -> Result<Vec<SegmentStep>, sqlx::Error> {
|
||||
sqlx::query_as::<_, SegmentStep>(
|
||||
r#"SELECT * FROM segment_step WHERE segment_id = $1 ORDER BY step_no"#,
|
||||
)
|
||||
|
|
@ -319,12 +318,11 @@ pub async fn delete_step(
|
|||
segment_id: Uuid,
|
||||
step_no: i32,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let result =
|
||||
sqlx::query(r#"DELETE FROM segment_step WHERE segment_id = $1 AND step_no = $2"#)
|
||||
.bind(segment_id)
|
||||
.bind(step_no)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let result = sqlx::query(r#"DELETE FROM segment_step WHERE segment_id = $1 AND step_no = $2"#)
|
||||
.bind(segment_id)
|
||||
.bind(step_no)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
|
|
@ -387,13 +385,11 @@ pub async fn delete_interlock(
|
|||
segment_id: Uuid,
|
||||
interlock_id: Uuid,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
r#"DELETE FROM segment_interlock WHERE segment_id = $1 AND id = $2"#,
|
||||
)
|
||||
.bind(segment_id)
|
||||
.bind(interlock_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let result = sqlx::query(r#"DELETE FROM segment_interlock WHERE segment_id = $1 AND id = $2"#)
|
||||
.bind(segment_id)
|
||||
.bind(interlock_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,15 +8,19 @@ pub async fn list_stations(
|
|||
line_code: Option<&str>,
|
||||
) -> Result<Vec<Station>, sqlx::Error> {
|
||||
match line_code {
|
||||
Some(line) => sqlx::query_as::<_, Station>(
|
||||
r#"SELECT * FROM station WHERE line_code = $1 ORDER BY code"#,
|
||||
)
|
||||
.bind(line)
|
||||
.fetch_all(pool)
|
||||
.await,
|
||||
None => sqlx::query_as::<_, Station>(r#"SELECT * FROM station ORDER BY code"#)
|
||||
Some(line) => {
|
||||
sqlx::query_as::<_, Station>(
|
||||
r#"SELECT * FROM station WHERE line_code = $1 ORDER BY code"#,
|
||||
)
|
||||
.bind(line)
|
||||
.fetch_all(pool)
|
||||
.await,
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as::<_, Station>(r#"SELECT * FROM station ORDER BY code"#)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -180,12 +184,11 @@ pub async fn delete_station_signal(
|
|||
station_id: Uuid,
|
||||
signal_role: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
r#"DELETE FROM station_signal WHERE station_id = $1 AND signal_role = $2"#,
|
||||
)
|
||||
.bind(station_id)
|
||||
.bind(signal_role)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let result =
|
||||
sqlx::query(r#"DELETE FROM station_signal WHERE station_id = $1 AND signal_role = $2"#)
|
||||
.bind(station_id)
|
||||
.bind(signal_role)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue