Compare commits
33 Commits
093fc5035b
...
07239ebff1
| Author | SHA1 | Date |
|---|---|---|
|
|
07239ebff1 | |
|
|
5613c9f0d5 | |
|
|
ed638eadb2 | |
|
|
c5983ab5c3 | |
|
|
d616829988 | |
|
|
b84ce744d3 | |
|
|
3667d64243 | |
|
|
e2248fa04f | |
|
|
972938a8e6 | |
|
|
a7f5c85032 | |
|
|
ed1067f6e5 | |
|
|
e3e7917078 | |
|
|
aaf48a336d | |
|
|
63683a24c8 | |
|
|
a33c013da5 | |
|
|
fd028b1320 | |
|
|
19ace9c2be | |
|
|
3467f203ca | |
|
|
2a6dde9e0e | |
|
|
3b92c0028a | |
|
|
52cd3e630e | |
|
|
6c8e5561dc | |
|
|
3e0d4c242b | |
|
|
1c646dfaa7 | |
|
|
58fdb9f58e | |
|
|
706fb4f72a | |
|
|
087f016f01 | |
|
|
dabcde1fca | |
|
|
a49f6adf9b | |
|
|
f8ba864a65 | |
|
|
24b1d3546b | |
|
|
1317271e16 | |
|
|
6814e9eae9 |
|
|
@ -31,4 +31,6 @@ Thumbs.db
|
|||
.VSCodeCounter/
|
||||
cl.bat
|
||||
col.bat
|
||||
cl.ps1
|
||||
col.ps1
|
||||
.worktrees/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
# CLAUDE.md
|
||||
|
||||
本仓库是一个 Cargo workspace,承载两个 PLC 上位机应用,共享一个平台核心库与一套前端基础设施。
|
||||
|
||||
## 两个应用
|
||||
|
||||
- **投煤控制系统** — crate `app_feeder_distributor`,前端 `web/feeder/`,默认端口 `60309`,单实例锁名 `PLCControl.FeederDistributor`(约定)。业务模型是 `control unit + run/stop/acc/bl 时长`。
|
||||
- **隧道窑运转系统** — crate `app_operation_system`,前端 `web/ops/`,默认端口 `3100`,单实例锁名 `PLCControl.OperationSystem`。业务模型是 `工位 + 流程段(segment) + 步骤 + 联锁 + 完成确认`,不要套用 feeder 的 unit 模型。
|
||||
- 共享核心库 `plc_platform_core`:config / db / connection(OPC UA) / event / websocket / service / 平台 handler。
|
||||
|
||||
两个应用页签完全一致:**运行监控 / 应用配置 / 平台配置**,布局也对齐——**系统事件在「运行监控」**,**实时日志(SSE)在「平台配置」**。feeder 的「应用配置」是控制单元,ops 的是段/工位配置。
|
||||
|
||||
## 前端架构(关键、易踩坑)
|
||||
|
||||
- `/ui` 路由 = `ServeDir(应用目录).fallback(ServeDir("web/core"))`(见 `plc_platform_core/src/http.rs::static_ui_routes`)。**物理放在 `web/core/` 的文件,对两个应用都暴露在相同的 `/ui/*` URL**。所以共享前端无需改 import 路径,只要移动文件位置。
|
||||
- **共享平台 JS 在 `web/core/js/platform/`**(api/dom/state/roles/sources/points/equipment/events/chart/docs/platform-config)。放子目录是有意为之:`web/ops/js/` 自带 **同名但不同内容**的 `api.js`、`dom.js`(导出 `segmentApi`/`el`,而非 `apiFetch`/`dom`)。若把共享模块放 `web/core/js/` 顶层,会被 ops 本地同名文件 shadow。放 `js/platform/` 子目录后两个应用都回退到 core,得到单一实例。
|
||||
- 平台配置页的事件绑定 / 初始化 / 数据加载统一在 `web/core/js/platform/platform-config.js`:`bindPlatformConfigEvents()` / `initPlatformConfigUi()` / `loadPlatformConfig()`,全部 null-guard(各应用只含部分面板)。feeder 的 `app.js` 和 ops 的 `views.js` 都调用它,**不要再复制这套逻辑**。
|
||||
- `web/core/js/platform/events.js` 在**模块加载时**就给 `dom.eventList` 加 scroll 监听 → 凡引入平台 JS(events.js)的页面,DOM 里必须有 `#eventList`(`logs-panel.html`,现放在「运行监控」),否则 import 链在加载期就崩。
|
||||
- 实时日志(SSE `/api/logs/stream` → `#logView`)在共享的 `web/core/js/platform/log-stream.js`(`startLogs`/`stopLogs`),feeder 与 ops 的「平台配置」都用它。
|
||||
- HTML 是分片 `data-partial`,由各 `index.js` 先加载完所有 partial 再 `import('./app.js')`;core 的 `dom.js` 在 import 期 `byId`,依赖这个加载顺序。
|
||||
- 静态文件 ServeDir 直接读磁盘,**改 JS/HTML/CSS 不用重新编译**,刷新即可;改 Rust 才需重启进程(注意单实例锁,旧进程不退会占锁)。
|
||||
|
||||
## 后端要点
|
||||
|
||||
- ops 的 router 已 `merge(plc_platform_core::handler::platform_routes())`,平台 CRUD(source/point/equipment/tag/page)两个应用共用。`/api/event` 是 **ops 自己的** `runtime_routes`,不在 platform_routes 里。
|
||||
- **数据库迁移不自动执行**(`db.rs` 注释)。首次启动前手动跑 `migrations/`:`sqlx migrate run --source migrations`。
|
||||
- 事件类型用命名空间前缀:`platform.*` / `feeder.*` / `ops.*`。
|
||||
|
||||
## 构建 / 运行
|
||||
|
||||
```powershell
|
||||
cargo build -p app_operation_system # 或 -p app_feeder_distributor
|
||||
cargo run -p app_operation_system # 开发态
|
||||
```
|
||||
|
||||
ops 调试用环境变量:`OPS_SEED_TEMPLATES=1`(写入 12 段+11 工位骨架)、`SIMULATE_PLC=1`(自动回写确认信号,无 PLC 也能跑通段)。详见 `run.md`。
|
||||
|
||||
## 文档
|
||||
|
||||
- 运转系统方案:`docs/运转系统实现方案.md`
|
||||
- 双应用共享核心设计:`docs/superpowers/specs/2026-04-14-dual-app-shared-core-design.md`
|
||||
- API:`docs/api-feeder.md`、`docs/api-ops.md`
|
||||
|
|
@ -135,14 +135,20 @@ dependencies = [
|
|||
name = "app_operation_system"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"dotenv",
|
||||
"plc_platform_core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"validator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -57,9 +57,9 @@ plc_control/
|
|||
app_feeder_distributor/ # 投煤器布料机专用版
|
||||
app_operation_system/ # 运转系统专用版
|
||||
web/
|
||||
core/ # 共享 HTML/CSS(数据源、点位、设备、图表、日志等)
|
||||
feeder/ # 投煤器布料机页面 + JS
|
||||
ops/ # 运转系统页面 + JS
|
||||
core/ # 共享 HTML/CSS + 平台 JS(数据源、点位、设备、图表、日志等)
|
||||
feeder/ # 投煤控制系统页面 + 业务 JS
|
||||
ops/ # 隧道窑运转系统页面 + 业务 JS
|
||||
```
|
||||
|
||||
### 共享平台核心库 (`plc_platform_core`)
|
||||
|
|
@ -122,11 +122,11 @@ deploy/
|
|||
|
||||
前端采用原生 ES Module 和分片 HTML 结构,按应用拆分目录:
|
||||
|
||||
- `web/core/` — 共享 HTML 面板(数据源、点位、设备、图表、日志、文档抽屉)和样式
|
||||
- `web/feeder/` — 投煤器专用入口、运维面板、控制单元表单、全部 JS 模块
|
||||
- `web/ops/` — 运转系统专用入口(开发中)
|
||||
- `web/core/` — 共享 HTML 面板(数据源、点位、设备、图表、日志、文档抽屉)、样式,以及共享平台 JS `web/core/js/platform/`(数据源 / 点位 / 设备配置逻辑,feeder 与 ops 共用,避免重复)
|
||||
- `web/feeder/`(投煤控制系统)— 三个页签:运行监控 / 应用配置 / 平台配置;仅保留投煤业务 JS(控制单元、运行监控卡片等)
|
||||
- `web/ops/`(隧道窑运转系统)— 三个页签:运行监控 / 应用配置 / 平台配置;仅保留运转业务 JS(段、工位、联锁)
|
||||
|
||||
每个应用的 Axum 路由使用 `ServeDir` 回退链:先查应用目录,再查 core 目录,URL 路径无需变化。
|
||||
每个应用的 Axum 路由使用 `ServeDir` 回退链:先查应用目录,再查 core 目录,URL 路径无需变化。共享平台 JS 正是依赖该回退链——它放在 `web/core/js/platform/` 子目录,两个应用本地都没有同名子目录,故都回退到 core,得到同一份实例(也因此避开了 ops 自带 `web/ops/js/api.js`、`dom.js` 的同名冲突)。
|
||||
|
||||
## 实时日志设计
|
||||
|
||||
|
|
@ -159,8 +159,6 @@ deploy/
|
|||
- `DATABASE_URL`
|
||||
- `HOST`
|
||||
- `PORT`
|
||||
- `WRITE_API_KEY`
|
||||
- `SIMULATE_PLC`
|
||||
|
||||
## 文档索引
|
||||
|
||||
|
|
|
|||
|
|
@ -1,84 +1,53 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
config::AppConfig,
|
||||
connection::ConnectionManager,
|
||||
control,
|
||||
event::EventManager,
|
||||
router::build_router,
|
||||
websocket::WebSocketManager,
|
||||
use crate::{control, event::EventManager, router::build_router};
|
||||
use axum::extract::FromRef;
|
||||
use plc_platform_core::{bootstrap, websocket::WebSocketManager};
|
||||
use plc_platform_core::{
|
||||
config::ServerConfig, connection::ConnectionManager, platform_context::PlatformContext,
|
||||
};
|
||||
use plc_platform_core::platform_context::PlatformContext;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: AppConfig,
|
||||
pub config: ServerConfig,
|
||||
pub platform: PlatformContext,
|
||||
pub event_manager: Arc<EventManager>,
|
||||
pub control_runtime: Arc<control::runtime::ControlRuntimeStore>,
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for PlatformContext {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.platform.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run() {
|
||||
dotenv::dotenv().ok();
|
||||
plc_platform_core::util::log::init_logger();
|
||||
let _single_instance =
|
||||
match plc_platform_core::util::single_instance::try_acquire("PLCControl.FeederDistributor") {
|
||||
Ok(guard) => guard,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
tracing::warn!("Another feeder distributor instance is already running");
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to initialize single instance guard: {}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(_single_instance) = bootstrap::init_process(
|
||||
"PLCControl.FeederDistributor",
|
||||
"Another feeder distributor instance is already running",
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let config = AppConfig::from_env().expect("Failed to load configuration");
|
||||
let mut builder = plc_platform_core::bootstrap::bootstrap_platform(&config.database_url)
|
||||
let config = ServerConfig::from_env("HOST", "0.0.0.0", "PORT", 60309)
|
||||
.expect("Failed to load server configuration");
|
||||
let builder = bootstrap::bootstrap_platform(&config.database_url)
|
||||
.await
|
||||
.expect("Failed to bootstrap platform");
|
||||
|
||||
let event_manager = Arc::new(EventManager::new(
|
||||
builder.pool.clone(),
|
||||
Arc::new(builder.connection_manager.clone()),
|
||||
Some(builder.ws_manager.clone()),
|
||||
));
|
||||
|
||||
builder.connection_manager.set_event_manager(event_manager.clone());
|
||||
builder.connection_manager.set_pool_and_start_reconnect_task(Arc::new(builder.pool.clone()));
|
||||
|
||||
let control_runtime = Arc::new(control::runtime::ControlRuntimeStore::new());
|
||||
let platform = builder.build();
|
||||
|
||||
let control_runtime = Arc::new(control::runtime::ControlRuntimeStore::new());
|
||||
let event_manager = Arc::new(EventManager::new(
|
||||
platform.pool.clone(),
|
||||
Some(platform.ws_manager.clone()),
|
||||
platform.metadata.clone(),
|
||||
));
|
||||
|
||||
let sources = crate::service::get_all_enabled_sources(&platform.pool)
|
||||
bootstrap::connect_all_enabled_sources(&platform)
|
||||
.await
|
||||
.expect("Failed to fetch sources");
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for source in sources {
|
||||
let cm = platform.connection_manager.clone();
|
||||
let p = platform.pool.clone();
|
||||
let source_name = source.name.clone();
|
||||
let source_id = source.id;
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
if let Err(err) = cm.connect_from_source(&p, source_id).await {
|
||||
tracing::error!("Failed to connect to source {}: {}", source_name, err);
|
||||
}
|
||||
});
|
||||
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
if let Err(err) = task.await {
|
||||
tracing::error!("Source connection task failed: {:?}", err);
|
||||
}
|
||||
}
|
||||
.expect("Failed to connect enabled sources");
|
||||
|
||||
let state = AppState {
|
||||
config: config.clone(),
|
||||
|
|
@ -87,40 +56,33 @@ pub async fn run() {
|
|||
control_runtime: control_runtime.clone(),
|
||||
};
|
||||
control::engine::start(state.clone(), control_runtime);
|
||||
if config.simulate_plc {
|
||||
|
||||
if control::simulate::enabled() {
|
||||
tracing::info!("SIMULATE_PLC enabled: starting chaos simulation");
|
||||
control::simulate::start(state.clone());
|
||||
}
|
||||
|
||||
let app = build_router(state.clone());
|
||||
let addr = format!("{}:{}", config.server_host, config.server_port);
|
||||
tracing::info!("Starting feeder distributor server at http://{}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
|
||||
let ui_url = format!("http://{}:{}/ui", "localhost", config.server_port);
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
let shutdown_tx_ctrl = shutdown_tx.clone();
|
||||
let ui_url = config.local_ui_url();
|
||||
let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(1);
|
||||
let rt_handle = tokio::runtime::Handle::current();
|
||||
init_tray(ui_url, shutdown_tx.clone(), rt_handle);
|
||||
|
||||
let connection_manager_for_shutdown = state.platform.connection_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to install Ctrl+C handler");
|
||||
let _ = shutdown_tx_ctrl.send(()).await;
|
||||
});
|
||||
bootstrap::install_ctrl_c_shutdown(shutdown_tx);
|
||||
|
||||
let shutdown_signal = async move {
|
||||
let _ = shutdown_rx.recv().await;
|
||||
tracing::info!("Received shutdown signal, closing all feeder connections...");
|
||||
connection_manager_for_shutdown.disconnect_all().await;
|
||||
tracing::info!("All feeder connections closed");
|
||||
};
|
||||
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal)
|
||||
.await
|
||||
.unwrap();
|
||||
bootstrap::serve_app_with_graceful_shutdown(
|
||||
&config,
|
||||
"feeder distributor",
|
||||
app,
|
||||
bootstrap::disconnect_all_on_shutdown(
|
||||
shutdown_rx,
|
||||
connection_manager_for_shutdown,
|
||||
"feeder",
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub fn test_state() -> AppState {
|
||||
|
|
@ -130,20 +92,18 @@ pub fn test_state() -> AppState {
|
|||
.expect("lazy pool should build");
|
||||
let connection_manager = Arc::new(ConnectionManager::new());
|
||||
let ws_manager = Arc::new(WebSocketManager::new());
|
||||
let platform = PlatformContext::new(pool.clone(), connection_manager, ws_manager.clone());
|
||||
let event_manager = Arc::new(EventManager::new(
|
||||
pool.clone(),
|
||||
connection_manager.clone(),
|
||||
Some(ws_manager.clone()),
|
||||
pool,
|
||||
Some(ws_manager),
|
||||
platform.metadata.clone(),
|
||||
));
|
||||
let platform = PlatformContext::new(pool, connection_manager, ws_manager);
|
||||
|
||||
AppState {
|
||||
config: AppConfig {
|
||||
config: ServerConfig {
|
||||
database_url,
|
||||
server_host: "127.0.0.1".to_string(),
|
||||
server_port: 0,
|
||||
write_api_key: Some("test-write-key".to_string()),
|
||||
simulate_plc: false,
|
||||
},
|
||||
platform,
|
||||
event_manager,
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
use std::env;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppConfig {
|
||||
pub database_url: String,
|
||||
pub server_host: String,
|
||||
pub server_port: u16,
|
||||
pub write_api_key: Option<String>,
|
||||
/// When true, simulate RUN signal feedback after start/stop commands.
|
||||
/// Set SIMULATE_PLC=true in .env for use with OPC UA proxy simulators.
|
||||
pub simulate_plc: bool,
|
||||
}
|
||||
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
let database_url = get_env("DATABASE_URL")?;
|
||||
let server_host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
|
||||
let server_port = env::var("PORT")
|
||||
.unwrap_or_else(|_| "60309".to_string())
|
||||
.parse::<u16>()
|
||||
.map_err(|_| "PORT must be a number")?;
|
||||
// Prefer WRITE_API_KEY, keep WRITE_KEY as backward-compatible fallback.
|
||||
let write_api_key = env::var("WRITE_API_KEY")
|
||||
.ok()
|
||||
.or_else(|| env::var("WRITE_KEY").ok());
|
||||
|
||||
let simulate_plc = env::var("SIMULATE_PLC")
|
||||
.unwrap_or_default()
|
||||
.to_lowercase() == "true";
|
||||
|
||||
Ok(Self {
|
||||
database_url,
|
||||
server_host,
|
||||
server_port,
|
||||
write_api_key,
|
||||
simulate_plc,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verify_write_key(&self, key: &str) -> bool {
|
||||
self.write_api_key
|
||||
.as_ref()
|
||||
.map(|expected| expected == key)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_env(key: &str) -> Result<String, String> {
|
||||
env::var(key).map_err(|_| format!("Missing environment variable: {}", key))
|
||||
}
|
||||
|
|
@ -11,10 +11,14 @@ use crate::{
|
|||
runtime::{ControlRuntimeStore, UnitRuntime, UnitRuntimeState},
|
||||
},
|
||||
event::AppEvent,
|
||||
model::ControlUnit,
|
||||
service,
|
||||
AppState,
|
||||
};
|
||||
use plc_platform_core::{
|
||||
service::EquipmentRolePoint,
|
||||
telemetry::{PointMonitorInfo, PointQuality},
|
||||
websocket::WsMessage,
|
||||
AppState,
|
||||
websocket::{AppWsEvent, WsMessage},
|
||||
};
|
||||
|
||||
/// Start the engine: a supervisor spawns one async task per enabled unit.
|
||||
|
|
@ -33,16 +37,16 @@ async fn supervise(state: AppState, store: Arc<ControlRuntimeStore>) {
|
|||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match crate::service::get_all_enabled_units(&state.platform.pool).await {
|
||||
match service::get_all_enabled_units(&state.platform.pool).await {
|
||||
Ok(units) => {
|
||||
for unit in units {
|
||||
let needs_spawn = tasks
|
||||
.get(&unit.id)
|
||||
.map_or(true, |h| h.is_finished());
|
||||
let needs_spawn = tasks.get(&unit.id).is_none_or(|h| h.is_finished());
|
||||
if needs_spawn {
|
||||
let s = state.clone();
|
||||
let st = store.clone();
|
||||
let handle = tokio::spawn(async move { unit_task(s, st, unit.id).await; });
|
||||
let handle = tokio::spawn(async move {
|
||||
unit_task(s, st, unit.id).await;
|
||||
});
|
||||
tasks.insert(unit.id, handle);
|
||||
}
|
||||
}
|
||||
|
|
@ -63,21 +67,23 @@ async fn unit_task(state: AppState, store: Arc<ControlRuntimeStore>, unit_id: Uu
|
|||
|
||||
loop {
|
||||
// Reload unit config on each iteration to detect disable/delete.
|
||||
let unit = match crate::service::get_unit_by_id(&state.platform.pool, unit_id).await {
|
||||
Ok(Some(u)) if u.enabled => u,
|
||||
Ok(_) => {
|
||||
tracing::info!("Engine: unit {} disabled or deleted, task exiting", unit_id);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Engine: unit {} config reload failed: {}", unit_id, e);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let unit =
|
||||
match service::get_unit_by_id(&state.platform.pool, unit_id).await {
|
||||
Ok(Some(u)) if u.enabled => u,
|
||||
Ok(_) => {
|
||||
tracing::info!("Engine: unit {} disabled or deleted, task exiting", unit_id);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Engine: unit {} config reload failed: {}", unit_id, e);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Fault / comm check.
|
||||
let (kind_roles, kind_eq_ids, all_roles) = match load_equipment_maps(&state, unit_id).await {
|
||||
let (kind_roles, all_roles, kind_eq_ids) = match load_equipment_maps(&state, unit_id).await
|
||||
{
|
||||
Ok(maps) => maps,
|
||||
Err(e) => {
|
||||
tracing::error!("Engine: unit {} equipment load failed: {}", unit_id, e);
|
||||
|
|
@ -93,7 +99,11 @@ async fn unit_task(state: AppState, store: Arc<ControlRuntimeStore>, unit_id: Uu
|
|||
}
|
||||
|
||||
// Wait when not active.
|
||||
if !runtime.auto_enabled || runtime.fault_locked || runtime.comm_locked || runtime.manual_ack_required {
|
||||
if !runtime.auto_enabled
|
||||
|| runtime.fault_locked
|
||||
|| runtime.comm_locked
|
||||
|| runtime.manual_ack_required
|
||||
{
|
||||
tokio::select! {
|
||||
_ = fault_tick.tick() => {}
|
||||
_ = notify.notified() => {
|
||||
|
|
@ -114,17 +124,31 @@ async fn unit_task(state: AppState, store: Arc<ControlRuntimeStore>, unit_id: Uu
|
|||
continue;
|
||||
}
|
||||
// Send feeder start command.
|
||||
let monitor = state.platform.connection_manager.get_point_monitor_data_read_guard().await;
|
||||
let cmd = kind_roles.get("coal_feeder").and_then(|r| find_cmd(r, "start_cmd", &monitor));
|
||||
let monitor = state
|
||||
.platform
|
||||
.connection_manager
|
||||
.get_point_monitor_data_read_guard()
|
||||
.await;
|
||||
let cmd = kind_roles
|
||||
.get("coal_feeder")
|
||||
.and_then(|r| find_cmd(r, "start_cmd", &monitor));
|
||||
drop(monitor);
|
||||
if let Some((pid, vt)) = cmd {
|
||||
if let Err(e) = send_pulse_command(&state.platform.connection_manager, pid, vt.as_ref(), 300).await {
|
||||
if let Err(e) = send_pulse_command(
|
||||
&state.platform.connection_manager,
|
||||
pid,
|
||||
vt.as_ref(),
|
||||
300,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Engine: start feeder failed for unit {}: {}", unit_id, e);
|
||||
continue;
|
||||
}
|
||||
if state.config.simulate_plc {
|
||||
if crate::control::simulate::enabled() {
|
||||
if let Some(eq_id) = kind_eq_ids.get("coal_feeder").copied() {
|
||||
crate::control::simulate::simulate_run_feedback(&state, eq_id, true).await;
|
||||
crate::control::simulate::simulate_run_feedback(&state, eq_id, true)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -137,26 +161,53 @@ async fn unit_task(state: AppState, store: Arc<ControlRuntimeStore>, unit_id: Uu
|
|||
UnitRuntimeState::Running => {
|
||||
// Wait run_time_sec. run_time_sec == 0 means run without a time limit
|
||||
// (relies on acc_time_sec to eventually stop). Treat as a very long phase.
|
||||
let secs = if unit.run_time_sec > 0 { unit.run_time_sec } else { i32::MAX };
|
||||
let unit_for_wait = plc_platform_core::model::ControlUnit {
|
||||
let secs = if unit.run_time_sec > 0 {
|
||||
unit.run_time_sec
|
||||
} else {
|
||||
i32::MAX
|
||||
};
|
||||
let unit_for_wait = ControlUnit {
|
||||
run_time_sec: secs,
|
||||
..unit.clone()
|
||||
};
|
||||
if !wait_phase(&state, &store, &unit_for_wait, &all_roles, ¬ify, &mut fault_tick).await {
|
||||
if !wait_phase(
|
||||
&state,
|
||||
&store,
|
||||
&unit_for_wait,
|
||||
&all_roles,
|
||||
¬ify,
|
||||
&mut fault_tick,
|
||||
)
|
||||
.await
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Stop feeder.
|
||||
let monitor = state.platform.connection_manager.get_point_monitor_data_read_guard().await;
|
||||
let cmd = kind_roles.get("coal_feeder").and_then(|r| find_cmd(r, "stop_cmd", &monitor));
|
||||
let monitor = state
|
||||
.platform
|
||||
.connection_manager
|
||||
.get_point_monitor_data_read_guard()
|
||||
.await;
|
||||
let cmd = kind_roles
|
||||
.get("coal_feeder")
|
||||
.and_then(|r| find_cmd(r, "stop_cmd", &monitor));
|
||||
drop(monitor);
|
||||
if let Some((pid, vt)) = cmd {
|
||||
if let Err(e) = send_pulse_command(&state.platform.connection_manager, pid, vt.as_ref(), 300).await {
|
||||
if let Err(e) = send_pulse_command(
|
||||
&state.platform.connection_manager,
|
||||
pid,
|
||||
vt.as_ref(),
|
||||
300,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Engine: stop feeder failed for unit {}: {}", unit_id, e);
|
||||
continue;
|
||||
}
|
||||
if state.config.simulate_plc {
|
||||
if crate::control::simulate::enabled() {
|
||||
if let Some(eq_id) = kind_eq_ids.get("coal_feeder").copied() {
|
||||
crate::control::simulate::simulate_run_feedback(&state, eq_id, false).await;
|
||||
crate::control::simulate::simulate_run_feedback(&state, eq_id, false)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -164,17 +215,39 @@ async fn unit_task(state: AppState, store: Arc<ControlRuntimeStore>, unit_id: Uu
|
|||
runtime.accumulated_run_sec += secs as i64 * 1000;
|
||||
runtime.display_acc_sec = runtime.accumulated_run_sec;
|
||||
|
||||
if unit.acc_time_sec > 0 && runtime.accumulated_run_sec >= unit.acc_time_sec as i64 * 1000 {
|
||||
if unit.acc_time_sec > 0
|
||||
&& runtime.accumulated_run_sec >= unit.acc_time_sec as i64 * 1000
|
||||
{
|
||||
// Accumulated threshold reached; start distributor.
|
||||
let monitor = state.platform.connection_manager.get_point_monitor_data_read_guard().await;
|
||||
let dist_cmd = kind_roles.get("distributor").and_then(|r| find_cmd(r, "start_cmd", &monitor));
|
||||
let monitor = state
|
||||
.platform
|
||||
.connection_manager
|
||||
.get_point_monitor_data_read_guard()
|
||||
.await;
|
||||
let dist_cmd = kind_roles
|
||||
.get("distributor")
|
||||
.and_then(|r| find_cmd(r, "start_cmd", &monitor));
|
||||
drop(monitor);
|
||||
if let Some((pid, vt)) = dist_cmd {
|
||||
if let Err(e) = send_pulse_command(&state.platform.connection_manager, pid, vt.as_ref(), 300).await {
|
||||
tracing::warn!("Engine: start distributor failed for unit {}: {}", unit_id, e);
|
||||
} else if state.config.simulate_plc {
|
||||
if let Err(e) = send_pulse_command(
|
||||
&state.platform.connection_manager,
|
||||
pid,
|
||||
vt.as_ref(),
|
||||
300,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Engine: start distributor failed for unit {}: {}",
|
||||
unit_id,
|
||||
e
|
||||
);
|
||||
} else if crate::control::simulate::enabled() {
|
||||
if let Some(eq_id) = kind_eq_ids.get("distributor").copied() {
|
||||
crate::control::simulate::simulate_run_feedback(&state, eq_id, true).await;
|
||||
crate::control::simulate::simulate_run_feedback(
|
||||
&state, eq_id, true,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -191,17 +264,35 @@ async fn unit_task(state: AppState, store: Arc<ControlRuntimeStore>, unit_id: Uu
|
|||
if !wait_phase(&state, &store, &unit, &all_roles, ¬ify, &mut fault_tick).await {
|
||||
continue;
|
||||
}
|
||||
let monitor = state.platform.connection_manager.get_point_monitor_data_read_guard().await;
|
||||
let cmd = kind_roles.get("distributor").and_then(|r| find_cmd(r, "stop_cmd", &monitor));
|
||||
let monitor = state
|
||||
.platform
|
||||
.connection_manager
|
||||
.get_point_monitor_data_read_guard()
|
||||
.await;
|
||||
let cmd = kind_roles
|
||||
.get("distributor")
|
||||
.and_then(|r| find_cmd(r, "stop_cmd", &monitor));
|
||||
drop(monitor);
|
||||
if let Some((pid, vt)) = cmd {
|
||||
if let Err(e) = send_pulse_command(&state.platform.connection_manager, pid, vt.as_ref(), 300).await {
|
||||
tracing::warn!("Engine: stop distributor failed for unit {}: {}", unit_id, e);
|
||||
if let Err(e) = send_pulse_command(
|
||||
&state.platform.connection_manager,
|
||||
pid,
|
||||
vt.as_ref(),
|
||||
300,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Engine: stop distributor failed for unit {}: {}",
|
||||
unit_id,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if state.config.simulate_plc {
|
||||
if crate::control::simulate::enabled() {
|
||||
if let Some(eq_id) = kind_eq_ids.get("distributor").copied() {
|
||||
crate::control::simulate::simulate_run_feedback(&state, eq_id, false).await;
|
||||
crate::control::simulate::simulate_run_feedback(&state, eq_id, false)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -231,7 +322,7 @@ async fn unit_task(state: AppState, store: Arc<ControlRuntimeStore>, unit_id: Uu
|
|||
async fn wait_phase(
|
||||
state: &AppState,
|
||||
store: &ControlRuntimeStore,
|
||||
unit: &plc_platform_core::model::ControlUnit,
|
||||
unit: &ControlUnit,
|
||||
all_roles: &[(Uuid, HashMap<String, EquipmentRolePoint>)],
|
||||
notify: &Arc<Notify>,
|
||||
fault_tick: &mut tokio::time::Interval,
|
||||
|
|
@ -261,18 +352,30 @@ async fn wait_phase(
|
|||
store.upsert(runtime.clone()).await;
|
||||
push_ws(state, &runtime).await;
|
||||
}
|
||||
if !runtime.auto_enabled || runtime.fault_locked || runtime.comm_locked || runtime.manual_ack_required {
|
||||
if !runtime.auto_enabled
|
||||
|| runtime.fault_locked
|
||||
|| runtime.comm_locked
|
||||
|| runtime.manual_ack_required
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn push_ws(state: &AppState, runtime: &UnitRuntime) {
|
||||
if let Err(e) = state
|
||||
.platform.ws_manager
|
||||
.send_to_public(WsMessage::UnitRuntimeChanged(runtime.clone()))
|
||||
.await
|
||||
{
|
||||
let payload = match serde_json::to_value(runtime) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
tracing::warn!("Engine: failed to serialize runtime for WS push: {}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let message = WsMessage::AppEvent(AppWsEvent {
|
||||
app: "feeder".to_string(),
|
||||
event_type: "unit_runtime_changed".to_string(),
|
||||
data: payload,
|
||||
});
|
||||
if let Err(e) = state.platform.ws_manager.send_to_public(message).await {
|
||||
tracing::debug!("Engine: WS push skipped (no subscribers): {}", e);
|
||||
}
|
||||
}
|
||||
|
|
@ -282,11 +385,12 @@ async fn push_ws(state: &AppState, runtime: &UnitRuntime) {
|
|||
async fn check_fault_comm(
|
||||
state: &AppState,
|
||||
runtime: &mut UnitRuntime,
|
||||
unit: &plc_platform_core::model::ControlUnit,
|
||||
unit: &ControlUnit,
|
||||
all_roles: &[(Uuid, HashMap<String, EquipmentRolePoint>)],
|
||||
) -> bool {
|
||||
let monitor = state
|
||||
.platform.connection_manager
|
||||
.platform
|
||||
.connection_manager
|
||||
.get_point_monitor_data_read_guard()
|
||||
.await;
|
||||
|
||||
|
|
@ -301,7 +405,7 @@ async fn check_fault_comm(
|
|||
roles
|
||||
.get("flt")
|
||||
.and_then(|rp| monitor.get(&rp.point_id))
|
||||
.map(|m| super::monitor_value_as_bool(m))
|
||||
.map(super::monitor_value_as_bool)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
|
|
@ -312,7 +416,7 @@ async fn check_fault_comm(
|
|||
roles
|
||||
.get("flt")
|
||||
.and_then(|rp| monitor.get(&rp.point_id))
|
||||
.map(|m| super::monitor_value_as_bool(m))
|
||||
.map(super::monitor_value_as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|(eq_id, _)| *eq_id)
|
||||
|
|
@ -359,17 +463,26 @@ async fn check_fault_comm(
|
|||
runtime.rem_local = any_rem_local;
|
||||
|
||||
if !prev_comm && runtime.comm_locked {
|
||||
let _ = state.event_manager.send(AppEvent::CommLocked { unit_id: unit.id });
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(AppEvent::CommLocked { unit_id: unit.id });
|
||||
} else if prev_comm && !runtime.comm_locked {
|
||||
let _ = state.event_manager.send(AppEvent::CommRecovered { unit_id: unit.id });
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(AppEvent::CommRecovered { unit_id: unit.id });
|
||||
}
|
||||
|
||||
if let Some(eq_id) = flt_eq_id {
|
||||
runtime.fault_locked = true;
|
||||
let _ = state.event_manager.send(AppEvent::FaultLocked { unit_id: unit.id, equipment_id: eq_id });
|
||||
let _ = state.event_manager.send(AppEvent::FaultLocked {
|
||||
unit_id: unit.id,
|
||||
equipment_id: eq_id,
|
||||
});
|
||||
if runtime.auto_enabled {
|
||||
runtime.auto_enabled = false;
|
||||
let _ = state.event_manager.send(AppEvent::AutoControlStopped { unit_id: unit.id });
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(AppEvent::AutoControlStopped { unit_id: unit.id });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -383,16 +496,23 @@ async fn check_fault_comm(
|
|||
|
||||
// Fire RemLocal event when any equipment first switches to local mode.
|
||||
if let Some(eq_id) = rem_local_eq_id {
|
||||
let _ = state.event_manager.send(AppEvent::RemLocal { unit_id: unit.id, equipment_id: eq_id });
|
||||
let _ = state.event_manager.send(AppEvent::RemLocal {
|
||||
unit_id: unit.id,
|
||||
equipment_id: eq_id,
|
||||
});
|
||||
if runtime.auto_enabled {
|
||||
runtime.auto_enabled = false;
|
||||
let _ = state.event_manager.send(AppEvent::AutoControlStopped { unit_id: unit.id });
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(AppEvent::AutoControlStopped { unit_id: unit.id });
|
||||
}
|
||||
}
|
||||
|
||||
// Fire RemRecovered when all rem signals return to remote.
|
||||
if prev_rem_local && !any_rem_local {
|
||||
let _ = state.event_manager.send(AppEvent::RemRecovered { unit_id: unit.id });
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(AppEvent::RemRecovered { unit_id: unit.id });
|
||||
}
|
||||
|
||||
runtime.comm_locked != prev_comm
|
||||
|
|
@ -405,15 +525,19 @@ async fn check_fault_comm(
|
|||
|
||||
type EquipMaps = (
|
||||
HashMap<String, HashMap<String, EquipmentRolePoint>>,
|
||||
HashMap<String, Uuid>,
|
||||
Vec<(Uuid, HashMap<String, EquipmentRolePoint>)>,
|
||||
HashMap<String, Uuid>,
|
||||
);
|
||||
|
||||
async fn load_equipment_maps(state: &AppState, unit_id: Uuid) -> Result<EquipMaps, sqlx::Error> {
|
||||
let equipment_list = crate::service::get_equipment_by_unit_id(&state.platform.pool, unit_id).await?;
|
||||
let equipment_list =
|
||||
plc_platform_core::service::get_equipment_by_unit_id(&state.platform.pool, unit_id).await?;
|
||||
let equipment_ids: Vec<Uuid> = equipment_list.iter().map(|equip| equip.id).collect();
|
||||
let role_point_rows =
|
||||
crate::service::get_signal_role_points_batch(&state.platform.pool, &equipment_ids).await?;
|
||||
let role_point_rows = plc_platform_core::service::get_signal_role_points_batch(
|
||||
&state.platform.pool,
|
||||
&equipment_ids,
|
||||
)
|
||||
.await?;
|
||||
let mut role_points_by_equipment: HashMap<Uuid, Vec<EquipmentRolePoint>> = HashMap::new();
|
||||
for row in role_point_rows {
|
||||
role_points_by_equipment
|
||||
|
|
@ -438,8 +562,8 @@ fn build_equipment_maps(
|
|||
mut role_points_by_equipment: HashMap<Uuid, Vec<EquipmentRolePoint>>,
|
||||
) -> EquipMaps {
|
||||
let mut kind_roles: HashMap<String, HashMap<String, EquipmentRolePoint>> = HashMap::new();
|
||||
let mut kind_eq_ids: HashMap<String, Uuid> = HashMap::new();
|
||||
let mut all_roles: Vec<(Uuid, HashMap<String, EquipmentRolePoint>)> = Vec::new();
|
||||
let mut kind_eq_ids: HashMap<String, Uuid> = HashMap::new();
|
||||
|
||||
for equip in equipment_list {
|
||||
let role_map: HashMap<String, EquipmentRolePoint> = role_points_by_equipment
|
||||
|
|
@ -456,14 +580,15 @@ fn build_equipment_maps(
|
|||
} else {
|
||||
tracing::warn!(
|
||||
"Engine: unit {} has multiple {} equipment; using first",
|
||||
unit_id, kind
|
||||
unit_id,
|
||||
kind
|
||||
);
|
||||
}
|
||||
}
|
||||
all_roles.push((equip.id, role_map));
|
||||
}
|
||||
|
||||
(kind_roles, kind_eq_ids, all_roles)
|
||||
(kind_roles, all_roles, kind_eq_ids)
|
||||
}
|
||||
|
||||
/// Find a command point by role. Returns `None` if REM==0, FLT==1, or quality is bad.
|
||||
|
|
@ -471,7 +596,7 @@ fn find_cmd(
|
|||
roles: &HashMap<String, EquipmentRolePoint>,
|
||||
role: &str,
|
||||
monitor: &HashMap<Uuid, PointMonitorInfo>,
|
||||
) -> Option<(Uuid, Option<crate::telemetry::ValueType>)> {
|
||||
) -> Option<(Uuid, Option<plc_platform_core::telemetry::ValueType>)> {
|
||||
let cmd_rp = roles.get(role)?;
|
||||
|
||||
let rem_ok = roles
|
||||
|
|
@ -499,9 +624,9 @@ fn find_cmd(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_equipment_maps;
|
||||
use plc_platform_core::model::Equipment;
|
||||
use crate::service::EquipmentRolePoint;
|
||||
use chrono::Utc;
|
||||
use plc_platform_core::model::Equipment;
|
||||
use plc_platform_core::service::EquipmentRolePoint;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
pub use plc_platform_core::control::{command, runtime};
|
||||
pub use plc_platform_core::control::command;
|
||||
|
||||
pub mod engine;
|
||||
pub mod runtime;
|
||||
pub mod simulate;
|
||||
pub mod validator;
|
||||
|
||||
use crate::telemetry::{DataValue, PointMonitorInfo};
|
||||
use plc_platform_core::telemetry::{DataValue, PointMonitorInfo};
|
||||
|
||||
pub(crate) fn monitor_value_as_bool(monitor: &PointMonitorInfo) -> bool {
|
||||
match monitor.value.as_ref() {
|
||||
|
|
@ -13,7 +14,10 @@ pub(crate) fn monitor_value_as_bool(monitor: &PointMonitorInfo) -> bool {
|
|||
Some(DataValue::UInt(value)) => *value != 0,
|
||||
Some(DataValue::Float(value)) => *value != 0.0,
|
||||
Some(DataValue::Text(value)) => {
|
||||
matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "on" | "yes")
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "on" | "yes"
|
||||
)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
use tokio::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
use plc_platform_core::{
|
||||
connection::{BatchSetPointValueReq, SetPointValueReqItem},
|
||||
service,
|
||||
telemetry::{DataValue, PointMonitorInfo, PointQuality, ValueType},
|
||||
websocket::WsMessage,
|
||||
AppState,
|
||||
};
|
||||
|
||||
use crate::{service as feeder_service, AppState};
|
||||
|
||||
/// Whether SIMULATE_PLC mode is enabled via environment variable.
|
||||
pub fn enabled() -> bool {
|
||||
matches!(
|
||||
std::env::var("SIMULATE_PLC").ok().as_deref(),
|
||||
Some("true") | Some("1")
|
||||
)
|
||||
}
|
||||
|
||||
/// Start the chaos simulation task (only when SIMULATE_PLC=true).
|
||||
/// Randomly disrupts `rem` or `flt` signals on equipment to exercise the control engine.
|
||||
pub fn start(state: AppState) {
|
||||
|
|
@ -25,7 +35,7 @@ async fn run(state: AppState) {
|
|||
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
|
||||
|
||||
// Pick a random enabled unit.
|
||||
let units = match crate::service::get_all_enabled_units(&state.platform.pool).await {
|
||||
let units = match feeder_service::get_all_enabled_units(&state.platform.pool).await {
|
||||
Ok(u) if !u.is_empty() => u,
|
||||
_ => continue,
|
||||
};
|
||||
|
|
@ -39,7 +49,7 @@ async fn run(state: AppState) {
|
|||
|
||||
// Pick a random equipment in that unit.
|
||||
let equipments =
|
||||
match crate::service::get_equipment_by_unit_id(&state.platform.pool, unit.id).await {
|
||||
match service::get_equipment_by_unit_id(&state.platform.pool, unit.id).await {
|
||||
Ok(e) if !e.is_empty() => e,
|
||||
_ => continue,
|
||||
};
|
||||
|
|
@ -47,7 +57,7 @@ async fn run(state: AppState) {
|
|||
|
||||
// Find which of rem / flt this equipment has.
|
||||
let role_points =
|
||||
match crate::service::get_equipment_role_points(&state.platform.pool, eq.id).await {
|
||||
match service::get_equipment_role_points(&state.platform.pool, eq.id).await {
|
||||
Ok(rp) if !rp.is_empty() => rp,
|
||||
_ => continue,
|
||||
};
|
||||
|
|
@ -105,7 +115,7 @@ async fn run(state: AppState) {
|
|||
/// Called by the engine and control handler when SIMULATE_PLC=true.
|
||||
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.platform.pool, equipment_id).await {
|
||||
match service::get_equipment_role_points(&state.platform.pool, equipment_id).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("simulate_run_feedback: db error: {}", e);
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ use std::collections::HashMap;
|
|||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
use crate::AppState;
|
||||
use plc_platform_core::{
|
||||
service::EquipmentRolePoint,
|
||||
telemetry::{PointMonitorInfo, PointQuality, ValueType},
|
||||
util::response::ApiErr,
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
|
@ -43,11 +43,14 @@ pub async fn validate_manual_control(
|
|||
equipment_id: Uuid,
|
||||
action: ControlAction,
|
||||
) -> Result<ManualControlContext, ApiErr> {
|
||||
let equipment = crate::service::get_equipment_by_id(&state.platform.pool, equipment_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Equipment not found".to_string(), None))?;
|
||||
let equipment =
|
||||
plc_platform_core::service::get_equipment_by_id(&state.platform.pool, equipment_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Equipment not found".to_string(), None))?;
|
||||
|
||||
let role_points = crate::service::get_equipment_role_points(&state.platform.pool, equipment_id).await?;
|
||||
let role_points =
|
||||
plc_platform_core::service::get_equipment_role_points(&state.platform.pool, equipment_id)
|
||||
.await?;
|
||||
if role_points.is_empty() {
|
||||
return Err(ApiErr::BadRequest(
|
||||
"Equipment has no bound role points".to_string(),
|
||||
|
|
@ -75,7 +78,8 @@ pub async fn validate_manual_control(
|
|||
.clone();
|
||||
|
||||
let monitor_guard = state
|
||||
.platform.connection_manager
|
||||
.platform
|
||||
.connection_manager
|
||||
.get_point_monitor_data_read_guard()
|
||||
.await;
|
||||
|
||||
|
|
@ -135,7 +139,9 @@ pub async fn validate_manual_control(
|
|||
if runtime.fault_locked {
|
||||
return Err(ApiErr::Forbidden(
|
||||
"Unit is fault locked".to_string(),
|
||||
Some(json!({ "unit_id": unit_id, "manual_ack_required": runtime.manual_ack_required })),
|
||||
Some(
|
||||
json!({ "unit_id": unit_id, "manual_ack_required": runtime.manual_ack_required }),
|
||||
),
|
||||
));
|
||||
}
|
||||
if runtime.manual_ack_required {
|
||||
|
|
@ -148,7 +154,8 @@ pub async fn validate_manual_control(
|
|||
}
|
||||
|
||||
let command_value_type = state
|
||||
.platform.connection_manager
|
||||
.platform
|
||||
.connection_manager
|
||||
.get_point_monitor_data_read_guard()
|
||||
.await
|
||||
.get(&command_point.point_id)
|
||||
|
|
@ -198,4 +205,3 @@ fn missing_monitor_err(role: &str, equipment_id: Uuid) -> ApiErr {
|
|||
})),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,18 @@
|
|||
use std::collections::HashMap;
|
||||
use plc_platform_core::event::EventEnvelope;
|
||||
use std::sync::Arc;
|
||||
|
||||
use plc_platform_core::{
|
||||
event::{record_event, EventInsert, MetadataCache},
|
||||
websocket::WebSocketManager,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
use plc_platform_core::model::EventRecord;
|
||||
|
||||
const CONTROL_EVENT_CHANNEL_CAPACITY: usize = 1024;
|
||||
const TELEMETRY_EVENT_CHANNEL_CAPACITY: usize = 4096;
|
||||
|
||||
/// Feeder-specific business events only.
|
||||
/// Platform events (source/point lifecycle) are handled by core's emit_event().
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppEvent {
|
||||
SourceCreate {
|
||||
source_id: Uuid,
|
||||
},
|
||||
SourceUpdate {
|
||||
source_id: Uuid,
|
||||
},
|
||||
SourceDelete {
|
||||
source_id: Uuid,
|
||||
source_name: String,
|
||||
},
|
||||
PointCreateBatch {
|
||||
source_id: Uuid,
|
||||
point_ids: Vec<Uuid>,
|
||||
},
|
||||
PointDeleteBatch {
|
||||
source_id: Uuid,
|
||||
point_ids: Vec<Uuid>,
|
||||
},
|
||||
EquipmentStartCommandSent {
|
||||
equipment_id: Uuid,
|
||||
unit_id: Option<Uuid>,
|
||||
|
|
@ -37,531 +23,274 @@ pub enum AppEvent {
|
|||
unit_id: Option<Uuid>,
|
||||
point_id: Uuid,
|
||||
},
|
||||
AutoControlStarted { unit_id: Uuid },
|
||||
AutoControlStopped { unit_id: Uuid },
|
||||
FaultLocked { unit_id: Uuid, equipment_id: Uuid },
|
||||
FaultAcked { unit_id: Uuid },
|
||||
CommLocked { unit_id: Uuid },
|
||||
CommRecovered { unit_id: Uuid },
|
||||
RemLocal { unit_id: Uuid, equipment_id: Uuid },
|
||||
RemRecovered { unit_id: Uuid },
|
||||
UnitStateChanged { unit_id: Uuid, from_state: String, to_state: String },
|
||||
PointNewValue(crate::telemetry::PointNewValue),
|
||||
AutoControlStarted {
|
||||
unit_id: Uuid,
|
||||
},
|
||||
AutoControlStopped {
|
||||
unit_id: Uuid,
|
||||
},
|
||||
FaultLocked {
|
||||
unit_id: Uuid,
|
||||
equipment_id: Uuid,
|
||||
},
|
||||
FaultAcked {
|
||||
unit_id: Uuid,
|
||||
},
|
||||
CommLocked {
|
||||
unit_id: Uuid,
|
||||
},
|
||||
CommRecovered {
|
||||
unit_id: Uuid,
|
||||
},
|
||||
RemLocal {
|
||||
unit_id: Uuid,
|
||||
equipment_id: Uuid,
|
||||
},
|
||||
RemRecovered {
|
||||
unit_id: Uuid,
|
||||
},
|
||||
UnitStateChanged {
|
||||
unit_id: Uuid,
|
||||
from_state: String,
|
||||
to_state: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct EventManager {
|
||||
control_sender: mpsc::Sender<AppEvent>,
|
||||
telemetry_sender: mpsc::Sender<crate::telemetry::PointNewValue>,
|
||||
}
|
||||
|
||||
impl EventManager {
|
||||
pub fn new(
|
||||
pool: sqlx::PgPool,
|
||||
connection_manager: std::sync::Arc<crate::connection::ConnectionManager>,
|
||||
ws_manager: Option<std::sync::Arc<crate::websocket::WebSocketManager>>,
|
||||
ws_manager: Option<Arc<WebSocketManager>>,
|
||||
metadata: Arc<MetadataCache>,
|
||||
) -> Self {
|
||||
let (control_sender, mut control_receiver) =
|
||||
mpsc::channel::<AppEvent>(CONTROL_EVENT_CHANNEL_CAPACITY);
|
||||
let (telemetry_sender, mut telemetry_receiver) =
|
||||
mpsc::channel::<crate::telemetry::PointNewValue>(TELEMETRY_EVENT_CHANNEL_CAPACITY);
|
||||
|
||||
let control_cm = connection_manager.clone();
|
||||
let control_pool = pool.clone();
|
||||
let control_ws_manager = ws_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(event) = control_receiver.recv().await {
|
||||
handle_control_event(event, &control_pool, &control_cm, control_ws_manager.as_ref())
|
||||
.await;
|
||||
handle_control_event(
|
||||
event,
|
||||
&control_pool,
|
||||
control_ws_manager.as_ref(),
|
||||
&metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
let ws_manager_clone = ws_manager.clone();
|
||||
let telemetry_cm = connection_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(payload) = telemetry_receiver.recv().await {
|
||||
let mut latest_by_key: HashMap<(Uuid, u32), crate::telemetry::PointNewValue> =
|
||||
HashMap::new();
|
||||
latest_by_key.insert((payload.source_id, payload.client_handle), payload);
|
||||
|
||||
loop {
|
||||
match telemetry_receiver.try_recv() {
|
||||
Ok(next_payload) => {
|
||||
latest_by_key.insert(
|
||||
(next_payload.source_id, next_payload.client_handle),
|
||||
next_payload,
|
||||
);
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
|
||||
break;
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for point_payload in latest_by_key.into_values() {
|
||||
process_point_new_value(point_payload, &telemetry_cm, ws_manager_clone.as_ref())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
control_sender,
|
||||
telemetry_sender,
|
||||
}
|
||||
Self { control_sender }
|
||||
}
|
||||
|
||||
pub fn send(&self, event: AppEvent) -> Result<(), String> {
|
||||
match event {
|
||||
AppEvent::PointNewValue(payload) => match self.telemetry_sender.try_send(payload) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Closed(e)) => {
|
||||
Err(format!("Failed to send telemetry event: channel closed ({e:?})"))
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Full(payload)) => {
|
||||
// High-frequency telemetry is lossy by design under sustained pressure.
|
||||
tracing::warn!(
|
||||
"Dropping PointNewValue due to full telemetry queue: source={}, client_handle={}",
|
||||
payload.source_id,
|
||||
payload.client_handle
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
control_event => match self.control_sender.try_send(control_event) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Closed(e)) => {
|
||||
Err(format!("Failed to send control event: channel closed ({e:?})"))
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Full(e)) => {
|
||||
Err(format!("Failed to send control event: queue full ({e:?})"))
|
||||
}
|
||||
},
|
||||
match self.control_sender.try_send(event) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(mpsc::error::TrySendError::Closed(e)) => Err(format!(
|
||||
"Failed to send control event: channel closed ({e:?})"
|
||||
)),
|
||||
Err(mpsc::error::TrySendError::Full(e)) => {
|
||||
Err(format!("Failed to send control event: queue full ({e:?})"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl plc_platform_core::connection::PointEventSink for EventManager {
|
||||
fn send_point_new_value(
|
||||
&self,
|
||||
payload: plc_platform_core::telemetry::PointNewValue,
|
||||
) -> Result<(), String> {
|
||||
self.send(AppEvent::PointNewValue(payload))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_control_event(
|
||||
event: AppEvent,
|
||||
pool: &sqlx::PgPool,
|
||||
connection_manager: &std::sync::Arc<crate::connection::ConnectionManager>,
|
||||
ws_manager: Option<&std::sync::Arc<crate::websocket::WebSocketManager>>,
|
||||
ws_manager: Option<&Arc<WebSocketManager>>,
|
||||
metadata: &MetadataCache,
|
||||
) {
|
||||
persist_event_if_needed(&event, pool, ws_manager).await;
|
||||
|
||||
match event {
|
||||
AppEvent::SourceCreate { source_id } => {
|
||||
tracing::info!("Processing SourceCreate event for {}", source_id);
|
||||
if let Err(e) = connection_manager.connect_from_source(pool, source_id).await {
|
||||
tracing::error!("Failed to connect to source {}: {}", source_id, e);
|
||||
}
|
||||
}
|
||||
AppEvent::SourceUpdate { source_id } => {
|
||||
tracing::info!("Processing SourceUpdate event for {}", source_id);
|
||||
if let Err(e) = connection_manager.reconnect(pool, source_id).await {
|
||||
tracing::error!("Failed to reconnect source {}: {}", source_id, e);
|
||||
}
|
||||
}
|
||||
AppEvent::SourceDelete { source_id, .. } => {
|
||||
tracing::info!("Processing SourceDelete event for {}", source_id);
|
||||
if let Err(e) = connection_manager.disconnect(source_id).await {
|
||||
tracing::error!("Failed to disconnect from source {}: {}", source_id, e);
|
||||
}
|
||||
}
|
||||
AppEvent::PointCreateBatch { source_id, point_ids } => {
|
||||
let requested_count = point_ids.len();
|
||||
match connection_manager
|
||||
.subscribe_points_from_source(source_id, Some(point_ids), pool)
|
||||
.await
|
||||
{
|
||||
Ok(stats) => {
|
||||
let subscribed = *stats.get("subscribed").unwrap_or(&0);
|
||||
let polled = *stats.get("polled").unwrap_or(&0);
|
||||
let total = *stats.get("total").unwrap_or(&0);
|
||||
tracing::info!(
|
||||
"PointCreateBatch subscribe finished for source {}: requested={}, subscribed={}, polled={}, total={}",
|
||||
source_id,
|
||||
requested_count,
|
||||
subscribed,
|
||||
polled,
|
||||
total
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to subscribe to points: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::PointDeleteBatch { source_id, point_ids } => {
|
||||
tracing::info!(
|
||||
"Processing PointDeleteBatch event for source {} with {} points",
|
||||
source_id,
|
||||
point_ids.len()
|
||||
);
|
||||
if let Err(e) = connection_manager
|
||||
.unsubscribe_points_from_source(source_id, point_ids)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to unsubscribe points: {}", e);
|
||||
}
|
||||
}
|
||||
let record: Option<EventInsert> = match &event {
|
||||
AppEvent::EquipmentStartCommandSent {
|
||||
equipment_id,
|
||||
unit_id,
|
||||
point_id,
|
||||
} => {
|
||||
tracing::info!(
|
||||
"Equipment start command sent: equipment={}, unit={:?}, point={}",
|
||||
equipment_id,
|
||||
unit_id,
|
||||
point_id
|
||||
);
|
||||
let code = metadata.equipment_code(pool, *equipment_id).await;
|
||||
Some(EventInsert {
|
||||
event_type: "feeder.equipment.start_command_sent",
|
||||
level: "info",
|
||||
unit_id: *unit_id,
|
||||
equipment_id: Some(*equipment_id),
|
||||
source_id: None,
|
||||
subject_type: None,
|
||||
subject_id: None,
|
||||
message: format!("Start command sent to equipment {}", code),
|
||||
payload: serde_json::json!({
|
||||
"equipment_id": equipment_id,
|
||||
"unit_id": unit_id,
|
||||
"point_id": point_id
|
||||
}),
|
||||
})
|
||||
}
|
||||
AppEvent::EquipmentStopCommandSent {
|
||||
equipment_id,
|
||||
unit_id,
|
||||
point_id,
|
||||
} => {
|
||||
tracing::info!(
|
||||
"Equipment stop command sent: equipment={}, unit={:?}, point={}",
|
||||
equipment_id,
|
||||
unit_id,
|
||||
point_id
|
||||
);
|
||||
let code = metadata.equipment_code(pool, *equipment_id).await;
|
||||
Some(EventInsert {
|
||||
event_type: "feeder.equipment.stop_command_sent",
|
||||
level: "info",
|
||||
unit_id: *unit_id,
|
||||
equipment_id: Some(*equipment_id),
|
||||
source_id: None,
|
||||
subject_type: None,
|
||||
subject_id: None,
|
||||
message: format!("Stop command sent to equipment {}", code),
|
||||
payload: serde_json::json!({
|
||||
"equipment_id": equipment_id,
|
||||
"unit_id": unit_id,
|
||||
"point_id": point_id
|
||||
}),
|
||||
})
|
||||
}
|
||||
AppEvent::AutoControlStarted { unit_id } => {
|
||||
tracing::info!("Auto control started for unit {}", unit_id);
|
||||
let code = metadata.unit_code(pool, *unit_id).await;
|
||||
Some(EventInsert {
|
||||
event_type: "feeder.unit.auto_control_started",
|
||||
level: "info",
|
||||
unit_id: Some(*unit_id),
|
||||
equipment_id: None,
|
||||
source_id: None,
|
||||
subject_type: None,
|
||||
subject_id: None,
|
||||
message: format!("Auto control started for unit {}", code),
|
||||
payload: serde_json::json!({ "unit_id": unit_id }),
|
||||
})
|
||||
}
|
||||
AppEvent::AutoControlStopped { unit_id } => {
|
||||
tracing::info!("Auto control stopped for unit {}", unit_id);
|
||||
let code = metadata.unit_code(pool, *unit_id).await;
|
||||
Some(EventInsert {
|
||||
event_type: "feeder.unit.auto_control_stopped",
|
||||
level: "info",
|
||||
unit_id: Some(*unit_id),
|
||||
equipment_id: None,
|
||||
source_id: None,
|
||||
subject_type: None,
|
||||
subject_id: None,
|
||||
message: format!("Auto control stopped for unit {}", code),
|
||||
payload: serde_json::json!({ "unit_id": unit_id }),
|
||||
})
|
||||
}
|
||||
AppEvent::FaultLocked { unit_id, equipment_id } => {
|
||||
tracing::warn!("Fault locked: unit={}, equipment={}", unit_id, equipment_id);
|
||||
AppEvent::FaultLocked {
|
||||
unit_id,
|
||||
equipment_id,
|
||||
} => {
|
||||
let unit_code = metadata.unit_code(pool, *unit_id).await;
|
||||
let eq_code = metadata.equipment_code(pool, *equipment_id).await;
|
||||
Some(EventInsert {
|
||||
event_type: "feeder.unit.fault_locked",
|
||||
level: "error",
|
||||
unit_id: Some(*unit_id),
|
||||
equipment_id: Some(*equipment_id),
|
||||
source_id: None,
|
||||
subject_type: None,
|
||||
subject_id: None,
|
||||
message: format!(
|
||||
"Fault locked for unit {} by equipment {}",
|
||||
unit_code, eq_code
|
||||
),
|
||||
payload: serde_json::json!({ "unit_id": unit_id, "equipment_id": equipment_id }),
|
||||
})
|
||||
}
|
||||
AppEvent::FaultAcked { unit_id } => {
|
||||
tracing::info!("Fault acked for unit {}", unit_id);
|
||||
let code = metadata.unit_code(pool, *unit_id).await;
|
||||
Some(EventInsert {
|
||||
event_type: "feeder.unit.fault_acked",
|
||||
level: "info",
|
||||
unit_id: Some(*unit_id),
|
||||
equipment_id: None,
|
||||
source_id: None,
|
||||
subject_type: None,
|
||||
subject_id: None,
|
||||
message: format!("Fault acknowledged for unit {}", code),
|
||||
payload: serde_json::json!({ "unit_id": unit_id }),
|
||||
})
|
||||
}
|
||||
AppEvent::CommLocked { unit_id } => {
|
||||
tracing::warn!("Comm locked for unit {}", unit_id);
|
||||
let code = metadata.unit_code(pool, *unit_id).await;
|
||||
Some(EventInsert {
|
||||
event_type: "feeder.unit.comm_locked",
|
||||
level: "warn",
|
||||
unit_id: Some(*unit_id),
|
||||
equipment_id: None,
|
||||
source_id: None,
|
||||
subject_type: None,
|
||||
subject_id: None,
|
||||
message: format!("Communication locked for unit {}", code),
|
||||
payload: serde_json::json!({ "unit_id": unit_id }),
|
||||
})
|
||||
}
|
||||
AppEvent::CommRecovered { unit_id } => {
|
||||
tracing::info!("Comm recovered for unit {}", unit_id);
|
||||
let code = metadata.unit_code(pool, *unit_id).await;
|
||||
Some(EventInsert {
|
||||
event_type: "feeder.unit.comm_recovered",
|
||||
level: "info",
|
||||
unit_id: Some(*unit_id),
|
||||
equipment_id: None,
|
||||
source_id: None,
|
||||
subject_type: None,
|
||||
subject_id: None,
|
||||
message: format!("Communication recovered for unit {}", code),
|
||||
payload: serde_json::json!({ "unit_id": unit_id }),
|
||||
})
|
||||
}
|
||||
AppEvent::RemLocal { unit_id, equipment_id } => {
|
||||
tracing::warn!("REM local: unit={}, equipment={}", unit_id, equipment_id);
|
||||
AppEvent::RemLocal {
|
||||
unit_id,
|
||||
equipment_id,
|
||||
} => {
|
||||
let unit_code = metadata.unit_code(pool, *unit_id).await;
|
||||
let eq_code = metadata.equipment_code(pool, *equipment_id).await;
|
||||
Some(EventInsert {
|
||||
event_type: "feeder.unit.rem_local",
|
||||
level: "warn",
|
||||
unit_id: Some(*unit_id),
|
||||
equipment_id: Some(*equipment_id),
|
||||
source_id: None,
|
||||
subject_type: None,
|
||||
subject_id: None,
|
||||
message: format!(
|
||||
"Unit {} switched to local control via equipment {}",
|
||||
unit_code, eq_code
|
||||
),
|
||||
payload: serde_json::json!({ "unit_id": unit_id, "equipment_id": equipment_id }),
|
||||
})
|
||||
}
|
||||
AppEvent::RemRecovered { unit_id } => {
|
||||
tracing::info!("REM recovered for unit {}", unit_id);
|
||||
let code = metadata.unit_code(pool, *unit_id).await;
|
||||
Some(EventInsert {
|
||||
event_type: "feeder.unit.rem_recovered",
|
||||
level: "warn",
|
||||
unit_id: Some(*unit_id),
|
||||
equipment_id: None,
|
||||
source_id: None,
|
||||
subject_type: None,
|
||||
subject_id: None,
|
||||
message: format!(
|
||||
"Unit {} returned to remote control; auto control requires manual restart",
|
||||
code
|
||||
),
|
||||
payload: serde_json::json!({ "unit_id": unit_id }),
|
||||
})
|
||||
}
|
||||
AppEvent::UnitStateChanged { unit_id, from_state, to_state } => {
|
||||
// High-frequency, intentionally not persisted; tracing only for local observability.
|
||||
AppEvent::UnitStateChanged {
|
||||
unit_id,
|
||||
from_state,
|
||||
to_state,
|
||||
} => {
|
||||
tracing::info!("Unit {} state: {} -> {}", unit_id, from_state, to_state);
|
||||
None
|
||||
}
|
||||
AppEvent::PointNewValue(_) => {
|
||||
tracing::warn!("PointNewValue routed to control worker unexpectedly");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_source_name(pool: &sqlx::PgPool, id: Uuid) -> String {
|
||||
sqlx::query_scalar::<_, String>("SELECT name FROM source WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
async fn fetch_unit_code(pool: &sqlx::PgPool, id: Uuid) -> String {
|
||||
sqlx::query_scalar::<_, String>("SELECT code FROM unit WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
async fn fetch_equipment_code(pool: &sqlx::PgPool, id: Uuid) -> String {
|
||||
sqlx::query_scalar::<_, String>("SELECT code FROM equipment WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
async fn persist_event_if_needed(
|
||||
event: &AppEvent,
|
||||
pool: &sqlx::PgPool,
|
||||
ws_manager: Option<&std::sync::Arc<crate::websocket::WebSocketManager>>,
|
||||
) {
|
||||
let record = match event {
|
||||
AppEvent::SourceCreate { source_id } => {
|
||||
let name = fetch_source_name(pool, *source_id).await;
|
||||
Some((
|
||||
"source.created", "info",
|
||||
None, None, Some(*source_id),
|
||||
format!("Source {} created", name),
|
||||
serde_json::json!({ "source_id": source_id }),
|
||||
))
|
||||
}
|
||||
AppEvent::SourceUpdate { source_id } => {
|
||||
let name = fetch_source_name(pool, *source_id).await;
|
||||
Some((
|
||||
"source.updated", "info",
|
||||
None, None, Some(*source_id),
|
||||
format!("Source {} updated", name),
|
||||
serde_json::json!({ "source_id": source_id }),
|
||||
))
|
||||
}
|
||||
AppEvent::SourceDelete { source_id, source_name } => Some((
|
||||
"source.deleted", "warn",
|
||||
None, None, None,
|
||||
format!("Source {} deleted", source_name),
|
||||
serde_json::json!({ "source_id": source_id }),
|
||||
)),
|
||||
AppEvent::PointCreateBatch { source_id, point_ids } => {
|
||||
let name = fetch_source_name(pool, *source_id).await;
|
||||
Some((
|
||||
"point.batch_created", "info",
|
||||
None, None, Some(*source_id),
|
||||
format!("Created {} points for source {}", point_ids.len(), name),
|
||||
serde_json::json!({ "source_id": source_id, "point_ids": point_ids }),
|
||||
))
|
||||
}
|
||||
AppEvent::PointDeleteBatch { source_id, point_ids } => {
|
||||
let name = fetch_source_name(pool, *source_id).await;
|
||||
Some((
|
||||
"point.batch_deleted", "warn",
|
||||
None, None, Some(*source_id),
|
||||
format!("Deleted {} points for source {}", point_ids.len(), name),
|
||||
serde_json::json!({ "source_id": source_id, "point_ids": point_ids }),
|
||||
))
|
||||
}
|
||||
AppEvent::EquipmentStartCommandSent { equipment_id, unit_id, point_id } => {
|
||||
let code = fetch_equipment_code(pool, *equipment_id).await;
|
||||
Some((
|
||||
"equipment.start_command_sent", "info",
|
||||
*unit_id, Some(*equipment_id), None,
|
||||
format!("Start command sent to equipment {}", code),
|
||||
serde_json::json!({
|
||||
"equipment_id": equipment_id,
|
||||
"unit_id": unit_id,
|
||||
"point_id": point_id
|
||||
}),
|
||||
))
|
||||
}
|
||||
AppEvent::EquipmentStopCommandSent { equipment_id, unit_id, point_id } => {
|
||||
let code = fetch_equipment_code(pool, *equipment_id).await;
|
||||
Some((
|
||||
"equipment.stop_command_sent", "info",
|
||||
*unit_id, Some(*equipment_id), None,
|
||||
format!("Stop command sent to equipment {}", code),
|
||||
serde_json::json!({
|
||||
"equipment_id": equipment_id,
|
||||
"unit_id": unit_id,
|
||||
"point_id": point_id
|
||||
}),
|
||||
))
|
||||
}
|
||||
AppEvent::AutoControlStarted { unit_id } => {
|
||||
let code = fetch_unit_code(pool, *unit_id).await;
|
||||
Some((
|
||||
"unit.auto_control_started", "info",
|
||||
Some(*unit_id), None, None,
|
||||
format!("Auto control started for unit {}", code),
|
||||
serde_json::json!({ "unit_id": unit_id }),
|
||||
))
|
||||
}
|
||||
AppEvent::AutoControlStopped { unit_id } => {
|
||||
let code = fetch_unit_code(pool, *unit_id).await;
|
||||
Some((
|
||||
"unit.auto_control_stopped", "info",
|
||||
Some(*unit_id), None, None,
|
||||
format!("Auto control stopped for unit {}", code),
|
||||
serde_json::json!({ "unit_id": unit_id }),
|
||||
))
|
||||
}
|
||||
AppEvent::FaultLocked { unit_id, equipment_id } => {
|
||||
let unit_code = fetch_unit_code(pool, *unit_id).await;
|
||||
let eq_code = fetch_equipment_code(pool, *equipment_id).await;
|
||||
Some((
|
||||
"unit.fault_locked", "error",
|
||||
Some(*unit_id), Some(*equipment_id), None,
|
||||
format!("Fault locked for unit {} by equipment {}", unit_code, eq_code),
|
||||
serde_json::json!({ "unit_id": unit_id, "equipment_id": equipment_id }),
|
||||
))
|
||||
}
|
||||
AppEvent::FaultAcked { unit_id } => {
|
||||
let code = fetch_unit_code(pool, *unit_id).await;
|
||||
Some((
|
||||
"unit.fault_acked", "info",
|
||||
Some(*unit_id), None, None,
|
||||
format!("Fault acknowledged for unit {}", code),
|
||||
serde_json::json!({ "unit_id": unit_id }),
|
||||
))
|
||||
}
|
||||
AppEvent::CommLocked { unit_id } => {
|
||||
let code = fetch_unit_code(pool, *unit_id).await;
|
||||
Some((
|
||||
"unit.comm_locked", "warn",
|
||||
Some(*unit_id), None, None,
|
||||
format!("Communication locked for unit {}", code),
|
||||
serde_json::json!({ "unit_id": unit_id }),
|
||||
))
|
||||
}
|
||||
AppEvent::CommRecovered { unit_id } => {
|
||||
let code = fetch_unit_code(pool, *unit_id).await;
|
||||
Some((
|
||||
"unit.comm_recovered", "info",
|
||||
Some(*unit_id), None, None,
|
||||
format!("Communication recovered for unit {}", code),
|
||||
serde_json::json!({ "unit_id": unit_id }),
|
||||
))
|
||||
}
|
||||
AppEvent::RemLocal { unit_id, equipment_id } => {
|
||||
let unit_code = fetch_unit_code(pool, *unit_id).await;
|
||||
let eq_code = fetch_equipment_code(pool, *equipment_id).await;
|
||||
Some((
|
||||
"unit.rem_local", "warn",
|
||||
Some(*unit_id), Some(*equipment_id), None,
|
||||
format!("Unit {} switched to local control via equipment {}", unit_code, eq_code),
|
||||
serde_json::json!({ "unit_id": unit_id, "equipment_id": equipment_id }),
|
||||
))
|
||||
}
|
||||
AppEvent::RemRecovered { unit_id } => {
|
||||
let code = fetch_unit_code(pool, *unit_id).await;
|
||||
Some((
|
||||
"unit.rem_recovered", "warn",
|
||||
Some(*unit_id), None, None,
|
||||
format!("Unit {} returned to remote control; auto control requires manual restart", code),
|
||||
serde_json::json!({ "unit_id": unit_id }),
|
||||
))
|
||||
}
|
||||
AppEvent::UnitStateChanged { .. } => None,
|
||||
AppEvent::PointNewValue(_) => None,
|
||||
};
|
||||
|
||||
let Some((event_type, level, unit_id, equipment_id, source_id, message, payload)) = record else {
|
||||
return;
|
||||
};
|
||||
let envelope = EventEnvelope::new(event_type, payload);
|
||||
|
||||
let inserted = sqlx::query_as::<_, EventRecord>(
|
||||
r#"
|
||||
INSERT INTO event (event_type, level, unit_id, equipment_id, source_id, message, payload)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(envelope.event_type)
|
||||
.bind(level)
|
||||
.bind(unit_id as Option<Uuid>)
|
||||
.bind(equipment_id as Option<Uuid>)
|
||||
.bind(source_id)
|
||||
.bind(message)
|
||||
.bind(sqlx::types::Json(envelope.payload))
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
|
||||
match inserted {
|
||||
Ok(record) => {
|
||||
if let Some(ws_manager) = ws_manager {
|
||||
let ws_message = crate::websocket::WsMessage::EventCreated(record);
|
||||
if let Err(err) = ws_manager.send_to_public(ws_message).await {
|
||||
tracing::warn!("Failed to broadcast event websocket message: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to persist event: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_point_new_value(
|
||||
payload: crate::telemetry::PointNewValue,
|
||||
connection_manager: &std::sync::Arc<crate::connection::ConnectionManager>,
|
||||
ws_manager: Option<&std::sync::Arc<crate::websocket::WebSocketManager>>,
|
||||
) {
|
||||
let source_id = payload.source_id;
|
||||
let client_handle = payload.client_handle;
|
||||
let point_id = if let Some(point_id) = payload.point_id {
|
||||
Some(point_id)
|
||||
} else {
|
||||
let status = connection_manager.get_status_read_guard().await;
|
||||
status
|
||||
.get(&source_id)
|
||||
.and_then(|s| s.client_handle_map.get(&client_handle).copied())
|
||||
};
|
||||
if let Some(point_id) = point_id {
|
||||
// Read the previous value from the in-memory cache.
|
||||
let (old_value, old_timestamp, value_changed) = {
|
||||
let monitor_data = connection_manager.get_point_monitor_data_read_guard().await;
|
||||
let old_monitor_info = monitor_data.get(&point_id);
|
||||
|
||||
if let Some(old_info) = old_monitor_info {
|
||||
let changed = old_info.value != payload.value || old_info.timestamp != payload.timestamp;
|
||||
(old_info.value.clone(), old_info.timestamp, changed)
|
||||
} else {
|
||||
(None, None, false)
|
||||
}
|
||||
};
|
||||
|
||||
let monitor = crate::telemetry::PointMonitorInfo {
|
||||
protocol: payload.protocol,
|
||||
source_id,
|
||||
point_id,
|
||||
client_handle,
|
||||
scan_mode: payload.scan_mode,
|
||||
timestamp: payload.timestamp,
|
||||
quality: payload.quality,
|
||||
value: payload.value,
|
||||
value_type: payload.value_type,
|
||||
value_text: payload.value_text,
|
||||
old_value,
|
||||
old_timestamp,
|
||||
value_changed,
|
||||
};
|
||||
|
||||
if let Err(e) = connection_manager
|
||||
.update_point_monitor_data(monitor.clone())
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to update point monitor data for point {}: {}",
|
||||
point_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ws_manager) = ws_manager {
|
||||
let ws_message = crate::websocket::WsMessage::PointNewValue(monitor);
|
||||
if let Err(e) = ws_manager.send_to_public(ws_message).await {
|
||||
tracing::warn!(
|
||||
"Failed to send WebSocket message to public room: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Point not found for source {} client_handle {}",
|
||||
source_id,
|
||||
client_handle
|
||||
);
|
||||
if let Some(record) = record {
|
||||
record_event(pool, ws_manager.map(Arc::as_ref), record).await;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,2 @@
|
|||
pub mod control;
|
||||
pub mod control;
|
||||
pub mod doc;
|
||||
pub mod equipment;
|
||||
pub mod log {
|
||||
pub use plc_platform_core::handler::log::*;
|
||||
}
|
||||
pub mod page;
|
||||
pub mod point;
|
||||
pub mod source;
|
||||
pub mod tag;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
use axum::{
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
|
|
@ -10,18 +12,25 @@ use uuid::Uuid;
|
|||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
control::runtime::{UnitRuntime, UnitRuntimeState},
|
||||
control::validator::{validate_manual_control, ControlAction},
|
||||
event::AppEvent,
|
||||
model::ControlUnit,
|
||||
service as feeder_service,
|
||||
AppState,
|
||||
};
|
||||
use plc_platform_core::{
|
||||
handler::equipment::SignalRolePoint,
|
||||
model::{Equipment, Point},
|
||||
service,
|
||||
telemetry::PointMonitorInfo,
|
||||
util::{
|
||||
pagination::{PaginatedResponse, PaginationParams},
|
||||
response::ApiErr,
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
|
||||
fn validate_unit_timing_order(
|
||||
run_time_sec: i32,
|
||||
acc_time_sec: i32,
|
||||
) -> Result<(), ApiErr> {
|
||||
fn validate_unit_timing_order(run_time_sec: i32, acc_time_sec: i32) -> Result<(), ApiErr> {
|
||||
if acc_time_sec <= run_time_sec {
|
||||
return Err(ApiErr::BadRequest(
|
||||
"acc_time_sec must be greater than run_time_sec".to_string(),
|
||||
|
|
@ -35,7 +44,7 @@ fn validate_unit_timing_order(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn auto_control_start_blocked(runtime: &crate::control::runtime::UnitRuntime) -> bool {
|
||||
fn auto_control_start_blocked(runtime: &UnitRuntime) -> bool {
|
||||
runtime.fault_locked || runtime.comm_locked || runtime.manual_ack_required || runtime.rem_local
|
||||
}
|
||||
|
||||
|
|
@ -50,15 +59,15 @@ pub struct GetUnitListQuery {
|
|||
#[derive(serde::Serialize)]
|
||||
pub struct UnitEquipmentItem {
|
||||
#[serde(flatten)]
|
||||
pub equipment: plc_platform_core::model::Equipment,
|
||||
pub role_points: Vec<crate::handler::equipment::SignalRolePoint>,
|
||||
pub equipment: Equipment,
|
||||
pub role_points: Vec<SignalRolePoint>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct UnitWithRuntime {
|
||||
#[serde(flatten)]
|
||||
pub unit: plc_platform_core::model::ControlUnit,
|
||||
pub runtime: Option<crate::control::runtime::UnitRuntime>,
|
||||
pub unit: ControlUnit,
|
||||
pub runtime: Option<UnitRuntime>,
|
||||
pub equipments: Vec<UnitEquipmentItem>,
|
||||
}
|
||||
|
||||
|
|
@ -68,8 +77,10 @@ pub async fn get_unit_list(
|
|||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
query.validate()?;
|
||||
|
||||
let total = crate::service::get_units_count(&state.platform.pool, query.keyword.as_deref()).await?;
|
||||
let units = crate::service::get_units_paginated(
|
||||
let total =
|
||||
feeder_service::get_units_count(&state.platform.pool, query.keyword.as_deref())
|
||||
.await?;
|
||||
let units = feeder_service::get_units_paginated(
|
||||
&state.platform.pool,
|
||||
query.keyword.as_deref(),
|
||||
query.pagination.page_size,
|
||||
|
|
@ -81,42 +92,47 @@ pub async fn get_unit_list(
|
|||
|
||||
let unit_ids: Vec<Uuid> = units.iter().map(|u| u.id).collect();
|
||||
let all_equipments =
|
||||
crate::service::get_equipment_by_unit_ids(&state.platform.pool, &unit_ids).await?;
|
||||
service::get_equipment_by_unit_ids(&state.platform.pool, &unit_ids)
|
||||
.await?;
|
||||
|
||||
let eq_ids: Vec<Uuid> = all_equipments.iter().map(|e| e.id).collect();
|
||||
let role_point_rows =
|
||||
crate::service::get_signal_role_points_batch(&state.platform.pool, &eq_ids).await?;
|
||||
service::get_signal_role_points_batch(&state.platform.pool, &eq_ids)
|
||||
.await?;
|
||||
|
||||
let monitor_guard = state
|
||||
.platform.connection_manager
|
||||
.platform
|
||||
.connection_manager
|
||||
.get_point_monitor_data_read_guard()
|
||||
.await;
|
||||
|
||||
let mut role_points_map: std::collections::HashMap<
|
||||
let mut role_points_map: HashMap<
|
||||
Uuid,
|
||||
Vec<crate::handler::equipment::SignalRolePoint>,
|
||||
> = std::collections::HashMap::new();
|
||||
Vec<SignalRolePoint>,
|
||||
> = HashMap::new();
|
||||
for rp in role_point_rows {
|
||||
role_points_map
|
||||
.entry(rp.equipment_id)
|
||||
.or_default()
|
||||
.push(crate::handler::equipment::SignalRolePoint {
|
||||
role_points_map.entry(rp.equipment_id).or_default().push(
|
||||
SignalRolePoint {
|
||||
point_id: rp.point_id,
|
||||
signal_role: rp.signal_role,
|
||||
point_monitor: monitor_guard.get(&rp.point_id).cloned(),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
drop(monitor_guard);
|
||||
|
||||
let mut equipments_by_unit: std::collections::HashMap<Uuid, Vec<UnitEquipmentItem>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut equipments_by_unit: HashMap<Uuid, Vec<UnitEquipmentItem>> =
|
||||
HashMap::new();
|
||||
for eq in all_equipments {
|
||||
let role_points = role_points_map.remove(&eq.id).unwrap_or_default();
|
||||
if let Some(unit_id) = eq.unit_id {
|
||||
equipments_by_unit
|
||||
.entry(unit_id)
|
||||
.or_default()
|
||||
.push(UnitEquipmentItem { equipment: eq, role_points });
|
||||
.push(UnitEquipmentItem {
|
||||
equipment: eq,
|
||||
role_points,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +141,11 @@ pub async fn get_unit_list(
|
|||
.map(|unit| {
|
||||
let runtime = all_runtimes.get(&unit.id).cloned();
|
||||
let equipments = equipments_by_unit.remove(&unit.id).unwrap_or_default();
|
||||
UnitWithRuntime { unit, runtime, equipments }
|
||||
UnitWithRuntime {
|
||||
unit,
|
||||
runtime,
|
||||
equipments,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
|
|
@ -151,7 +171,6 @@ pub async fn stop_equipment(
|
|||
send_equipment_command(state, equipment_id, ControlAction::Stop).await
|
||||
}
|
||||
|
||||
|
||||
async fn send_equipment_command(
|
||||
state: AppState,
|
||||
equipment_id: Uuid,
|
||||
|
|
@ -169,7 +188,7 @@ async fn send_equipment_command(
|
|||
.await
|
||||
.map_err(|e| ApiErr::Internal(e, None))?;
|
||||
|
||||
if state.config.simulate_plc {
|
||||
if crate::control::simulate::enabled() {
|
||||
crate::control::simulate::simulate_run_feedback(
|
||||
&state,
|
||||
equipment_id,
|
||||
|
|
@ -179,12 +198,12 @@ async fn send_equipment_command(
|
|||
}
|
||||
|
||||
let event = match action {
|
||||
ControlAction::Start => crate::event::AppEvent::EquipmentStartCommandSent {
|
||||
ControlAction::Start => AppEvent::EquipmentStartCommandSent {
|
||||
equipment_id,
|
||||
unit_id: context.unit_id,
|
||||
point_id: context.command_point.point_id,
|
||||
},
|
||||
ControlAction::Stop => crate::event::AppEvent::EquipmentStopCommandSent {
|
||||
ControlAction::Stop => AppEvent::EquipmentStopCommandSent {
|
||||
equipment_id,
|
||||
unit_id: context.unit_id,
|
||||
point_id: context.command_point.point_id,
|
||||
|
|
@ -206,33 +225,34 @@ pub async fn get_unit(
|
|||
State(state): State<AppState>,
|
||||
Path(unit_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let unit = crate::service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||
let unit = feeder_service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
||||
let runtime = state.control_runtime.get(unit_id).await;
|
||||
|
||||
let all_equipments =
|
||||
crate::service::get_equipment_by_unit_id(&state.platform.pool, unit_id).await?;
|
||||
service::get_equipment_by_unit_id(&state.platform.pool, unit_id).await?;
|
||||
let eq_ids: Vec<Uuid> = all_equipments.iter().map(|e| e.id).collect();
|
||||
let role_point_rows =
|
||||
crate::service::get_signal_role_points_batch(&state.platform.pool, &eq_ids).await?;
|
||||
service::get_signal_role_points_batch(&state.platform.pool, &eq_ids)
|
||||
.await?;
|
||||
let monitor_guard = state
|
||||
.platform.connection_manager
|
||||
.platform
|
||||
.connection_manager
|
||||
.get_point_monitor_data_read_guard()
|
||||
.await;
|
||||
let mut role_points_map: std::collections::HashMap<
|
||||
let mut role_points_map: HashMap<
|
||||
Uuid,
|
||||
Vec<crate::handler::equipment::SignalRolePoint>,
|
||||
> = std::collections::HashMap::new();
|
||||
Vec<SignalRolePoint>,
|
||||
> = HashMap::new();
|
||||
for rp in role_point_rows {
|
||||
role_points_map
|
||||
.entry(rp.equipment_id)
|
||||
.or_default()
|
||||
.push(crate::handler::equipment::SignalRolePoint {
|
||||
role_points_map.entry(rp.equipment_id).or_default().push(
|
||||
SignalRolePoint {
|
||||
point_id: rp.point_id,
|
||||
signal_role: rp.signal_role,
|
||||
point_monitor: monitor_guard.get(&rp.point_id).cloned(),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
drop(monitor_guard);
|
||||
|
||||
|
|
@ -240,32 +260,39 @@ pub async fn get_unit(
|
|||
.into_iter()
|
||||
.map(|eq| {
|
||||
let role_points = role_points_map.remove(&eq.id).unwrap_or_default();
|
||||
UnitEquipmentItem { equipment: eq, role_points }
|
||||
UnitEquipmentItem {
|
||||
equipment: eq,
|
||||
role_points,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(UnitWithRuntime { unit, runtime, equipments }))
|
||||
Ok(Json(UnitWithRuntime {
|
||||
unit,
|
||||
runtime,
|
||||
equipments,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct PointDetail {
|
||||
#[serde(flatten)]
|
||||
pub point: plc_platform_core::model::Point,
|
||||
pub point_monitor: Option<crate::telemetry::PointMonitorInfo>,
|
||||
pub point: Point,
|
||||
pub point_monitor: Option<PointMonitorInfo>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct EquipmentDetail {
|
||||
#[serde(flatten)]
|
||||
pub equipment: plc_platform_core::model::Equipment,
|
||||
pub equipment: Equipment,
|
||||
pub points: Vec<PointDetail>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct UnitDetail {
|
||||
#[serde(flatten)]
|
||||
pub unit: plc_platform_core::model::ControlUnit,
|
||||
pub runtime: Option<crate::control::runtime::UnitRuntime>,
|
||||
pub unit: ControlUnit,
|
||||
pub runtime: Option<UnitRuntime>,
|
||||
pub equipments: Vec<EquipmentDetail>,
|
||||
}
|
||||
|
||||
|
|
@ -273,18 +300,24 @@ pub async fn get_unit_detail(
|
|||
State(state): State<AppState>,
|
||||
Path(unit_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let unit = crate::service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||
let unit = feeder_service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
||||
|
||||
let runtime = state.control_runtime.get(unit_id).await;
|
||||
|
||||
let equipments = crate::service::get_equipment_by_unit_id(&state.platform.pool, unit_id).await?;
|
||||
let equipments =
|
||||
service::get_equipment_by_unit_id(&state.platform.pool, unit_id).await?;
|
||||
let equipment_ids: Vec<Uuid> = equipments.iter().map(|e| e.id).collect();
|
||||
let all_points = crate::service::get_points_by_equipment_ids(&state.platform.pool, &equipment_ids).await?;
|
||||
let all_points = service::get_points_by_equipment_ids(
|
||||
&state.platform.pool,
|
||||
&equipment_ids,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let monitor_guard = state
|
||||
.platform.connection_manager
|
||||
.platform
|
||||
.connection_manager
|
||||
.get_point_monitor_data_read_guard()
|
||||
.await;
|
||||
|
||||
|
|
@ -299,11 +332,18 @@ pub async fn get_unit_detail(
|
|||
point: p.clone(),
|
||||
})
|
||||
.collect();
|
||||
EquipmentDetail { equipment: eq, points }
|
||||
EquipmentDetail {
|
||||
equipment: eq,
|
||||
points,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(UnitDetail { unit, runtime, equipments }))
|
||||
Ok(Json(UnitDetail {
|
||||
unit,
|
||||
runtime,
|
||||
equipments,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
|
|
@ -358,7 +398,7 @@ pub async fn create_unit(
|
|||
|
||||
validate_unit_timing_order(run_time_sec, acc_time_sec)?;
|
||||
|
||||
if crate::service::get_unit_by_code(&state.platform.pool, &payload.code)
|
||||
if feeder_service::get_unit_by_code(&state.platform.pool, &payload.code)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
|
|
@ -368,9 +408,9 @@ pub async fn create_unit(
|
|||
));
|
||||
}
|
||||
|
||||
let unit_id = crate::service::create_unit(
|
||||
let unit_id = feeder_service::create_unit(
|
||||
&state.platform.pool,
|
||||
crate::service::CreateUnitParams {
|
||||
feeder_service::CreateUnitParams {
|
||||
code: &payload.code,
|
||||
name: &payload.name,
|
||||
description: payload.description.as_deref(),
|
||||
|
|
@ -379,9 +419,7 @@ pub async fn create_unit(
|
|||
stop_time_sec,
|
||||
acc_time_sec,
|
||||
bl_time_sec,
|
||||
require_manual_ack_after_fault: payload
|
||||
.require_manual_ack_after_fault
|
||||
.unwrap_or(true),
|
||||
require_manual_ack_after_fault: payload.require_manual_ack_after_fault.unwrap_or(true),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -421,7 +459,7 @@ pub async fn update_unit(
|
|||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
|
||||
let existing_unit = crate::service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||
let existing_unit = feeder_service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
||||
|
||||
|
|
@ -431,7 +469,8 @@ pub async fn update_unit(
|
|||
)?;
|
||||
|
||||
if let Some(code) = payload.code.as_deref() {
|
||||
let duplicate = crate::service::get_unit_by_code(&state.platform.pool, code).await?;
|
||||
let duplicate =
|
||||
feeder_service::get_unit_by_code(&state.platform.pool, code).await?;
|
||||
if duplicate.as_ref().is_some_and(|item| item.id != unit_id) {
|
||||
return Err(ApiErr::BadRequest(
|
||||
"Unit code already exists".to_string(),
|
||||
|
|
@ -453,10 +492,10 @@ pub async fn update_unit(
|
|||
return Ok(Json(serde_json::json!({"ok_msg": "No fields to update"})));
|
||||
}
|
||||
|
||||
crate::service::update_unit(
|
||||
feeder_service::update_unit(
|
||||
&state.platform.pool,
|
||||
unit_id,
|
||||
crate::service::UpdateUnitParams {
|
||||
feeder_service::UpdateUnitParams {
|
||||
code: payload.code.as_deref(),
|
||||
name: payload.name.as_deref(),
|
||||
description: payload.description.as_deref(),
|
||||
|
|
@ -470,6 +509,8 @@ pub async fn update_unit(
|
|||
)
|
||||
.await?;
|
||||
|
||||
state.platform.metadata.invalidate_unit(unit_id).await;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok_msg": "Unit updated successfully"
|
||||
})))
|
||||
|
|
@ -479,11 +520,13 @@ pub async fn delete_unit(
|
|||
State(state): State<AppState>,
|
||||
Path(unit_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let deleted = crate::service::delete_unit(&state.platform.pool, unit_id).await?;
|
||||
let deleted = feeder_service::delete_unit(&state.platform.pool, unit_id).await?;
|
||||
if !deleted {
|
||||
return Err(ApiErr::NotFound("Unit not found".to_string(), None));
|
||||
}
|
||||
|
||||
state.platform.metadata.invalidate_unit(unit_id).await;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
|
@ -502,13 +545,13 @@ pub async fn get_event_list(
|
|||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
query.validate()?;
|
||||
|
||||
let total = crate::service::get_events_count(
|
||||
let total = service::get_events_count(
|
||||
&state.platform.pool,
|
||||
query.unit_id,
|
||||
query.event_type.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let data = crate::service::get_events_paginated(
|
||||
let data = service::get_events_paginated(
|
||||
&state.platform.pool,
|
||||
query.unit_id,
|
||||
query.event_type.as_deref(),
|
||||
|
|
@ -529,7 +572,7 @@ pub async fn start_auto_unit(
|
|||
State(state): State<AppState>,
|
||||
Path(unit_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let unit = crate::service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||
let unit = feeder_service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
||||
|
||||
|
|
@ -551,20 +594,24 @@ pub async fn start_auto_unit(
|
|||
return Err(ApiErr::BadRequest(message.to_string(), None));
|
||||
}
|
||||
runtime.auto_enabled = true;
|
||||
runtime.state = crate::control::runtime::UnitRuntimeState::Stopped;
|
||||
runtime.state = UnitRuntimeState::Stopped;
|
||||
state.control_runtime.upsert(runtime).await;
|
||||
state.control_runtime.notify_unit(unit_id).await;
|
||||
|
||||
let _ = state.event_manager.send(crate::event::AppEvent::AutoControlStarted { unit_id });
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(AppEvent::AutoControlStarted { unit_id });
|
||||
|
||||
Ok(Json(json!({ "ok_msg": "Auto control started", "unit_id": unit_id })))
|
||||
Ok(Json(
|
||||
json!({ "ok_msg": "Auto control started", "unit_id": unit_id }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn stop_auto_unit(
|
||||
State(state): State<AppState>,
|
||||
Path(unit_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
crate::service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||
feeder_service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
||||
|
||||
|
|
@ -573,15 +620,17 @@ pub async fn stop_auto_unit(
|
|||
state.control_runtime.upsert(runtime).await;
|
||||
state.control_runtime.notify_unit(unit_id).await;
|
||||
|
||||
let _ = state.event_manager.send(crate::event::AppEvent::AutoControlStopped { unit_id });
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(AppEvent::AutoControlStopped { unit_id });
|
||||
|
||||
Ok(Json(json!({ "ok_msg": "Auto control stopped", "unit_id": unit_id })))
|
||||
Ok(Json(
|
||||
json!({ "ok_msg": "Auto control stopped", "unit_id": unit_id }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn batch_start_auto(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let units = crate::service::get_all_enabled_units(&state.platform.pool).await?;
|
||||
pub async fn batch_start_auto(State(state): State<AppState>) -> Result<impl IntoResponse, ApiErr> {
|
||||
let units = feeder_service::get_all_enabled_units(&state.platform.pool).await?;
|
||||
let mut started = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
|
||||
|
|
@ -596,22 +645,20 @@ pub async fn batch_start_auto(
|
|||
continue;
|
||||
}
|
||||
runtime.auto_enabled = true;
|
||||
runtime.state = crate::control::runtime::UnitRuntimeState::Stopped;
|
||||
runtime.state = UnitRuntimeState::Stopped;
|
||||
state.control_runtime.upsert(runtime).await;
|
||||
state.control_runtime.notify_unit(unit.id).await;
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(crate::event::AppEvent::AutoControlStarted { unit_id: unit.id });
|
||||
.send(AppEvent::AutoControlStarted { unit_id: unit.id });
|
||||
started.push(unit.id);
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "started": started, "skipped": skipped })))
|
||||
}
|
||||
|
||||
pub async fn batch_stop_auto(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let units = crate::service::get_all_enabled_units(&state.platform.pool).await?;
|
||||
pub async fn batch_stop_auto(State(state): State<AppState>) -> Result<impl IntoResponse, ApiErr> {
|
||||
let units = feeder_service::get_all_enabled_units(&state.platform.pool).await?;
|
||||
let mut stopped = Vec::new();
|
||||
|
||||
for unit in units {
|
||||
|
|
@ -624,7 +671,7 @@ pub async fn batch_stop_auto(
|
|||
state.control_runtime.notify_unit(unit.id).await;
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(crate::event::AppEvent::AutoControlStopped { unit_id: unit.id });
|
||||
.send(AppEvent::AutoControlStopped { unit_id: unit.id });
|
||||
stopped.push(unit.id);
|
||||
}
|
||||
|
||||
|
|
@ -635,7 +682,7 @@ pub async fn ack_fault_unit(
|
|||
State(state): State<AppState>,
|
||||
Path(unit_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
crate::service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||
feeder_service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
||||
|
||||
|
|
@ -656,20 +703,24 @@ pub async fn ack_fault_unit(
|
|||
|
||||
runtime.fault_locked = false;
|
||||
runtime.manual_ack_required = false;
|
||||
runtime.state = crate::control::runtime::UnitRuntimeState::Stopped;
|
||||
runtime.state = UnitRuntimeState::Stopped;
|
||||
state.control_runtime.upsert(runtime).await;
|
||||
state.control_runtime.notify_unit(unit_id).await;
|
||||
|
||||
let _ = state.event_manager.send(crate::event::AppEvent::FaultAcked { unit_id });
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(AppEvent::FaultAcked { unit_id });
|
||||
|
||||
Ok(Json(json!({ "ok_msg": "Fault acknowledged", "unit_id": unit_id })))
|
||||
Ok(Json(
|
||||
json!({ "ok_msg": "Fault acknowledged", "unit_id": unit_id }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_unit_runtime(
|
||||
State(state): State<AppState>,
|
||||
Path(unit_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
crate::service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||
feeder_service::get_unit_by_id(&state.platform.pool, unit_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,31 +1,10 @@
|
|||
pub mod app;
|
||||
pub mod config;
|
||||
pub mod control;
|
||||
pub mod event;
|
||||
pub mod handler;
|
||||
pub mod middleware;
|
||||
pub mod model;
|
||||
pub mod router;
|
||||
pub mod websocket;
|
||||
pub mod service;
|
||||
|
||||
pub mod connection {
|
||||
pub use plc_platform_core::connection::*;
|
||||
}
|
||||
|
||||
pub mod db {
|
||||
pub use plc_platform_core::db::*;
|
||||
}
|
||||
|
||||
pub mod service {
|
||||
pub use plc_platform_core::service::*;
|
||||
}
|
||||
|
||||
pub mod telemetry {
|
||||
pub use plc_platform_core::telemetry::*;
|
||||
}
|
||||
|
||||
pub mod util {
|
||||
pub use plc_platform_core::util::*;
|
||||
}
|
||||
|
||||
pub use app::{run, AppState, test_state};
|
||||
pub use app::{run, test_state, AppState};
|
||||
pub use router::build_router;
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
use axum::{
|
||||
body::Body,
|
||||
http::Request,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use std::time::Instant;
|
||||
|
||||
pub async fn simple_logger(
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Borrow the path string directly; no clone needed.
|
||||
let method = req.method().to_string();
|
||||
let uri = req.uri().to_string(); // `Uri::to_string()` allocates the owned string once.
|
||||
|
||||
let start = Instant::now();
|
||||
let res = next.run(req).await;
|
||||
let duration = start.elapsed();
|
||||
let status = res.status();
|
||||
match status.as_u16() {
|
||||
100..=399 => {
|
||||
tracing::info!("{} {} {} {:?}", method, uri, status, duration);
|
||||
}
|
||||
400..=499 => {
|
||||
tracing::warn!("{} {} {} {:?}", method, uri, status, duration);
|
||||
}
|
||||
500..=599 => {
|
||||
tracing::error!("{} {} {} {:?}", method, uri, status, duration);
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("{} {} {} {:?}", method, uri, status, duration);
|
||||
}
|
||||
}
|
||||
|
||||
res
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use plc_platform_core::util::datetime::utc_to_local_str;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
|
||||
pub struct ControlUnit {
|
||||
pub id: Uuid,
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub run_time_sec: i32,
|
||||
pub stop_time_sec: i32,
|
||||
pub acc_time_sec: i32,
|
||||
pub bl_time_sec: i32,
|
||||
pub require_manual_ack_after_fault: bool,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
|
@ -1,93 +1,17 @@
|
|||
use axum::{
|
||||
extract::Request,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
routing::{get, post, put},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::{handler, middleware::simple_logger, websocket, AppState};
|
||||
|
||||
async fn no_cache(req: Request, next: Next) -> Response {
|
||||
let mut response = next.run(req).await;
|
||||
response.headers_mut().insert(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
axum::http::HeaderValue::from_static("no-store"),
|
||||
);
|
||||
response
|
||||
}
|
||||
use crate::{handler, AppState};
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let all_route = Router::new()
|
||||
.route(
|
||||
"/api/source",
|
||||
get(handler::source::get_source_list).post(handler::source::create_source),
|
||||
)
|
||||
.route(
|
||||
"/api/source/{source_id}",
|
||||
axum::routing::delete(handler::source::delete_source)
|
||||
.put(handler::source::update_source),
|
||||
)
|
||||
.route(
|
||||
"/api/source/{source_id}/reconnect",
|
||||
axum::routing::post(handler::source::reconnect_source),
|
||||
)
|
||||
.route(
|
||||
"/api/source/{source_id}/browse",
|
||||
axum::routing::post(handler::source::browse_and_save_nodes),
|
||||
)
|
||||
.route(
|
||||
"/api/source/{source_id}/node-tree",
|
||||
get(handler::source::get_node_tree),
|
||||
)
|
||||
.route("/api/point", get(handler::point::get_point_list))
|
||||
.route(
|
||||
"/api/point/value/batch",
|
||||
axum::routing::post(handler::point::batch_set_point_value),
|
||||
)
|
||||
.route(
|
||||
"/api/point/batch",
|
||||
axum::routing::post(handler::point::batch_create_points)
|
||||
.delete(handler::point::batch_delete_points),
|
||||
)
|
||||
.route(
|
||||
"/api/point/{point_id}/history",
|
||||
get(handler::point::get_point_history),
|
||||
)
|
||||
.route(
|
||||
"/api/point/{point_id}",
|
||||
get(handler::point::get_point)
|
||||
.put(handler::point::update_point)
|
||||
.delete(handler::point::delete_point),
|
||||
)
|
||||
.route(
|
||||
"/api/point/batch/set-tags",
|
||||
put(handler::point::batch_set_point_tags),
|
||||
)
|
||||
.route(
|
||||
"/api/point/batch/set-equipment",
|
||||
put(handler::point::batch_set_point_equipment),
|
||||
)
|
||||
.route(
|
||||
"/api/equipment",
|
||||
get(handler::equipment::get_equipment_list).post(handler::equipment::create_equipment),
|
||||
)
|
||||
.route(
|
||||
"/api/equipment/{equipment_id}",
|
||||
get(handler::equipment::get_equipment)
|
||||
.put(handler::equipment::update_equipment)
|
||||
.delete(handler::equipment::delete_equipment),
|
||||
)
|
||||
.route(
|
||||
"/api/equipment/batch/set-unit",
|
||||
put(handler::equipment::batch_set_equipment_unit),
|
||||
)
|
||||
.route(
|
||||
"/api/equipment/{equipment_id}/points",
|
||||
get(handler::equipment::get_equipment_points),
|
||||
)
|
||||
// Platform routes (source, point, equipment, tag, page, logs) from core.
|
||||
let platform = plc_platform_core::handler::platform_routes::<AppState>();
|
||||
|
||||
// Feeder-specific routes.
|
||||
let feeder_routes = Router::new()
|
||||
// Unit / control routes (feeder-specific).
|
||||
.route(
|
||||
"/api/unit",
|
||||
get(handler::control::get_unit_list).post(handler::control::create_unit),
|
||||
|
|
@ -135,54 +59,28 @@ pub fn build_router(state: AppState) -> Router {
|
|||
"/api/unit/{unit_id}/detail",
|
||||
get(handler::control::get_unit_detail),
|
||||
)
|
||||
.route(
|
||||
"/api/tag",
|
||||
get(handler::tag::get_tag_list).post(handler::tag::create_tag),
|
||||
)
|
||||
.route(
|
||||
"/api/tag/{tag_id}",
|
||||
get(handler::tag::get_tag_points)
|
||||
.put(handler::tag::update_tag)
|
||||
.delete(handler::tag::delete_tag),
|
||||
)
|
||||
.route(
|
||||
"/api/page",
|
||||
get(handler::page::get_page_list).post(handler::page::create_page),
|
||||
)
|
||||
.route(
|
||||
"/api/page/{page_id}",
|
||||
get(handler::page::get_page)
|
||||
.put(handler::page::update_page)
|
||||
.delete(handler::page::delete_page),
|
||||
)
|
||||
.route("/api/logs", get(handler::log::get_logs))
|
||||
.route("/api/logs/stream", get(handler::log::stream_logs))
|
||||
// Doc routes (feeder-specific doc paths).
|
||||
.route("/api/docs/api-md", get(handler::doc::get_api_md))
|
||||
.route("/api/docs/readme-md", get(handler::doc::get_readme_md));
|
||||
|
||||
Router::new()
|
||||
.merge(all_route)
|
||||
.merge(platform)
|
||||
.merge(feeder_routes)
|
||||
.nest(
|
||||
"/ui",
|
||||
Router::new()
|
||||
.fallback_service(
|
||||
ServeDir::new("web/feeder")
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(ServeDir::new("web/core")),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(no_cache)),
|
||||
plc_platform_core::http::static_ui_routes("web/feeder", "web/core"),
|
||||
)
|
||||
.route(
|
||||
"/ws/public",
|
||||
get(plc_platform_core::websocket::public_websocket_handler::<AppState>),
|
||||
)
|
||||
.route("/ws/public", get(websocket::public_websocket_handler))
|
||||
.route(
|
||||
"/ws/client/{client_id}",
|
||||
get(websocket::client_websocket_handler),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(simple_logger))
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
get(plc_platform_core::websocket::client_websocket_handler::<AppState>),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(
|
||||
plc_platform_core::http::simple_logger,
|
||||
))
|
||||
.layer(plc_platform_core::http::permissive_cors())
|
||||
.with_state(state)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
pub mod unit;
|
||||
|
||||
pub use unit::*;
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::ControlUnit;
|
||||
|
||||
fn unit_order_clause() -> &'static str {
|
||||
"code"
|
||||
}
|
||||
|
||||
pub async fn get_units_count(pool: &PgPool, keyword: Option<&str>) -> Result<i64, sqlx::Error> {
|
||||
match keyword {
|
||||
Some(keyword) => {
|
||||
let like = format!("%{}%", keyword);
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM unit
|
||||
WHERE code ILIKE $1 OR name ILIKE $1
|
||||
"#,
|
||||
)
|
||||
.bind(like)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
sqlx::query_scalar::<_, i64>(r#"SELECT COUNT(*) FROM unit"#)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_units_paginated(
|
||||
pool: &PgPool,
|
||||
keyword: Option<&str>,
|
||||
page_size: i32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<ControlUnit>, sqlx::Error> {
|
||||
let unit_order = unit_order_clause();
|
||||
match keyword {
|
||||
Some(keyword) => {
|
||||
let like = format!("%{}%", keyword);
|
||||
if page_size == -1 {
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT *
|
||||
FROM unit
|
||||
WHERE code ILIKE $1 OR name ILIKE $1
|
||||
ORDER BY {}
|
||||
"#,
|
||||
unit_order
|
||||
);
|
||||
sqlx::query_as::<_, ControlUnit>(&sql)
|
||||
.bind(like)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else {
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT *
|
||||
FROM unit
|
||||
WHERE code ILIKE $1 OR name ILIKE $1
|
||||
ORDER BY {}
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
unit_order
|
||||
);
|
||||
sqlx::query_as::<_, ControlUnit>(&sql)
|
||||
.bind(like)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if page_size == -1 {
|
||||
let sql = format!("SELECT * FROM unit ORDER BY {}", unit_order);
|
||||
sqlx::query_as::<_, ControlUnit>(&sql).fetch_all(pool).await
|
||||
} else {
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT *
|
||||
FROM unit
|
||||
ORDER BY {}
|
||||
LIMIT $1 OFFSET $2
|
||||
"#,
|
||||
unit_order
|
||||
);
|
||||
sqlx::query_as::<_, ControlUnit>(&sql)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_unit_by_id(
|
||||
pool: &PgPool,
|
||||
unit_id: Uuid,
|
||||
) -> Result<Option<ControlUnit>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ControlUnit>(r#"SELECT * FROM unit WHERE id = $1"#)
|
||||
.bind(unit_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_unit_by_code(
|
||||
pool: &PgPool,
|
||||
code: &str,
|
||||
) -> Result<Option<ControlUnit>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ControlUnit>(r#"SELECT * FROM unit WHERE code = $1"#)
|
||||
.bind(code)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub struct CreateUnitParams<'a> {
|
||||
pub code: &'a str,
|
||||
pub name: &'a str,
|
||||
pub description: Option<&'a str>,
|
||||
pub enabled: bool,
|
||||
pub run_time_sec: i32,
|
||||
pub stop_time_sec: i32,
|
||||
pub acc_time_sec: i32,
|
||||
pub bl_time_sec: i32,
|
||||
pub require_manual_ack_after_fault: bool,
|
||||
}
|
||||
|
||||
pub async fn create_unit(pool: &PgPool, params: CreateUnitParams<'_>) -> Result<Uuid, sqlx::Error> {
|
||||
let unit_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO unit (
|
||||
id, code, name, description, enabled,
|
||||
run_time_sec, stop_time_sec, acc_time_sec, bl_time_sec,
|
||||
require_manual_ack_after_fault
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(unit_id)
|
||||
.bind(params.code)
|
||||
.bind(params.name)
|
||||
.bind(params.description)
|
||||
.bind(params.enabled)
|
||||
.bind(params.run_time_sec)
|
||||
.bind(params.stop_time_sec)
|
||||
.bind(params.acc_time_sec)
|
||||
.bind(params.bl_time_sec)
|
||||
.bind(params.require_manual_ack_after_fault)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(unit_id)
|
||||
}
|
||||
|
||||
pub struct UpdateUnitParams<'a> {
|
||||
pub code: Option<&'a str>,
|
||||
pub name: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
pub enabled: Option<bool>,
|
||||
pub run_time_sec: Option<i32>,
|
||||
pub stop_time_sec: Option<i32>,
|
||||
pub acc_time_sec: Option<i32>,
|
||||
pub bl_time_sec: Option<i32>,
|
||||
pub require_manual_ack_after_fault: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn update_unit(
|
||||
pool: &PgPool,
|
||||
unit_id: Uuid,
|
||||
params: UpdateUnitParams<'_>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut updates = Vec::new();
|
||||
let mut param_count = 1;
|
||||
|
||||
if params.code.is_some() {
|
||||
updates.push(format!("code = ${}", param_count));
|
||||
param_count += 1;
|
||||
}
|
||||
if params.name.is_some() {
|
||||
updates.push(format!("name = ${}", param_count));
|
||||
param_count += 1;
|
||||
}
|
||||
if params.description.is_some() {
|
||||
updates.push(format!("description = ${}", param_count));
|
||||
param_count += 1;
|
||||
}
|
||||
if params.enabled.is_some() {
|
||||
updates.push(format!("enabled = ${}", param_count));
|
||||
param_count += 1;
|
||||
}
|
||||
if params.run_time_sec.is_some() {
|
||||
updates.push(format!("run_time_sec = ${}", param_count));
|
||||
param_count += 1;
|
||||
}
|
||||
if params.stop_time_sec.is_some() {
|
||||
updates.push(format!("stop_time_sec = ${}", param_count));
|
||||
param_count += 1;
|
||||
}
|
||||
if params.acc_time_sec.is_some() {
|
||||
updates.push(format!("acc_time_sec = ${}", param_count));
|
||||
param_count += 1;
|
||||
}
|
||||
if params.bl_time_sec.is_some() {
|
||||
updates.push(format!("bl_time_sec = ${}", param_count));
|
||||
param_count += 1;
|
||||
}
|
||||
if params.require_manual_ack_after_fault.is_some() {
|
||||
updates.push(format!("require_manual_ack_after_fault = ${}", param_count));
|
||||
param_count += 1;
|
||||
}
|
||||
|
||||
updates.push("updated_at = NOW()".to_string());
|
||||
|
||||
let sql = format!(
|
||||
r#"UPDATE unit SET {} WHERE id = ${}"#,
|
||||
updates.join(", "),
|
||||
param_count
|
||||
);
|
||||
|
||||
let mut query = sqlx::query(&sql);
|
||||
|
||||
if let Some(code) = params.code {
|
||||
query = query.bind(code);
|
||||
}
|
||||
if let Some(name) = params.name {
|
||||
query = query.bind(name);
|
||||
}
|
||||
if let Some(description) = params.description {
|
||||
query = query.bind(description);
|
||||
}
|
||||
if let Some(enabled) = params.enabled {
|
||||
query = query.bind(enabled);
|
||||
}
|
||||
if let Some(run_time_sec) = params.run_time_sec {
|
||||
query = query.bind(run_time_sec);
|
||||
}
|
||||
if let Some(stop_time_sec) = params.stop_time_sec {
|
||||
query = query.bind(stop_time_sec);
|
||||
}
|
||||
if let Some(acc_time_sec) = params.acc_time_sec {
|
||||
query = query.bind(acc_time_sec);
|
||||
}
|
||||
if let Some(bl_time_sec) = params.bl_time_sec {
|
||||
query = query.bind(bl_time_sec);
|
||||
}
|
||||
if let Some(require_manual_ack_after_fault) = params.require_manual_ack_after_fault {
|
||||
query = query.bind(require_manual_ack_after_fault);
|
||||
}
|
||||
|
||||
query.bind(unit_id).execute(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_unit(pool: &PgPool, unit_id: Uuid) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query(r#"DELETE FROM unit WHERE id = $1"#)
|
||||
.bind(unit_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn get_all_enabled_units(pool: &PgPool) -> Result<Vec<ControlUnit>, sqlx::Error> {
|
||||
let sql = format!(
|
||||
"SELECT * FROM unit WHERE enabled = TRUE ORDER BY {}",
|
||||
unit_order_clause()
|
||||
);
|
||||
sqlx::query_as::<_, ControlUnit>(&sql).fetch_all(pool).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::unit_order_clause;
|
||||
|
||||
#[test]
|
||||
fn unit_ordering_defaults_to_code() {
|
||||
assert_eq!(unit_order_clause(), "code");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
Path, State,
|
||||
},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use plc_platform_core::websocket::{
|
||||
RoomManager, WebSocketManager, WsClientMessage, WsMessage,
|
||||
};
|
||||
|
||||
/// Public websocket handler.
|
||||
pub async fn public_websocket_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<crate::AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let ws_manager = state.platform.ws_manager.clone();
|
||||
let app_state = state.clone();
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, ws_manager, "public".to_string(), app_state))
|
||||
}
|
||||
|
||||
/// Client websocket handler.
|
||||
pub async fn client_websocket_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
Path(client_id): Path<Uuid>,
|
||||
State(state): State<crate::AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let ws_manager = state.platform.ws_manager.clone();
|
||||
let room_id = client_id.to_string();
|
||||
let app_state = state.clone();
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, ws_manager, room_id, app_state))
|
||||
}
|
||||
|
||||
/// Handle websocket connection for one room.
|
||||
async fn handle_socket(
|
||||
mut socket: WebSocket,
|
||||
ws_manager: Arc<WebSocketManager>,
|
||||
room_id: String,
|
||||
state: crate::AppState,
|
||||
) {
|
||||
let mut rx = ws_manager.subscribe_room(&room_id).await;
|
||||
let mut can_write = false;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
maybe_msg = socket.recv() => {
|
||||
match maybe_msg {
|
||||
Some(Ok(msg)) => {
|
||||
if matches!(msg, Message::Close(_)) {
|
||||
break;
|
||||
}
|
||||
match msg {
|
||||
Message::Text(text) => {
|
||||
match serde_json::from_str::<WsClientMessage>(&text) {
|
||||
Ok(WsClientMessage::AuthWrite(payload)) => {
|
||||
can_write = state.config.verify_write_key(&payload.key);
|
||||
if !can_write {
|
||||
tracing::warn!("WebSocket write auth failed in room {}", room_id);
|
||||
}
|
||||
}
|
||||
Ok(WsClientMessage::PointSetValueBatch(payload)) => {
|
||||
let response = if !can_write {
|
||||
crate::connection::BatchSetPointValueRes {
|
||||
success: false,
|
||||
err_msg: Some("write permission denied".to_string()),
|
||||
success_count: 0,
|
||||
failed_count: 0,
|
||||
results: vec![],
|
||||
}
|
||||
} else {
|
||||
match state.platform.connection_manager.write_point_values_batch(payload).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => crate::connection::BatchSetPointValueRes {
|
||||
success: false,
|
||||
err_msg: Some(e),
|
||||
success_count: 0,
|
||||
failed_count: 1,
|
||||
results: vec![crate::connection::SetPointValueResItem {
|
||||
point_id: Uuid::nil(),
|
||||
success: false,
|
||||
err_msg: Some("Internal write error".to_string()),
|
||||
}],
|
||||
},
|
||||
}
|
||||
};
|
||||
if let Err(e) = ws_manager
|
||||
.send_to_room(&room_id, WsMessage::PointSetValueBatchResult(response))
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to send PointSetValueBatchResult to room {}: {}",
|
||||
room_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Invalid websocket message in room {}: {}",
|
||||
room_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("Received WebSocket message from room {}: {:?}", room_id, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
tracing::error!("WebSocket error in room {}: {}", room_id, e);
|
||||
break;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
room_message = rx.recv() => {
|
||||
match room_message {
|
||||
Ok(message) => match serde_json::to_string(&message) {
|
||||
Ok(json_str) => {
|
||||
if socket.send(Message::Text(json_str.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to serialize websocket message: {}", e);
|
||||
}
|
||||
},
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
tracing::warn!("WebSocket room {} lagged, skipped {} messages", room_id, skipped);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ws_manager.remove_room_if_empty(&room_id).await;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use plc_platform_core::control::runtime::{ControlRuntimeStore, UnitRuntimeState};
|
||||
use app_feeder_distributor::control::runtime::{ControlRuntimeStore, UnitRuntimeState};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -10,7 +10,13 @@ axum = { version = "0.8", features = ["ws"] }
|
|||
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
||||
tracing = "0.1"
|
||||
dotenv = "0.15"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = "0.4"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid", "json"] }
|
||||
uuid = { version = "1.21", features = ["serde", "v4"] }
|
||||
validator = { version = "0.20", features = ["derive"] }
|
||||
anyhow = "1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
|
|
|||
|
|
@ -1,24 +1,30 @@
|
|||
use crate::router::build_router;
|
||||
use plc_platform_core::platform_context::PlatformContext;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use plc_platform_core::{bootstrap, platform_context::PlatformContext};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::{
|
||||
control::{resource::ResourceRegistry, runtime::SegmentRuntimeStore},
|
||||
event::EventManager,
|
||||
router::build_router,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppConfig {
|
||||
pub database_url: String,
|
||||
pub server_host: String,
|
||||
pub server_port: u16,
|
||||
pub server: plc_platform_core::config::ServerConfig,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
database_url: std::env::var("DATABASE_URL")
|
||||
.expect("DATABASE_URL must be set"),
|
||||
server_host: std::env::var("OPS_SERVER_HOST")
|
||||
.unwrap_or_else(|_| "127.0.0.1".to_string()),
|
||||
server_port: std::env::var("OPS_SERVER_PORT")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(3100),
|
||||
server: plc_platform_core::config::ServerConfig::from_env(
|
||||
"OPS_SERVER_HOST",
|
||||
"127.0.0.1",
|
||||
"OPS_SERVER_PORT",
|
||||
3100,
|
||||
)
|
||||
.expect("Failed to load operation-system server configuration"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,45 +34,84 @@ pub struct AppState {
|
|||
pub app_name: &'static str,
|
||||
pub config: AppConfig,
|
||||
pub platform: PlatformContext,
|
||||
pub event_manager: Arc<EventManager>,
|
||||
pub segment_runtime: Arc<SegmentRuntimeStore>,
|
||||
pub resource_registry: Arc<ResourceRegistry>,
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for PlatformContext {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.platform.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run() {
|
||||
dotenv::dotenv().ok();
|
||||
plc_platform_core::util::log::init_logger();
|
||||
let _single_instance =
|
||||
match plc_platform_core::util::single_instance::try_acquire("PLCControl.OperationSystem") {
|
||||
Ok(guard) => guard,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
tracing::warn!("Another operation-system instance is already running");
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to initialize single instance guard: {}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(_single_instance) = bootstrap::init_process(
|
||||
"PLCControl.OperationSystem",
|
||||
"Another operation-system instance is already running",
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
let builder = plc_platform_core::bootstrap::bootstrap_platform(&config.database_url)
|
||||
let builder = bootstrap::bootstrap_platform(&config.server.database_url)
|
||||
.await
|
||||
.expect("Failed to bootstrap platform");
|
||||
let platform = builder.build();
|
||||
|
||||
let event_manager = Arc::new(EventManager::new(
|
||||
platform.pool.clone(),
|
||||
Some(platform.ws_manager.clone()),
|
||||
platform.metadata.clone(),
|
||||
));
|
||||
let segment_runtime = Arc::new(SegmentRuntimeStore::new());
|
||||
let resource_registry = Arc::new(ResourceRegistry::new());
|
||||
|
||||
bootstrap::connect_all_enabled_sources(&platform)
|
||||
.await
|
||||
.expect("Failed to connect enabled sources");
|
||||
|
||||
if crate::seed::enabled_via_env() {
|
||||
match crate::seed::ensure_default_templates(&platform.pool).await {
|
||||
Ok(report) => tracing::info!(
|
||||
"Seeded default templates (stations={}, segments={}, steps={}, resources={})",
|
||||
report.stations_inserted,
|
||||
report.segments_inserted,
|
||||
report.steps_inserted,
|
||||
report.resources_inserted
|
||||
),
|
||||
Err(err) => tracing::error!("Seed default templates failed: {}", err),
|
||||
}
|
||||
}
|
||||
|
||||
let state = AppState {
|
||||
app_name: "operation-system",
|
||||
config,
|
||||
config: config.clone(),
|
||||
platform,
|
||||
event_manager,
|
||||
segment_runtime: segment_runtime.clone(),
|
||||
resource_registry,
|
||||
};
|
||||
let app = build_router(state.clone());
|
||||
let addr = format!("{}:{}", state.config.server_host, state.config.server_port);
|
||||
tracing::info!("Starting operation-system server at http://{}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.expect("operation-system listener should bind");
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("operation-system server should run");
|
||||
crate::control::engine::start(state.clone(), segment_runtime);
|
||||
|
||||
let app = build_router(state.clone());
|
||||
let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(1);
|
||||
let connection_manager_for_shutdown = state.platform.connection_manager.clone();
|
||||
bootstrap::install_ctrl_c_shutdown(shutdown_tx);
|
||||
|
||||
bootstrap::serve_app_with_graceful_shutdown(
|
||||
&state.config.server,
|
||||
"operation-system",
|
||||
app,
|
||||
bootstrap::disconnect_all_on_shutdown(
|
||||
shutdown_rx,
|
||||
connection_manager_for_shutdown,
|
||||
"operation-system",
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("operation-system server should run");
|
||||
}
|
||||
|
||||
pub fn test_state() -> AppState {
|
||||
|
|
@ -74,16 +119,28 @@ pub fn test_state() -> AppState {
|
|||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.connect_lazy(&database_url)
|
||||
.expect("lazy pool should build");
|
||||
let connection_manager = std::sync::Arc::new(plc_platform_core::connection::ConnectionManager::new());
|
||||
let connection_manager =
|
||||
std::sync::Arc::new(plc_platform_core::connection::ConnectionManager::new());
|
||||
let ws_manager = std::sync::Arc::new(plc_platform_core::websocket::WebSocketManager::new());
|
||||
let platform = PlatformContext::new(pool.clone(), connection_manager, ws_manager.clone());
|
||||
let event_manager = Arc::new(EventManager::new(
|
||||
pool,
|
||||
Some(ws_manager),
|
||||
platform.metadata.clone(),
|
||||
));
|
||||
|
||||
AppState {
|
||||
app_name: "operation-system",
|
||||
config: AppConfig {
|
||||
database_url,
|
||||
server_host: "127.0.0.1".to_string(),
|
||||
server_port: 0,
|
||||
server: plc_platform_core::config::ServerConfig {
|
||||
database_url,
|
||||
server_host: "127.0.0.1".to_string(),
|
||||
server_port: 0,
|
||||
},
|
||||
},
|
||||
platform: PlatformContext::new(pool, connection_manager, ws_manager),
|
||||
platform,
|
||||
event_manager,
|
||||
segment_runtime: Arc::new(SegmentRuntimeStore::new()),
|
||||
resource_registry: Arc::new(ResourceRegistry::new()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,918 @@
|
|||
//! Segment supervisor + per-segment task (design doc §5.1–§5.3).
|
||||
//!
|
||||
//! Supervisor scans enabled segments every 10 s and ensures each has a running
|
||||
//! task (mirrors the `app_feeder_distributor` supervisor). Each per-segment
|
||||
//! task drives the 9-state machine in §5.2 by re-reading config + interlocks
|
||||
//! every iteration and reacting to runtime change notifications.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use plc_platform_core::telemetry::PointMonitorInfo;
|
||||
use plc_platform_core::websocket::{AppWsEvent, WsMessage};
|
||||
use tokio::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
control::{
|
||||
interlock::{self, InterlockContext},
|
||||
runtime::{SegmentRuntime, SegmentRuntimeStore},
|
||||
simulate,
|
||||
state::SegmentState,
|
||||
step_executor::{self, CommandPointIndex, DispatchInputs, DispatchOutcome},
|
||||
},
|
||||
event::AppEvent,
|
||||
model::{ProcessSegment, SegmentInterlock, SegmentResource, SegmentStep},
|
||||
service::{segment as segment_service, station as station_service},
|
||||
AppState,
|
||||
};
|
||||
|
||||
const APP_NAME: &str = "operation-system";
|
||||
const SUPERVISOR_INTERVAL_SECS: u64 = 10;
|
||||
const FAULT_TICK_MS: u64 = 500;
|
||||
/// Resource leases older than this with no heartbeat are reclaimed by the
|
||||
/// supervisor. Three supervisor ticks is enough headroom for a slow segment
|
||||
/// task to refresh, but short enough to recover quickly from panics.
|
||||
const RESOURCE_LEASE_MAX_AGE_SECS: i64 = 30;
|
||||
|
||||
/// Start the engine supervisor. Mirrors the feeder entry point.
|
||||
pub fn start(state: AppState, store: Arc<SegmentRuntimeStore>) {
|
||||
tokio::spawn(async move {
|
||||
supervise(state, store).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn supervise(state: AppState, store: Arc<SegmentRuntimeStore>) {
|
||||
let mut tasks: HashMap<Uuid, tokio::task::JoinHandle<()>> = HashMap::new();
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(SUPERVISOR_INTERVAL_SECS));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
tracing::info!("Operation-system engine supervisor started");
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match segment_service::list_segments(&state.platform.pool, None).await {
|
||||
Ok(segments) => {
|
||||
for segment in segments
|
||||
.into_iter()
|
||||
.filter(|s| s.enabled && s.mode != "disabled")
|
||||
{
|
||||
let needs_spawn = tasks
|
||||
.get(&segment.id)
|
||||
.is_none_or(|handle| handle.is_finished());
|
||||
if needs_spawn {
|
||||
let task_state = state.clone();
|
||||
let task_store = store.clone();
|
||||
let segment_id = segment.id;
|
||||
let handle = tokio::spawn(async move {
|
||||
segment_task(task_state, task_store, segment_id).await;
|
||||
});
|
||||
tasks.insert(segment.id, handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => tracing::error!("Engine supervisor: list_segments failed: {}", err),
|
||||
}
|
||||
|
||||
// Reclaim stale resource leases (design doc §7 recovery path).
|
||||
let reclaimed = state
|
||||
.resource_registry
|
||||
.sweep_stale(chrono::Duration::seconds(RESOURCE_LEASE_MAX_AGE_SECS))
|
||||
.await;
|
||||
for (key, owner) in reclaimed {
|
||||
tracing::warn!(
|
||||
"Engine: reclaimed stale resource '{}' previously held by segment {}",
|
||||
key,
|
||||
owner
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn segment_task(state: AppState, store: Arc<SegmentRuntimeStore>, segment_id: Uuid) {
|
||||
let notify = store.get_or_create_notify(segment_id).await;
|
||||
let mut fault_tick = tokio::time::interval(Duration::from_millis(FAULT_TICK_MS));
|
||||
fault_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let interlocks =
|
||||
match segment_service::list_interlocks(&state.platform.pool, segment_id).await {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Engine: segment {} interlocks reload failed: {}",
|
||||
segment_id,
|
||||
err
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let resources =
|
||||
match segment_service::list_resources(&state.platform.pool, segment_id).await {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Engine: segment {} resources reload failed: {}",
|
||||
segment_id,
|
||||
err
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let cmd_index = match CommandPointIndex::for_steps(&state.platform.pool, &steps).await {
|
||||
Ok(idx) => idx,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Engine: segment {} command-point 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
|
||||
.platform
|
||||
.connection_manager
|
||||
.get_point_monitor_data_read_guard()
|
||||
.await;
|
||||
let monitor: HashMap<Uuid, PointMonitorInfo> = monitor_guard.clone();
|
||||
drop(monitor_guard);
|
||||
|
||||
// 4. Apply one state-machine step.
|
||||
let runtime = store.get_or_init(segment_id).await;
|
||||
let next_runtime = tick(
|
||||
&state,
|
||||
&segment,
|
||||
&steps,
|
||||
&interlocks,
|
||||
&resources,
|
||||
&ctx,
|
||||
&cmd_index,
|
||||
&monitor,
|
||||
runtime,
|
||||
)
|
||||
.await;
|
||||
let runtime_changed = match next_runtime {
|
||||
Some(updated) => {
|
||||
store.upsert(updated.clone()).await;
|
||||
push_runtime_change(&state, &updated).await;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
|
||||
// Refresh heartbeat on every tick we hold resources. Keeps the
|
||||
// supervisor sweep from reclaiming a live but slow segment.
|
||||
let snapshot_for_heartbeat = store.get_or_init(segment_id).await;
|
||||
for key in &snapshot_for_heartbeat.held_resources {
|
||||
state.resource_registry.heartbeat(key, segment_id).await;
|
||||
}
|
||||
|
||||
// 5. Decide how long to sleep based on next state.
|
||||
let snapshot = store.get_or_init(segment_id).await;
|
||||
if !runtime_changed && should_wait(&snapshot, segment.mode.as_str()) {
|
||||
tokio::select! {
|
||||
_ = fault_tick.tick() => {}
|
||||
_ = notify.notified() => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn tick(
|
||||
state: &AppState,
|
||||
segment: &ProcessSegment,
|
||||
steps: &[SegmentStep],
|
||||
interlocks: &[SegmentInterlock],
|
||||
resources: &[SegmentResource],
|
||||
ctx: &InterlockContext,
|
||||
cmd_index: &CommandPointIndex,
|
||||
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,
|
||||
SegmentState::Executing | SegmentState::Confirming | SegmentState::Resetting
|
||||
) {
|
||||
// Signal-conflict detection runs first: an impossible station state
|
||||
// means a sensor or wiring fault, which the engine should not
|
||||
// continue past regardless of how interlocks evaluate.
|
||||
let stations_referenced = collect_referenced_stations(steps, interlocks);
|
||||
if let Err((station_id, message)) =
|
||||
interlock::check_station_signal_conflicts(&stations_referenced, ctx, monitor)
|
||||
{
|
||||
let _ = state.event_manager.send(AppEvent::AlarmSignalConflict {
|
||||
segment_id: segment.id,
|
||||
message: message.clone(),
|
||||
});
|
||||
let _ = state.event_manager.send(AppEvent::SegmentFaultLocked {
|
||||
segment_id: segment.id,
|
||||
message: message.clone(),
|
||||
});
|
||||
tracing::warn!(
|
||||
"Engine: segment {} signal conflict on station {}: {}",
|
||||
segment.id,
|
||||
station_id,
|
||||
message
|
||||
);
|
||||
runtime.state = SegmentState::Faulted;
|
||||
runtime.fault_message = Some(message);
|
||||
return Some(runtime);
|
||||
}
|
||||
|
||||
let run_halt: Vec<&SegmentInterlock> = interlocks
|
||||
.iter()
|
||||
.filter(|i| i.applies_to == "run_halt")
|
||||
.collect();
|
||||
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 {
|
||||
segment_id: segment.id,
|
||||
message: reason.clone(),
|
||||
});
|
||||
runtime.state = SegmentState::Faulted;
|
||||
runtime.fault_message = Some(reason);
|
||||
return Some(runtime);
|
||||
}
|
||||
}
|
||||
|
||||
match runtime.state {
|
||||
SegmentState::Idle => {
|
||||
// Wait for auto activation or remote-manual notifications.
|
||||
if runtime.auto_enabled && segment.mode == "auto" {
|
||||
runtime.state = SegmentState::Checking;
|
||||
runtime.blocked_reason = None;
|
||||
return Some(runtime);
|
||||
}
|
||||
None
|
||||
}
|
||||
SegmentState::Checking => {
|
||||
// start_allow must all pass; start_deny rules being satisfied means we
|
||||
// should NOT start (per design doc §6.1, `start_deny` evaluates as a
|
||||
// "deny" condition — if its rule passes, start is denied).
|
||||
let start_allow: Vec<&SegmentInterlock> = interlocks
|
||||
.iter()
|
||||
.filter(|i| i.applies_to == "start_allow")
|
||||
.collect();
|
||||
if let Err(reason) = interlock::evaluate_all(&start_allow, ctx, monitor) {
|
||||
let _ = state.event_manager.send(AppEvent::SegmentBlocked {
|
||||
segment_id: segment.id,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
runtime.state = SegmentState::Blocked;
|
||||
runtime.blocked_reason = Some(reason);
|
||||
return Some(runtime);
|
||||
}
|
||||
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 _ = state.event_manager.send(AppEvent::SegmentBlocked {
|
||||
segment_id: segment.id,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
runtime.state = SegmentState::Blocked;
|
||||
runtime.blocked_reason = Some(reason);
|
||||
return Some(runtime);
|
||||
}
|
||||
}
|
||||
// Acquire declared resources.
|
||||
let mut acquired: Vec<String> = Vec::new();
|
||||
for res in resources {
|
||||
let ok = state
|
||||
.resource_registry
|
||||
.try_acquire(&res.resource_key, segment.id)
|
||||
.await;
|
||||
if !ok {
|
||||
for key in &acquired {
|
||||
state.resource_registry.release(key, segment.id).await;
|
||||
}
|
||||
let _ = state.event_manager.send(AppEvent::AlarmResourceBusy {
|
||||
segment_id: segment.id,
|
||||
resource_key: res.resource_key.clone(),
|
||||
});
|
||||
runtime.state = SegmentState::Blocked;
|
||||
runtime.blocked_reason = Some(format!("resource_busy: {}", res.resource_key));
|
||||
return Some(runtime);
|
||||
}
|
||||
acquired.push(res.resource_key.clone());
|
||||
}
|
||||
|
||||
let Some(first_step) = steps.iter().min_by_key(|s| s.step_no) else {
|
||||
runtime.state = SegmentState::Faulted;
|
||||
runtime.fault_message = Some("segment has no steps".to_string());
|
||||
return Some(runtime);
|
||||
};
|
||||
|
||||
runtime.held_resources = acquired;
|
||||
runtime.current_step_no = Some(first_step.step_no);
|
||||
runtime.step_started_at = Some(Utc::now());
|
||||
runtime.blocked_reason = None;
|
||||
runtime.state = SegmentState::Executing;
|
||||
Some(runtime)
|
||||
}
|
||||
SegmentState::Executing => {
|
||||
let Some(step_no) = runtime.current_step_no else {
|
||||
runtime.state = SegmentState::Faulted;
|
||||
runtime.fault_message = Some("Executing without current_step_no".to_string());
|
||||
return Some(runtime);
|
||||
};
|
||||
let Some(step) = steps.iter().find(|s| s.step_no == step_no) else {
|
||||
runtime.state = SegmentState::Faulted;
|
||||
runtime.fault_message = Some(format!("step {} not found", step_no));
|
||||
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(
|
||||
step,
|
||||
&state.platform.connection_manager,
|
||||
cmd_index,
|
||||
monitor,
|
||||
&inputs,
|
||||
)
|
||||
.await;
|
||||
match outcome {
|
||||
DispatchOutcome::Issued => {
|
||||
runtime.state = SegmentState::Confirming;
|
||||
runtime.step_started_at = Some(Utc::now());
|
||||
let _ = state.event_manager.send(AppEvent::SegmentStepAdvanced {
|
||||
segment_id: segment.id,
|
||||
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 Ok(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)
|
||||
}
|
||||
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 {
|
||||
segment_id: segment.id,
|
||||
message: msg.clone(),
|
||||
});
|
||||
runtime.state = SegmentState::Faulted;
|
||||
runtime.fault_message = Some(msg);
|
||||
Some(runtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
SegmentState::Confirming => {
|
||||
let Some(step_no) = runtime.current_step_no else {
|
||||
runtime.state = SegmentState::Faulted;
|
||||
runtime.fault_message = Some("Confirming without current_step_no".to_string());
|
||||
return Some(runtime);
|
||||
};
|
||||
let Some(step) = steps.iter().find(|s| s.step_no == step_no) else {
|
||||
runtime.state = SegmentState::Faulted;
|
||||
runtime.fault_message = Some(format!("step {} not found", step_no));
|
||||
return Some(runtime);
|
||||
};
|
||||
|
||||
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 => {
|
||||
// No confirm signal configured — treat the step as instantly done.
|
||||
Some(true)
|
||||
}
|
||||
};
|
||||
|
||||
if confirmed == Some(true) {
|
||||
if step.hold_until_confirm {
|
||||
if let Err(err) = step_executor::send_stop_command(
|
||||
step,
|
||||
&state.platform.connection_manager,
|
||||
cmd_index,
|
||||
monitor,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Engine: segment {} stop command for step {} failed: {}",
|
||||
segment.id,
|
||||
step_no,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
let next_step = step
|
||||
.next_step_no_on_success
|
||||
.or_else(|| next_sequential(steps, step_no));
|
||||
match next_step {
|
||||
Some(next_no) => {
|
||||
runtime.state = SegmentState::Executing;
|
||||
runtime.current_step_no = Some(next_no);
|
||||
runtime.step_started_at = Some(Utc::now());
|
||||
}
|
||||
None => {
|
||||
runtime.state = SegmentState::Completed;
|
||||
}
|
||||
}
|
||||
return Some(runtime);
|
||||
}
|
||||
|
||||
// Not yet confirmed: check timeout.
|
||||
if let Some(started) = runtime.step_started_at {
|
||||
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,
|
||||
step_no,
|
||||
});
|
||||
match step.on_timeout.as_str() {
|
||||
"retry" => {
|
||||
runtime.state = SegmentState::Executing;
|
||||
runtime.step_started_at = Some(Utc::now());
|
||||
}
|
||||
"block" => {
|
||||
runtime.state = SegmentState::Blocked;
|
||||
runtime.blocked_reason = Some(format!("step {} timeout", step_no));
|
||||
}
|
||||
_ => {
|
||||
// "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.fault_message = Some(format!("step {} timeout", step_no));
|
||||
}
|
||||
}
|
||||
return Some(runtime);
|
||||
}
|
||||
}
|
||||
// Still waiting — no state change.
|
||||
None
|
||||
}
|
||||
SegmentState::Resetting => {
|
||||
// First-pass reset is a no-op; configurations that need a reset step
|
||||
// should encode it as a normal step. Drop back to Idle.
|
||||
runtime.state = SegmentState::Idle;
|
||||
Some(runtime)
|
||||
}
|
||||
SegmentState::Completed => {
|
||||
state.resource_registry.release_all_for(segment.id).await;
|
||||
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,
|
||||
});
|
||||
runtime.state = SegmentState::Idle;
|
||||
Some(runtime)
|
||||
}
|
||||
SegmentState::Blocked => {
|
||||
// Periodically re-check whether the block has cleared.
|
||||
if runtime.auto_enabled && segment.mode == "auto" {
|
||||
let start_allow: Vec<&SegmentInterlock> = interlocks
|
||||
.iter()
|
||||
.filter(|i| i.applies_to == "start_allow")
|
||||
.collect();
|
||||
let any_deny = interlocks
|
||||
.iter()
|
||||
.filter(|i| i.applies_to == "start_deny")
|
||||
.any(|rule| interlock::evaluate(rule, ctx, monitor).is_ok());
|
||||
if interlock::evaluate_all(&start_allow, ctx, monitor).is_ok() && !any_deny {
|
||||
runtime.state = SegmentState::Checking;
|
||||
runtime.blocked_reason = None;
|
||||
return Some(runtime);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
SegmentState::Faulted => {
|
||||
// Release any held resources on fault entry; first-pass keeps it simple.
|
||||
state.resource_registry.release_all_for(segment.id).await;
|
||||
runtime.held_resources.clear();
|
||||
if segment.require_manual_ack_after_fault {
|
||||
runtime.manual_ack_required = true;
|
||||
runtime.state = SegmentState::ManualAckRequired;
|
||||
return Some(runtime);
|
||||
}
|
||||
// Otherwise we leave it Faulted; ack-fault API may flip it back to Idle.
|
||||
None
|
||||
}
|
||||
SegmentState::ManualAckRequired => {
|
||||
// ack-fault API will flip manual_ack_required=false + state=Idle and notify.
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect distinct station ids touched by either a step's `target_station_id`
|
||||
/// or an interlock's `station_id`. Used for cross-cutting station health checks.
|
||||
fn collect_referenced_stations(
|
||||
steps: &[SegmentStep],
|
||||
interlocks: &[SegmentInterlock],
|
||||
) -> Vec<Uuid> {
|
||||
let mut ids: Vec<Uuid> = steps.iter().filter_map(|s| s.target_station_id).collect();
|
||||
ids.extend(interlocks.iter().filter_map(|i| i.station_id));
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
|
||||
fn next_sequential(steps: &[SegmentStep], current: i32) -> Option<i32> {
|
||||
steps
|
||||
.iter()
|
||||
.filter(|s| s.step_no > current)
|
||||
.map(|s| s.step_no)
|
||||
.min()
|
||||
}
|
||||
|
||||
/// 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,
|
||||
) -> Result<Option<(Uuid, bool, bool)>, String> {
|
||||
if let Some(point_id) = step.confirm_point_id {
|
||||
return Ok(Some((point_id, false, step.expected_value)));
|
||||
}
|
||||
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()
|
||||
.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(
|
||||
monitor: &HashMap<Uuid, PointMonitorInfo>,
|
||||
point_id: Uuid,
|
||||
invert: bool,
|
||||
expected: bool,
|
||||
) -> Option<bool> {
|
||||
let m = monitor.get(&point_id)?;
|
||||
if m.quality != plc_platform_core::telemetry::PointQuality::Good {
|
||||
return None;
|
||||
}
|
||||
let raw = super::monitor_value_as_bool(m);
|
||||
let logical = raw ^ invert;
|
||||
Some(logical == expected)
|
||||
}
|
||||
|
||||
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,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn push_runtime_change(state: &AppState, runtime: &SegmentRuntime) {
|
||||
let payload = match serde_json::to_value(runtime) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
tracing::warn!("Engine: serialize SegmentRuntime failed: {}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let message = WsMessage::AppEvent(AppWsEvent {
|
||||
app: APP_NAME.to_string(),
|
||||
event_type: "segment_runtime_changed".to_string(),
|
||||
data: payload,
|
||||
});
|
||||
if let Err(err) = state.platform.ws_manager.send_to_public(message).await {
|
||||
tracing::debug!("Engine: WS push skipped: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
#[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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,489 @@
|
|||
//! Interlock evaluator (design doc §6).
|
||||
//!
|
||||
//! Evaluates a single `segment_interlock` row against the current point monitor
|
||||
//! snapshot. Returns `Ok(())` when the rule passes, `Err(reason)` when it fails.
|
||||
//!
|
||||
//! The first-pass rule set is fixed (no expression engine). New rule kinds are
|
||||
//! added by extending the `rule_kind` match.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
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};
|
||||
|
||||
/// Pre-loaded lookup maps so the engine evaluates interlocks without per-rule DB hits.
|
||||
pub struct InterlockContext {
|
||||
/// equipment_id → (role → point_id)
|
||||
pub equipment_role_points: HashMap<Uuid, HashMap<String, Uuid>>,
|
||||
/// station_id → (role → point_id, invert)
|
||||
pub station_role_points: HashMap<Uuid, HashMap<String, (Uuid, bool)>>,
|
||||
}
|
||||
|
||||
impl InterlockContext {
|
||||
pub async fn load_for_interlocks(
|
||||
pool: &PgPool,
|
||||
interlocks: &[SegmentInterlock],
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
let equipment_ids: Vec<Uuid> = interlocks.iter().filter_map(|i| i.equipment_id).collect();
|
||||
let station_ids: Vec<Uuid> = interlocks.iter().filter_map(|i| i.station_id).collect();
|
||||
|
||||
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],
|
||||
station_ids: &[Uuid],
|
||||
) -> 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?;
|
||||
for row in rows {
|
||||
equipment_role_points
|
||||
.entry(row.equipment_id)
|
||||
.or_default()
|
||||
.insert(row.signal_role, row.point_id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut station_role_points: HashMap<Uuid, HashMap<String, (Uuid, bool)>> = HashMap::new();
|
||||
if !station_ids.is_empty() {
|
||||
let signals = sqlx::query_as::<_, StationSignal>(
|
||||
r#"SELECT * FROM station_signal WHERE station_id = ANY($1)"#,
|
||||
)
|
||||
.bind(station_ids)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
for sig in signals {
|
||||
if let Some(point_id) = sig.point_id {
|
||||
station_role_points
|
||||
.entry(sig.station_id)
|
||||
.or_default()
|
||||
.insert(sig.signal_role, (point_id, sig.invert_value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
equipment_role_points,
|
||||
station_role_points,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the point id for a station's role, honoring `derived_from_role`.
|
||||
///
|
||||
/// Returns the resolved `(point_id, invert_value)`. The caller XORs `invert_value`
|
||||
/// with the raw bool to obtain the logical signal.
|
||||
fn resolve_station_point(
|
||||
ctx: &InterlockContext,
|
||||
station_id: Uuid,
|
||||
role: &str,
|
||||
) -> Option<(Uuid, bool)> {
|
||||
ctx.station_role_points
|
||||
.get(&station_id)
|
||||
.and_then(|m| m.get(role))
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Read a (point_id, invert) → logical bool, requiring Good quality.
|
||||
/// Returns `None` if the point is missing from the monitor map, has bad quality,
|
||||
/// or has no value yet.
|
||||
fn read_logical_bool(
|
||||
monitor: &HashMap<Uuid, PointMonitorInfo>,
|
||||
point_id: Uuid,
|
||||
invert: bool,
|
||||
) -> Option<bool> {
|
||||
let m = monitor.get(&point_id)?;
|
||||
if !monitor_quality_good(m) {
|
||||
return None;
|
||||
}
|
||||
let raw = monitor_value_as_bool(m);
|
||||
Some(raw ^ invert)
|
||||
}
|
||||
|
||||
/// Evaluate one interlock rule. Returns Ok when the rule is satisfied,
|
||||
/// Err with a human-readable reason when it is not.
|
||||
pub fn evaluate(
|
||||
rule: &SegmentInterlock,
|
||||
ctx: &InterlockContext,
|
||||
monitor: &HashMap<Uuid, PointMonitorInfo>,
|
||||
) -> Result<(), String> {
|
||||
match rule.rule_kind.as_str() {
|
||||
"point_eq" => {
|
||||
let point_id = rule
|
||||
.point_id
|
||||
.ok_or_else(|| format!("point_eq rule {} missing point_id", rule.id))?;
|
||||
let expected = rule.expected_value.unwrap_or(true);
|
||||
let actual = read_logical_bool(monitor, point_id, false)
|
||||
.ok_or_else(|| format!("point {} unavailable or bad quality", point_id))?;
|
||||
if actual == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"point {} expected {} got {}",
|
||||
point_id, expected, actual
|
||||
))
|
||||
}
|
||||
}
|
||||
"station_vacant" => {
|
||||
let station_id = rule
|
||||
.station_id
|
||||
.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)
|
||||
})?;
|
||||
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)
|
||||
})?;
|
||||
if !v {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("station {} occupied (presence=true)", station_id))
|
||||
}
|
||||
} else {
|
||||
Err(format!(
|
||||
"station {} has no presence/vacancy binding",
|
||||
station_id
|
||||
))
|
||||
}
|
||||
}
|
||||
"station_occupied" => {
|
||||
let station_id = rule
|
||||
.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)
|
||||
})?;
|
||||
if v {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("station {} empty (presence=false)", station_id))
|
||||
}
|
||||
} else {
|
||||
Err(format!("station {} has no presence binding", station_id))
|
||||
}
|
||||
}
|
||||
"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")
|
||||
}
|
||||
"equipment_remote" => {
|
||||
check_equipment_role(rule, ctx, monitor, "rem", true, "not in remote mode")
|
||||
}
|
||||
"safety_chain_ok" => {
|
||||
// First-pass: a safety_chain_ok rule must bind to a point that is true.
|
||||
let point_id = rule
|
||||
.point_id
|
||||
.ok_or_else(|| format!("safety_chain_ok rule {} missing point_id", rule.id))?;
|
||||
let actual = read_logical_bool(monitor, point_id, false)
|
||||
.ok_or_else(|| format!("safety chain point {} unavailable", point_id))?;
|
||||
if actual {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("safety chain point {} broken", point_id))
|
||||
}
|
||||
}
|
||||
other => Err(format!("unknown rule_kind {}", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_equipment_role(
|
||||
rule: &SegmentInterlock,
|
||||
ctx: &InterlockContext,
|
||||
monitor: &HashMap<Uuid, PointMonitorInfo>,
|
||||
role: &str,
|
||||
expected: bool,
|
||||
fail_reason: &str,
|
||||
) -> Result<(), String> {
|
||||
let equipment_id = rule
|
||||
.equipment_id
|
||||
.ok_or_else(|| format!("{} rule {} missing equipment_id", rule.rule_kind, rule.id))?;
|
||||
let point_id = ctx
|
||||
.equipment_role_points
|
||||
.get(&equipment_id)
|
||||
.and_then(|m| m.get(role))
|
||||
.copied()
|
||||
.ok_or_else(|| format!("equipment {} has no {} role binding", equipment_id, role))?;
|
||||
let actual = read_logical_bool(monitor, point_id, false).ok_or_else(|| {
|
||||
format!(
|
||||
"equipment {} role {} point {} unavailable",
|
||||
equipment_id, role, point_id
|
||||
)
|
||||
})?;
|
||||
if actual == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("equipment {} {}", equipment_id, fail_reason))
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate the supplied interlock set; returns the first failure.
|
||||
pub fn evaluate_all(
|
||||
rules: &[&SegmentInterlock],
|
||||
ctx: &InterlockContext,
|
||||
monitor: &HashMap<Uuid, PointMonitorInfo>,
|
||||
) -> Result<(), String> {
|
||||
for rule in rules {
|
||||
evaluate(rule, ctx, monitor)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check each station for impossible signal states (design doc §8.1
|
||||
/// `ops.alarm.signal_conflict`). Currently flags any station whose `presence`
|
||||
/// and `vacancy` are both logically true at the same time with Good quality.
|
||||
///
|
||||
/// Returns `Ok(())` when all stations are consistent. Returns
|
||||
/// `Err((station_id, reason))` for the first station with a conflict.
|
||||
pub fn check_station_signal_conflicts(
|
||||
station_ids: &[Uuid],
|
||||
ctx: &InterlockContext,
|
||||
monitor: &HashMap<Uuid, PointMonitorInfo>,
|
||||
) -> Result<(), (Uuid, String)> {
|
||||
for station_id in station_ids {
|
||||
let Some(roles) = ctx.station_role_points.get(station_id) else {
|
||||
continue;
|
||||
};
|
||||
let presence = roles
|
||||
.get("presence")
|
||||
.and_then(|(pid, invert)| read_logical_bool(monitor, *pid, *invert));
|
||||
let vacancy = roles
|
||||
.get("vacancy")
|
||||
.and_then(|(pid, invert)| read_logical_bool(monitor, *pid, *invert));
|
||||
match (presence, vacancy) {
|
||||
(Some(true), Some(true)) => {
|
||||
return Err((
|
||||
*station_id,
|
||||
format!(
|
||||
"station {} reports presence=true and vacancy=true simultaneously",
|
||||
station_id
|
||||
),
|
||||
));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use plc_platform_core::model::ScanMode;
|
||||
use plc_platform_core::telemetry::{DataValue, PointQuality, ValueType};
|
||||
|
||||
fn monitor_entry(point_id: Uuid, value: bool, good: bool) -> PointMonitorInfo {
|
||||
PointMonitorInfo {
|
||||
protocol: "test".to_string(),
|
||||
source_id: Uuid::nil(),
|
||||
point_id,
|
||||
client_handle: 0,
|
||||
scan_mode: ScanMode::Subscribe,
|
||||
timestamp: Some(Utc::now()),
|
||||
quality: if good {
|
||||
PointQuality::Good
|
||||
} else {
|
||||
PointQuality::Bad
|
||||
},
|
||||
value: Some(DataValue::Bool(value)),
|
||||
value_type: Some(ValueType::Bool),
|
||||
value_text: None,
|
||||
old_value: None,
|
||||
old_timestamp: None,
|
||||
value_changed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn dummy_interlock(rule_kind: &str) -> SegmentInterlock {
|
||||
SegmentInterlock {
|
||||
id: Uuid::new_v4(),
|
||||
segment_id: Uuid::new_v4(),
|
||||
applies_to: "start_allow".to_string(),
|
||||
rule_kind: rule_kind.to_string(),
|
||||
point_id: None,
|
||||
station_id: None,
|
||||
equipment_id: None,
|
||||
expected_value: None,
|
||||
description: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_eq_passes_when_value_matches_expected() {
|
||||
let pid = Uuid::new_v4();
|
||||
let mut rule = dummy_interlock("point_eq");
|
||||
rule.point_id = Some(pid);
|
||||
rule.expected_value = Some(true);
|
||||
|
||||
let mut monitor = HashMap::new();
|
||||
monitor.insert(pid, monitor_entry(pid, true, true));
|
||||
let ctx = InterlockContext {
|
||||
equipment_role_points: HashMap::new(),
|
||||
station_role_points: HashMap::new(),
|
||||
};
|
||||
|
||||
assert!(evaluate(&rule, &ctx, &monitor).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_eq_fails_when_quality_bad() {
|
||||
let pid = Uuid::new_v4();
|
||||
let mut rule = dummy_interlock("point_eq");
|
||||
rule.point_id = Some(pid);
|
||||
rule.expected_value = Some(true);
|
||||
|
||||
let mut monitor = HashMap::new();
|
||||
monitor.insert(pid, monitor_entry(pid, true, false));
|
||||
let ctx = InterlockContext {
|
||||
equipment_role_points: HashMap::new(),
|
||||
station_role_points: HashMap::new(),
|
||||
};
|
||||
|
||||
assert!(evaluate(&rule, &ctx, &monitor).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn station_vacant_uses_presence_when_no_vacancy_signal() {
|
||||
let station_id = Uuid::new_v4();
|
||||
let pid = Uuid::new_v4();
|
||||
let mut rule = dummy_interlock("station_vacant");
|
||||
rule.station_id = Some(station_id);
|
||||
|
||||
let mut monitor = HashMap::new();
|
||||
monitor.insert(pid, monitor_entry(pid, false, true));
|
||||
let mut station_role_points = HashMap::new();
|
||||
let mut roles = HashMap::new();
|
||||
roles.insert("presence".to_string(), (pid, false));
|
||||
station_role_points.insert(station_id, roles);
|
||||
let ctx = InterlockContext {
|
||||
equipment_role_points: HashMap::new(),
|
||||
station_role_points,
|
||||
};
|
||||
|
||||
assert!(evaluate(&rule, &ctx, &monitor).is_ok());
|
||||
|
||||
// Flip presence to true ⇒ vacant should fail.
|
||||
monitor.insert(pid, monitor_entry(pid, true, true));
|
||||
assert!(evaluate(&rule, &ctx, &monitor).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equipment_no_fault_fails_when_flt_true() {
|
||||
let eq_id = Uuid::new_v4();
|
||||
let pid = Uuid::new_v4();
|
||||
let mut rule = dummy_interlock("equipment_no_fault");
|
||||
rule.equipment_id = Some(eq_id);
|
||||
|
||||
let mut monitor = HashMap::new();
|
||||
monitor.insert(pid, monitor_entry(pid, true, true));
|
||||
let mut equipment_role_points = HashMap::new();
|
||||
let mut roles = HashMap::new();
|
||||
roles.insert("flt".to_string(), pid);
|
||||
equipment_role_points.insert(eq_id, roles);
|
||||
let ctx = InterlockContext {
|
||||
equipment_role_points,
|
||||
station_role_points: HashMap::new(),
|
||||
};
|
||||
|
||||
assert!(evaluate(&rule, &ctx, &monitor).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn station_signal_conflict_flags_simultaneous_presence_and_vacancy() {
|
||||
let station_id = Uuid::new_v4();
|
||||
let presence_pid = Uuid::new_v4();
|
||||
let vacancy_pid = Uuid::new_v4();
|
||||
|
||||
let mut roles = HashMap::new();
|
||||
roles.insert("presence".to_string(), (presence_pid, false));
|
||||
roles.insert("vacancy".to_string(), (vacancy_pid, false));
|
||||
let mut station_role_points = HashMap::new();
|
||||
station_role_points.insert(station_id, roles);
|
||||
let ctx = InterlockContext {
|
||||
equipment_role_points: HashMap::new(),
|
||||
station_role_points,
|
||||
};
|
||||
|
||||
let mut monitor = HashMap::new();
|
||||
monitor.insert(presence_pid, monitor_entry(presence_pid, true, true));
|
||||
monitor.insert(vacancy_pid, monitor_entry(vacancy_pid, true, true));
|
||||
|
||||
let err = check_station_signal_conflicts(&[station_id], &ctx, &monitor)
|
||||
.expect_err("conflict should surface");
|
||||
assert_eq!(err.0, station_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn station_signal_conflict_ignores_missing_signals() {
|
||||
let station_id = Uuid::new_v4();
|
||||
let ctx = InterlockContext {
|
||||
equipment_role_points: HashMap::new(),
|
||||
station_role_points: HashMap::new(),
|
||||
};
|
||||
assert!(check_station_signal_conflicts(&[station_id], &ctx, &HashMap::new()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_all_returns_first_failure() {
|
||||
let pid_ok = Uuid::new_v4();
|
||||
let pid_bad = Uuid::new_v4();
|
||||
let mut rule_a = dummy_interlock("point_eq");
|
||||
rule_a.point_id = Some(pid_ok);
|
||||
rule_a.expected_value = Some(true);
|
||||
let mut rule_b = dummy_interlock("point_eq");
|
||||
rule_b.point_id = Some(pid_bad);
|
||||
rule_b.expected_value = Some(true);
|
||||
|
||||
let mut monitor = HashMap::new();
|
||||
monitor.insert(pid_ok, monitor_entry(pid_ok, true, true));
|
||||
monitor.insert(pid_bad, monitor_entry(pid_bad, false, true));
|
||||
let ctx = InterlockContext {
|
||||
equipment_role_points: HashMap::new(),
|
||||
station_role_points: HashMap::new(),
|
||||
};
|
||||
|
||||
assert!(evaluate_all(&[&rule_a, &rule_b], &ctx, &monitor).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
pub use plc_platform_core::control::command;
|
||||
|
||||
pub mod engine;
|
||||
pub mod interlock;
|
||||
pub mod resource;
|
||||
pub mod runtime;
|
||||
pub mod simulate;
|
||||
pub mod state;
|
||||
pub mod step_executor;
|
||||
|
||||
use plc_platform_core::telemetry::{DataValue, PointMonitorInfo, PointQuality};
|
||||
|
||||
/// Interpret a monitored point value as a boolean signal.
|
||||
/// Mirrors `app_feeder_distributor::control::monitor_value_as_bool`.
|
||||
pub(crate) fn monitor_value_as_bool(monitor: &PointMonitorInfo) -> bool {
|
||||
match monitor.value.as_ref() {
|
||||
Some(DataValue::Bool(value)) => *value,
|
||||
Some(DataValue::Int(value)) => *value != 0,
|
||||
Some(DataValue::UInt(value)) => *value != 0,
|
||||
Some(DataValue::Float(value)) => *value != 0.0,
|
||||
Some(DataValue::Text(value)) => matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "on" | "yes"
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true iff the point is present in the monitor map and reports `Good` quality.
|
||||
pub(crate) fn monitor_quality_good(monitor: &PointMonitorInfo) -> bool {
|
||||
monitor.quality == PointQuality::Good
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Resource lease held by a segment task.
|
||||
///
|
||||
/// `acquired_at` and `heartbeat_at` exist to support the recovery strategy in
|
||||
/// design doc §7. Resources whose owner task has died (no heartbeat) can be
|
||||
/// reclaimed by the supervisor.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResourceLease {
|
||||
pub owner_segment_id: Uuid,
|
||||
pub acquired_at: DateTime<Utc>,
|
||||
pub heartbeat_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Named-lock registry for shared resources (transfer car, robot arm, unload
|
||||
/// position, return line, etc.).
|
||||
///
|
||||
/// Segment configuration declares which resources it needs via the
|
||||
/// `segment_resource` table. The engine acquires before `Executing` and
|
||||
/// releases on `Completed` (or safe `Faulted`).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ResourceRegistry {
|
||||
inner: Arc<RwLock<HashMap<String, ResourceLease>>>,
|
||||
}
|
||||
|
||||
impl ResourceRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Attempt to take the resource for the given segment.
|
||||
///
|
||||
/// Returns `true` if the lease was granted (either freshly or already held
|
||||
/// by the same segment). Returns `false` if another segment holds it.
|
||||
pub async fn try_acquire(&self, key: &str, segment_id: Uuid) -> bool {
|
||||
let mut inner = self.inner.write().await;
|
||||
match inner.get(key) {
|
||||
Some(lease) if lease.owner_segment_id != segment_id => false,
|
||||
_ => {
|
||||
let now = Utc::now();
|
||||
inner.insert(
|
||||
key.to_string(),
|
||||
ResourceLease {
|
||||
owner_segment_id: segment_id,
|
||||
acquired_at: now,
|
||||
heartbeat_at: now,
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the heartbeat for a resource the segment already holds.
|
||||
/// No-op if the resource is held by someone else or not held.
|
||||
pub async fn heartbeat(&self, key: &str, segment_id: Uuid) {
|
||||
let mut inner = self.inner.write().await;
|
||||
if let Some(lease) = inner.get_mut(key) {
|
||||
if lease.owner_segment_id == segment_id {
|
||||
lease.heartbeat_at = Utc::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release a resource held by the given segment.
|
||||
/// No-op if the resource is held by someone else or not held.
|
||||
pub async fn release(&self, key: &str, segment_id: Uuid) {
|
||||
let mut inner = self.inner.write().await;
|
||||
if let Some(lease) = inner.get(key) {
|
||||
if lease.owner_segment_id == segment_id {
|
||||
inner.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release every resource held by the given segment.
|
||||
/// Used when a segment task exits or transitions to `Completed`.
|
||||
pub async fn release_all_for(&self, segment_id: Uuid) {
|
||||
let mut inner = self.inner.write().await;
|
||||
inner.retain(|_, lease| lease.owner_segment_id != segment_id);
|
||||
}
|
||||
|
||||
pub async fn snapshot(&self) -> HashMap<String, ResourceLease> {
|
||||
self.inner.read().await.clone()
|
||||
}
|
||||
|
||||
/// Drop any lease whose heartbeat is older than `max_age`. Returns the keys
|
||||
/// that were reclaimed so the caller can log or alarm.
|
||||
///
|
||||
/// 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)> {
|
||||
let cutoff = Utc::now() - max_age;
|
||||
let mut reclaimed = Vec::new();
|
||||
let mut inner = self.inner.write().await;
|
||||
inner.retain(|key, lease| {
|
||||
if lease.heartbeat_at < cutoff {
|
||||
reclaimed.push((key.clone(), lease.owner_segment_id));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
reclaimed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn acquire_blocks_other_segment_until_released() {
|
||||
let registry = ResourceRegistry::new();
|
||||
let seg_a = Uuid::new_v4();
|
||||
let seg_b = Uuid::new_v4();
|
||||
|
||||
assert!(registry.try_acquire("transfer_front", seg_a).await);
|
||||
assert!(!registry.try_acquire("transfer_front", seg_b).await);
|
||||
// same owner can re-acquire (idempotent)
|
||||
assert!(registry.try_acquire("transfer_front", seg_a).await);
|
||||
|
||||
registry.release("transfer_front", seg_a).await;
|
||||
assert!(registry.try_acquire("transfer_front", seg_b).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn release_all_for_drops_only_owner_leases() {
|
||||
let registry = ResourceRegistry::new();
|
||||
let seg_a = Uuid::new_v4();
|
||||
let seg_b = Uuid::new_v4();
|
||||
|
||||
registry.try_acquire("r1", seg_a).await;
|
||||
registry.try_acquire("r2", seg_a).await;
|
||||
registry.try_acquire("r3", seg_b).await;
|
||||
|
||||
registry.release_all_for(seg_a).await;
|
||||
let snap = registry.snapshot().await;
|
||||
assert!(!snap.contains_key("r1"));
|
||||
assert!(!snap.contains_key("r2"));
|
||||
assert_eq!(snap.get("r3").unwrap().owner_segment_id, seg_b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heartbeat_refreshes_only_owner_lease() {
|
||||
let registry = ResourceRegistry::new();
|
||||
let seg_a = Uuid::new_v4();
|
||||
let seg_b = Uuid::new_v4();
|
||||
registry.try_acquire("r1", seg_a).await;
|
||||
|
||||
// Force the existing lease to look ancient.
|
||||
{
|
||||
let mut inner = registry.inner.write().await;
|
||||
if let Some(lease) = inner.get_mut("r1") {
|
||||
lease.heartbeat_at = Utc::now() - chrono::Duration::seconds(120);
|
||||
}
|
||||
}
|
||||
// Other-owner heartbeat is rejected.
|
||||
registry.heartbeat("r1", seg_b).await;
|
||||
let before = registry.snapshot().await.get("r1").unwrap().heartbeat_at;
|
||||
assert!(Utc::now() - before > chrono::Duration::seconds(60));
|
||||
|
||||
// Owner heartbeat updates timestamp.
|
||||
registry.heartbeat("r1", seg_a).await;
|
||||
let after = registry.snapshot().await.get("r1").unwrap().heartbeat_at;
|
||||
assert!(Utc::now() - after < chrono::Duration::seconds(10));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sweep_stale_reclaims_only_old_leases() {
|
||||
let registry = ResourceRegistry::new();
|
||||
let seg_a = Uuid::new_v4();
|
||||
let seg_b = Uuid::new_v4();
|
||||
registry.try_acquire("stale", seg_a).await;
|
||||
registry.try_acquire("fresh", seg_b).await;
|
||||
|
||||
// Age out the "stale" lease.
|
||||
{
|
||||
let mut inner = registry.inner.write().await;
|
||||
if let Some(lease) = inner.get_mut("stale") {
|
||||
lease.heartbeat_at = Utc::now() - chrono::Duration::seconds(60);
|
||||
}
|
||||
}
|
||||
|
||||
let reclaimed = registry.sweep_stale(chrono::Duration::seconds(30)).await;
|
||||
assert_eq!(reclaimed.len(), 1);
|
||||
assert_eq!(reclaimed[0].0, "stale");
|
||||
assert_eq!(reclaimed[0].1, seg_a);
|
||||
|
||||
let snap = registry.snapshot().await;
|
||||
assert!(!snap.contains_key("stale"));
|
||||
assert!(snap.contains_key("fresh"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{Notify, RwLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::state::SegmentState;
|
||||
|
||||
/// Per-segment runtime as defined in design doc §4.2.6.
|
||||
///
|
||||
/// Held in memory only; reset on restart.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SegmentRuntime {
|
||||
pub segment_id: Uuid,
|
||||
pub state: SegmentState,
|
||||
pub auto_enabled: bool,
|
||||
pub current_step_no: Option<i32>,
|
||||
pub step_started_at: Option<DateTime<Utc>>,
|
||||
pub last_completed_at: Option<DateTime<Utc>>,
|
||||
pub blocked_reason: Option<String>,
|
||||
pub fault_message: Option<String>,
|
||||
pub manual_ack_required: bool,
|
||||
pub comm_locked: bool,
|
||||
pub rem_local: bool,
|
||||
pub held_resources: Vec<String>,
|
||||
}
|
||||
|
||||
impl SegmentRuntime {
|
||||
pub fn new(segment_id: Uuid) -> Self {
|
||||
Self {
|
||||
segment_id,
|
||||
state: SegmentState::Idle,
|
||||
auto_enabled: false,
|
||||
current_step_no: None,
|
||||
step_started_at: None,
|
||||
last_completed_at: None,
|
||||
blocked_reason: None,
|
||||
fault_message: None,
|
||||
manual_ack_required: false,
|
||||
comm_locked: false,
|
||||
rem_local: false,
|
||||
held_resources: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SegmentRuntimeStore {
|
||||
inner: Arc<RwLock<HashMap<Uuid, SegmentRuntime>>>,
|
||||
notifiers: Arc<RwLock<HashMap<Uuid, Arc<Notify>>>>,
|
||||
}
|
||||
|
||||
impl SegmentRuntimeStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub async fn get(&self, segment_id: Uuid) -> Option<SegmentRuntime> {
|
||||
self.inner.read().await.get(&segment_id).cloned()
|
||||
}
|
||||
|
||||
pub async fn get_or_init(&self, segment_id: Uuid) -> SegmentRuntime {
|
||||
if let Some(runtime) = self.get(segment_id).await {
|
||||
return runtime;
|
||||
}
|
||||
|
||||
let runtime = SegmentRuntime::new(segment_id);
|
||||
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);
|
||||
}
|
||||
|
||||
pub async fn get_or_create_notify(&self, segment_id: Uuid) -> Arc<Notify> {
|
||||
self.notifiers
|
||||
.write()
|
||||
.await
|
||||
.entry(segment_id)
|
||||
.or_insert_with(|| Arc::new(Notify::new()))
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub async fn get_all(&self) -> HashMap<Uuid, SegmentRuntime> {
|
||||
self.inner.read().await.clone()
|
||||
}
|
||||
|
||||
pub async fn notify_segment(&self, segment_id: Uuid) {
|
||||
if let Some(notify) = self.notifiers.read().await.get(&segment_id) {
|
||||
notify.notify_one();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Segment execution state per design doc §5.2.
|
||||
///
|
||||
/// Matches the 9-state machine derived from spec §13.6.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SegmentState {
|
||||
Idle,
|
||||
Checking,
|
||||
Executing,
|
||||
Confirming,
|
||||
Resetting,
|
||||
Completed,
|
||||
Blocked,
|
||||
Faulted,
|
||||
ManualAckRequired,
|
||||
}
|
||||
|
|
@ -0,0 +1,378 @@
|
|||
//! Step executor (design doc §5.4).
|
||||
//!
|
||||
//! Resolves a `segment_step.action_kind` to a concrete write on a command point.
|
||||
//! Three dispatch modes:
|
||||
//!
|
||||
//! - 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::sync::Arc;
|
||||
|
||||
use plc_platform_core::{
|
||||
connection::{BatchSetPointValueReq, ConnectionManager, SetPointValueReqItem},
|
||||
control::command::send_pulse_command,
|
||||
service::EquipmentSignalRole,
|
||||
telemetry::{PointMonitorInfo, ValueType},
|
||||
};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::SegmentStep;
|
||||
|
||||
/// Cached lookup of (equipment_id, signal_role) → point_id for all equipment a
|
||||
/// segment touches. Loaded once per segment task tick.
|
||||
#[derive(Default)]
|
||||
pub struct CommandPointIndex {
|
||||
map: HashMap<(Uuid, String), Uuid>,
|
||||
}
|
||||
|
||||
impl CommandPointIndex {
|
||||
pub async fn for_steps(pool: &PgPool, steps: &[SegmentStep]) -> Result<Self, sqlx::Error> {
|
||||
let equipment_ids: Vec<Uuid> = steps.iter().filter_map(|s| s.target_equipment_id).collect();
|
||||
if equipment_ids.is_empty() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let rows: Vec<EquipmentSignalRole> =
|
||||
plc_platform_core::service::get_signal_role_points_batch(pool, &equipment_ids).await?;
|
||||
let mut map = HashMap::new();
|
||||
for row in rows {
|
||||
map.insert((row.equipment_id, row.signal_role), row.point_id);
|
||||
}
|
||||
Ok(Self { map })
|
||||
}
|
||||
|
||||
pub fn lookup(&self, equipment_id: Uuid, role: &str) -> Option<Uuid> {
|
||||
self.map.get(&(equipment_id, role.to_string())).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub enum DispatchOutcome {
|
||||
/// A command was issued (or skipped because the action is wait-only).
|
||||
/// The engine moves to `Confirming`.
|
||||
Issued,
|
||||
/// The step is mis-configured (missing role/equipment). The engine should
|
||||
/// transition to `Faulted` with this message.
|
||||
Misconfigured(String),
|
||||
/// The underlying write failed. Engine should transition to `Faulted`.
|
||||
WriteError(String),
|
||||
}
|
||||
|
||||
/// Dispatch `step.action_kind`. See module docs for the three dispatch modes.
|
||||
pub async fn dispatch(
|
||||
step: &SegmentStep,
|
||||
connection: &Arc<ConnectionManager>,
|
||||
command_points: &CommandPointIndex,
|
||||
monitor: &HashMap<Uuid, PointMonitorInfo>,
|
||||
inputs: &DispatchInputs<'_>,
|
||||
) -> DispatchOutcome {
|
||||
if step.action_kind == "wait_signal" {
|
||||
return DispatchOutcome::Issued;
|
||||
}
|
||||
|
||||
let command_role = match step.command_role.as_deref() {
|
||||
Some(role) => role,
|
||||
None => match default_command_role(step.action_kind.as_str()) {
|
||||
Some(role) => role,
|
||||
None => {
|
||||
return DispatchOutcome::Misconfigured(format!(
|
||||
"step {} action {} has no command_role and no default",
|
||||
step.step_no, step.action_kind
|
||||
))
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let equipment_id = match step.target_equipment_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return DispatchOutcome::Misconfigured(format!(
|
||||
"step {} action {} has no target_equipment_id",
|
||||
step.step_no, step.action_kind
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let point_id = match command_points.lookup(equipment_id, command_role) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return DispatchOutcome::Misconfigured(format!(
|
||||
"equipment {} has no '{}' role binding",
|
||||
equipment_id, command_role
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
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());
|
||||
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
|
||||
{
|
||||
return DispatchOutcome::WriteError(err);
|
||||
}
|
||||
|
||||
DispatchOutcome::Issued
|
||||
}
|
||||
|
||||
/// Send the configured stop command. Used after `hold_until_confirm` steps and
|
||||
/// on `cancel_on_fault` cleanup. No-op when no stop role is configured.
|
||||
pub async fn send_stop_command(
|
||||
step: &SegmentStep,
|
||||
connection: &Arc<ConnectionManager>,
|
||||
command_points: &CommandPointIndex,
|
||||
monitor: &HashMap<Uuid, PointMonitorInfo>,
|
||||
) -> Result<(), String> {
|
||||
let role = match step.stop_command_role.as_deref() {
|
||||
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 point_id = command_points.lookup(equipment_id, role).ok_or_else(|| {
|
||||
format!(
|
||||
"equipment {} has no '{}' stop-role binding",
|
||||
equipment_id, role
|
||||
)
|
||||
})?;
|
||||
let value_type = monitor.get(&point_id).and_then(|m| m.value_type.clone());
|
||||
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.
|
||||
fn default_command_role(action_kind: &str) -> Option<&'static str> {
|
||||
match action_kind {
|
||||
"open_door" => Some("open_cmd"),
|
||||
"close_door" => Some("close_cmd"),
|
||||
"push_forward" => Some("forward_cmd"),
|
||||
"push_retract" => Some("retract_cmd"),
|
||||
"pull_run" => Some("start_cmd"),
|
||||
"pull_retract" => Some("retract_cmd"),
|
||||
"transfer_move_to" => Some("move_cmd"),
|
||||
"step_once" => Some("step_cmd"),
|
||||
"robot_permit" => Some("permit_cmd"),
|
||||
"robot_release" => Some("release_cmd"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Default pulse width for actions where the spec doesn't override.
|
||||
fn default_pulse_ms(action_kind: &str) -> i32 {
|
||||
match action_kind {
|
||||
"open_door" | "close_door" | "robot_permit" | "robot_release" | "step_once" => 300,
|
||||
_ => 500,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
|
||||
fn make_step(action_kind: &str, equipment: Option<Uuid>, role: Option<&str>) -> SegmentStep {
|
||||
SegmentStep {
|
||||
id: Uuid::new_v4(),
|
||||
segment_id: Uuid::new_v4(),
|
||||
step_no: 1,
|
||||
step_code: "S1".to_string(),
|
||||
action_kind: action_kind.to_string(),
|
||||
target_equipment_id: equipment,
|
||||
target_station_id: None,
|
||||
confirm_signal_role: None,
|
||||
confirm_point_id: None,
|
||||
expected_value: true,
|
||||
timeout_ms: 30_000,
|
||||
command_role: role.map(|s| s.to_string()),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_pulse_short_for_short_actions() {
|
||||
assert_eq!(default_pulse_ms("open_door"), 300);
|
||||
assert_eq!(default_pulse_ms("transfer_move_to"), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_role_resolves_for_known_actions() {
|
||||
assert_eq!(default_command_role("open_door"), Some("open_cmd"));
|
||||
assert_eq!(default_command_role("transfer_move_to"), Some("move_cmd"));
|
||||
assert_eq!(default_command_role("wait_signal"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_point_index_lookup_returns_registered_point() {
|
||||
let eq_id = Uuid::new_v4();
|
||||
let pid = Uuid::new_v4();
|
||||
let mut idx = CommandPointIndex::default();
|
||||
idx.map.insert((eq_id, "open_cmd".to_string()), pid);
|
||||
assert_eq!(idx.lookup(eq_id, "open_cmd"), Some(pid));
|
||||
assert_eq!(idx.lookup(eq_id, "close_cmd"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_signal_step_is_dispatched_without_command_role() {
|
||||
let step = make_step("wait_signal", None, 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::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(_)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use plc_platform_core::{
|
||||
event::{record_event, EventInsert, MetadataCache},
|
||||
websocket::WebSocketManager,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
const CONTROL_EVENT_CHANNEL_CAPACITY: usize = 1024;
|
||||
|
||||
/// Operation-system business events.
|
||||
///
|
||||
/// Each variant maps to a row in the `event` table (via `record_event`) and
|
||||
/// follows the `ops.*` namespace from design doc §8.1. Every record carries
|
||||
/// `subject_type` + `subject_id` so the front-end can filter the timeline
|
||||
/// for one segment / station without joining on event_type strings.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppEvent {
|
||||
SegmentAutoStarted {
|
||||
segment_id: Uuid,
|
||||
},
|
||||
SegmentAutoStopped {
|
||||
segment_id: Uuid,
|
||||
},
|
||||
SegmentStepAdvanced {
|
||||
segment_id: Uuid,
|
||||
step_no: i32,
|
||||
},
|
||||
SegmentCompleted {
|
||||
segment_id: Uuid,
|
||||
},
|
||||
SegmentBlocked {
|
||||
segment_id: Uuid,
|
||||
reason: String,
|
||||
},
|
||||
SegmentFaultLocked {
|
||||
segment_id: Uuid,
|
||||
message: String,
|
||||
},
|
||||
SegmentFaultAcked {
|
||||
segment_id: Uuid,
|
||||
},
|
||||
SegmentCommLocked {
|
||||
segment_id: Uuid,
|
||||
},
|
||||
SegmentCommRecovered {
|
||||
segment_id: Uuid,
|
||||
},
|
||||
StationStateChanged {
|
||||
station_id: Uuid,
|
||||
presence: bool,
|
||||
vacancy: bool,
|
||||
},
|
||||
AlarmActionTimeout {
|
||||
segment_id: Uuid,
|
||||
step_no: i32,
|
||||
},
|
||||
AlarmSignalConflict {
|
||||
segment_id: Uuid,
|
||||
message: String,
|
||||
},
|
||||
AlarmResourceBusy {
|
||||
segment_id: Uuid,
|
||||
resource_key: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct EventManager {
|
||||
sender: mpsc::Sender<AppEvent>,
|
||||
}
|
||||
|
||||
impl EventManager {
|
||||
pub fn new(
|
||||
pool: sqlx::PgPool,
|
||||
ws_manager: Option<Arc<WebSocketManager>>,
|
||||
metadata: Arc<MetadataCache>,
|
||||
) -> Self {
|
||||
let (sender, mut receiver) = mpsc::channel::<AppEvent>(CONTROL_EVENT_CHANNEL_CAPACITY);
|
||||
|
||||
let pool_for_task = pool.clone();
|
||||
let ws_for_task = ws_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(event) = receiver.recv().await {
|
||||
handle_event(event, &pool_for_task, ws_for_task.as_ref(), &metadata).await;
|
||||
}
|
||||
});
|
||||
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
pub fn send(&self, event: AppEvent) -> Result<(), String> {
|
||||
match self.sender.try_send(event) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(mpsc::error::TrySendError::Closed(e)) => {
|
||||
Err(format!("ops event channel closed ({e:?})"))
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Full(e)) => Err(format!("ops event queue full ({e:?})")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_event(
|
||||
event_type: &'static str,
|
||||
level: &'static str,
|
||||
segment_id: Uuid,
|
||||
message: String,
|
||||
payload: serde_json::Value,
|
||||
) -> EventInsert {
|
||||
EventInsert {
|
||||
event_type,
|
||||
level,
|
||||
unit_id: None,
|
||||
equipment_id: None,
|
||||
source_id: None,
|
||||
subject_type: Some("segment"),
|
||||
subject_id: Some(segment_id),
|
||||
message,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_event(
|
||||
event: AppEvent,
|
||||
pool: &sqlx::PgPool,
|
||||
ws_manager: Option<&Arc<WebSocketManager>>,
|
||||
_metadata: &MetadataCache,
|
||||
) {
|
||||
let record: Option<EventInsert> = match &event {
|
||||
AppEvent::SegmentAutoStarted { segment_id } => Some(segment_event(
|
||||
"ops.segment.auto_started",
|
||||
"info",
|
||||
*segment_id,
|
||||
format!("Segment {} auto control started", segment_id),
|
||||
serde_json::json!({ "segment_id": segment_id }),
|
||||
)),
|
||||
AppEvent::SegmentAutoStopped { segment_id } => Some(segment_event(
|
||||
"ops.segment.auto_stopped",
|
||||
"info",
|
||||
*segment_id,
|
||||
format!("Segment {} auto control stopped", segment_id),
|
||||
serde_json::json!({ "segment_id": segment_id }),
|
||||
)),
|
||||
AppEvent::SegmentStepAdvanced {
|
||||
segment_id,
|
||||
step_no,
|
||||
} => Some(segment_event(
|
||||
"ops.segment.step_advanced",
|
||||
"info",
|
||||
*segment_id,
|
||||
format!("Segment {} advanced to step {}", segment_id, step_no),
|
||||
serde_json::json!({ "segment_id": segment_id, "step_no": step_no }),
|
||||
)),
|
||||
AppEvent::SegmentCompleted { segment_id } => Some(segment_event(
|
||||
"ops.segment.completed",
|
||||
"info",
|
||||
*segment_id,
|
||||
format!("Segment {} completed", segment_id),
|
||||
serde_json::json!({ "segment_id": segment_id }),
|
||||
)),
|
||||
AppEvent::SegmentBlocked { segment_id, reason } => Some(segment_event(
|
||||
"ops.segment.blocked",
|
||||
"warn",
|
||||
*segment_id,
|
||||
format!("Segment {} blocked: {}", segment_id, reason),
|
||||
serde_json::json!({ "segment_id": segment_id, "reason": reason }),
|
||||
)),
|
||||
AppEvent::SegmentFaultLocked {
|
||||
segment_id,
|
||||
message,
|
||||
} => Some(segment_event(
|
||||
"ops.segment.fault_locked",
|
||||
"error",
|
||||
*segment_id,
|
||||
format!("Segment {} fault locked: {}", segment_id, message),
|
||||
serde_json::json!({ "segment_id": segment_id, "message": message }),
|
||||
)),
|
||||
AppEvent::SegmentFaultAcked { segment_id } => Some(segment_event(
|
||||
"ops.segment.fault_acked",
|
||||
"info",
|
||||
*segment_id,
|
||||
format!("Segment {} fault acknowledged", segment_id),
|
||||
serde_json::json!({ "segment_id": segment_id }),
|
||||
)),
|
||||
AppEvent::SegmentCommLocked { segment_id } => Some(segment_event(
|
||||
"ops.segment.comm_locked",
|
||||
"warn",
|
||||
*segment_id,
|
||||
format!("Segment {} communication locked", segment_id),
|
||||
serde_json::json!({ "segment_id": segment_id }),
|
||||
)),
|
||||
AppEvent::SegmentCommRecovered { segment_id } => Some(segment_event(
|
||||
"ops.segment.comm_recovered",
|
||||
"info",
|
||||
*segment_id,
|
||||
format!("Segment {} communication recovered", segment_id),
|
||||
serde_json::json!({ "segment_id": segment_id }),
|
||||
)),
|
||||
AppEvent::StationStateChanged {
|
||||
station_id,
|
||||
presence,
|
||||
vacancy,
|
||||
} => Some(EventInsert {
|
||||
event_type: "ops.station.state_changed",
|
||||
level: "info",
|
||||
unit_id: None,
|
||||
equipment_id: None,
|
||||
source_id: None,
|
||||
subject_type: Some("station"),
|
||||
subject_id: Some(*station_id),
|
||||
message: format!(
|
||||
"Station {} state changed (presence={}, vacancy={})",
|
||||
station_id, presence, vacancy
|
||||
),
|
||||
payload: serde_json::json!({
|
||||
"station_id": station_id,
|
||||
"presence": presence,
|
||||
"vacancy": vacancy
|
||||
}),
|
||||
}),
|
||||
AppEvent::AlarmActionTimeout {
|
||||
segment_id,
|
||||
step_no,
|
||||
} => Some(segment_event(
|
||||
"ops.alarm.action_timeout",
|
||||
"error",
|
||||
*segment_id,
|
||||
format!("Action timeout on segment {} step {}", segment_id, step_no),
|
||||
serde_json::json!({ "segment_id": segment_id, "step_no": step_no }),
|
||||
)),
|
||||
AppEvent::AlarmSignalConflict {
|
||||
segment_id,
|
||||
message,
|
||||
} => Some(segment_event(
|
||||
"ops.alarm.signal_conflict",
|
||||
"error",
|
||||
*segment_id,
|
||||
format!("Signal conflict on segment {}: {}", segment_id, message),
|
||||
serde_json::json!({ "segment_id": segment_id, "message": message }),
|
||||
)),
|
||||
AppEvent::AlarmResourceBusy {
|
||||
segment_id,
|
||||
resource_key,
|
||||
} => Some(segment_event(
|
||||
"ops.alarm.resource_busy",
|
||||
"warn",
|
||||
*segment_id,
|
||||
format!("Resource {} busy for segment {}", resource_key, segment_id),
|
||||
serde_json::json!({
|
||||
"segment_id": segment_id,
|
||||
"resource_key": resource_key
|
||||
}),
|
||||
)),
|
||||
};
|
||||
|
||||
if let Some(record) = record {
|
||||
record_event(pool, ws_manager.map(Arc::as_ref), record).await;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,6 @@
|
|||
pub mod control;
|
||||
pub mod doc;
|
||||
pub mod event;
|
||||
pub mod runtime;
|
||||
pub mod segment;
|
||||
pub mod station;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,209 @@
|
|||
//! Segment control endpoints (design doc §9.2).
|
||||
//!
|
||||
//! 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 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,
|
||||
};
|
||||
|
||||
async fn require_segment(
|
||||
state: &AppState,
|
||||
segment_id: Uuid,
|
||||
) -> Result<crate::model::ProcessSegment, ApiErr> {
|
||||
let segment = segment_service::get_segment_by_id(&state.platform.pool, segment_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Segment not found".to_string(), None))?;
|
||||
if !segment.enabled {
|
||||
return Err(ApiErr::BadRequest(
|
||||
"Segment is disabled".to_string(),
|
||||
Some(json!({ "segment_id": segment_id })),
|
||||
));
|
||||
}
|
||||
Ok(segment)
|
||||
}
|
||||
|
||||
pub async fn start_auto_segment(
|
||||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let segment = require_segment(&state, segment_id).await?;
|
||||
if segment.mode != "auto" {
|
||||
return Err(ApiErr::BadRequest(
|
||||
format!("Segment mode {} does not allow auto start", segment.mode),
|
||||
Some(json!({ "segment_id": segment_id, "mode": segment.mode })),
|
||||
));
|
||||
}
|
||||
|
||||
let mut runtime = state.segment_runtime.get_or_init(segment_id).await;
|
||||
if matches!(
|
||||
runtime.state,
|
||||
SegmentState::Faulted | SegmentState::ManualAckRequired
|
||||
) {
|
||||
return Err(ApiErr::BadRequest(
|
||||
"Segment is fault-locked; acknowledge before starting".to_string(),
|
||||
Some(json!({
|
||||
"segment_id": segment_id,
|
||||
"state": serde_json::to_value(&runtime.state).unwrap_or(serde_json::Value::Null)
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
runtime.auto_enabled = true;
|
||||
if matches!(runtime.state, SegmentState::Idle) {
|
||||
runtime.blocked_reason = None;
|
||||
}
|
||||
state.segment_runtime.upsert(runtime).await;
|
||||
state.segment_runtime.notify_segment(segment_id).await;
|
||||
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(AppEvent::SegmentAutoStarted { segment_id });
|
||||
|
||||
Ok(Json(
|
||||
json!({ "ok_msg": "Auto control started", "segment_id": segment_id }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn stop_auto_segment(
|
||||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
require_segment(&state, segment_id).await?;
|
||||
let mut runtime = state.segment_runtime.get_or_init(segment_id).await;
|
||||
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 });
|
||||
|
||||
Ok(Json(
|
||||
json!({ "ok_msg": "Auto control stopped", "segment_id": segment_id }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn ack_fault_segment(
|
||||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
require_segment(&state, segment_id).await?;
|
||||
let mut runtime = state.segment_runtime.get_or_init(segment_id).await;
|
||||
|
||||
if !matches!(
|
||||
runtime.state,
|
||||
SegmentState::Faulted | SegmentState::ManualAckRequired
|
||||
) {
|
||||
return Err(ApiErr::BadRequest(
|
||||
"Segment is not in a faulted state".to_string(),
|
||||
Some(json!({ "segment_id": segment_id })),
|
||||
));
|
||||
}
|
||||
|
||||
runtime.fault_message = None;
|
||||
runtime.manual_ack_required = false;
|
||||
runtime.current_step_no = None;
|
||||
runtime.blocked_reason = None;
|
||||
runtime.state = SegmentState::Idle;
|
||||
state.segment_runtime.upsert(runtime).await;
|
||||
state.segment_runtime.notify_segment(segment_id).await;
|
||||
|
||||
let _ = state
|
||||
.event_manager
|
||||
.send(AppEvent::SegmentFaultAcked { segment_id });
|
||||
|
||||
Ok(Json(
|
||||
json!({ "ok_msg": "Fault acknowledged", "segment_id": segment_id }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn reset_segment(
|
||||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
require_segment(&state, segment_id).await?;
|
||||
let mut runtime = state.segment_runtime.get_or_init(segment_id).await;
|
||||
if !matches!(
|
||||
runtime.state,
|
||||
SegmentState::Blocked | SegmentState::Faulted | SegmentState::ManualAckRequired
|
||||
) {
|
||||
return Err(ApiErr::BadRequest(
|
||||
"Reset only allowed from Blocked / Faulted / ManualAckRequired".to_string(),
|
||||
Some(json!({ "segment_id": segment_id })),
|
||||
));
|
||||
}
|
||||
state.resource_registry.release_all_for(segment_id).await;
|
||||
runtime.held_resources.clear();
|
||||
runtime.auto_enabled = false;
|
||||
runtime.current_step_no = None;
|
||||
runtime.step_started_at = None;
|
||||
runtime.blocked_reason = None;
|
||||
runtime.fault_message = None;
|
||||
runtime.manual_ack_required = false;
|
||||
runtime.state = SegmentState::Idle;
|
||||
state.segment_runtime.upsert(runtime).await;
|
||||
state.segment_runtime.notify_segment(segment_id).await;
|
||||
|
||||
Ok(Json(
|
||||
json!({ "ok_msg": "Segment reset to idle", "segment_id": segment_id }),
|
||||
))
|
||||
}
|
||||
|
||||
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();
|
||||
for segment in segments {
|
||||
if !segment.enabled || segment.mode != "auto" {
|
||||
skipped.push(segment.id);
|
||||
continue;
|
||||
}
|
||||
let mut runtime = state.segment_runtime.get_or_init(segment.id).await;
|
||||
if matches!(
|
||||
runtime.state,
|
||||
SegmentState::Faulted | SegmentState::ManualAckRequired
|
||||
) || runtime.auto_enabled
|
||||
{
|
||||
skipped.push(segment.id);
|
||||
continue;
|
||||
}
|
||||
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,
|
||||
});
|
||||
started.push(segment.id);
|
||||
}
|
||||
Ok(Json(json!({ "started": started, "skipped": skipped })))
|
||||
}
|
||||
|
||||
pub async fn batch_stop_auto(State(state): State<AppState>) -> Result<impl IntoResponse, ApiErr> {
|
||||
let segments = segment_service::list_segments(&state.platform.pool, None).await?;
|
||||
let mut stopped = Vec::new();
|
||||
for segment in segments {
|
||||
let mut runtime = state.segment_runtime.get_or_init(segment.id).await;
|
||||
if !runtime.auto_enabled {
|
||||
continue;
|
||||
}
|
||||
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,
|
||||
});
|
||||
stopped.push(segment.id);
|
||||
}
|
||||
Ok(Json(json!({ "stopped": stopped })))
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
//! Event timeline endpoint with subject filtering (design doc §9.3).
|
||||
//!
|
||||
//! `event_type` matches exact value or prefix when `event_type=ops.` style is
|
||||
//! requested. `subject_type` / `subject_id` use the columns added by the P1
|
||||
//! 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 serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
use plc_platform_core::{
|
||||
service::EventFilter,
|
||||
util::{
|
||||
pagination::{PaginatedResponse, PaginationParams},
|
||||
response::ApiErr,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct GetEventListQuery {
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub event_type: Option<String>,
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub event_type_prefix: Option<String>,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub subject_type: Option<String>,
|
||||
pub subject_id: Option<Uuid>,
|
||||
#[serde(flatten)]
|
||||
pub pagination: PaginationParams,
|
||||
}
|
||||
|
||||
pub async fn get_event_list(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<GetEventListQuery>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
query.validate()?;
|
||||
|
||||
let filter = EventFilter {
|
||||
unit_id: None,
|
||||
event_type: query.event_type.as_deref(),
|
||||
event_type_prefix: query.event_type_prefix.as_deref(),
|
||||
subject_type: query.subject_type.as_deref(),
|
||||
subject_id: query.subject_id,
|
||||
};
|
||||
|
||||
let total =
|
||||
plc_platform_core::service::get_events_count_filtered(&state.platform.pool, &filter)
|
||||
.await?;
|
||||
let data = plc_platform_core::service::get_events_paginated_filtered(
|
||||
&state.platform.pool,
|
||||
&filter,
|
||||
query.pagination.page_size,
|
||||
query.pagination.offset(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(PaginatedResponse::new(
|
||||
data,
|
||||
total,
|
||||
query.pagination.page,
|
||||
query.pagination.page_size,
|
||||
)))
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
//! Runtime read endpoints (design doc §9.3).
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use plc_platform_core::util::response::ApiErr;
|
||||
|
||||
use crate::{
|
||||
service::{segment as segment_service, station as station_service},
|
||||
AppState,
|
||||
};
|
||||
|
||||
pub async fn get_overview(State(state): State<AppState>) -> Result<impl IntoResponse, ApiErr> {
|
||||
let segments = segment_service::list_segments(&state.platform.pool, None).await?;
|
||||
let runtimes = state.segment_runtime.get_all().await;
|
||||
|
||||
let segment_payload: Vec<_> = segments
|
||||
.into_iter()
|
||||
.map(|seg| {
|
||||
let runtime = runtimes.get(&seg.id).cloned();
|
||||
json!({
|
||||
"segment": seg,
|
||||
"runtime": runtime,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let resource_snapshot = state.resource_registry.snapshot().await;
|
||||
let resources: Vec<_> = resource_snapshot
|
||||
.into_iter()
|
||||
.map(|(key, lease)| {
|
||||
json!({
|
||||
"resource_key": key,
|
||||
"owner_segment_id": lease.owner_segment_id,
|
||||
"acquired_at": lease.acquired_at,
|
||||
"heartbeat_at": lease.heartbeat_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"segments": segment_payload,
|
||||
"resources": resources,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_segment_runtime(
|
||||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let segment = segment_service::get_segment_by_id(&state.platform.pool, segment_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Segment not found".to_string(), None))?;
|
||||
let runtime = state.segment_runtime.get_or_init(segment_id).await;
|
||||
Ok(Json(json!({
|
||||
"segment": segment,
|
||||
"runtime": runtime,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_station_runtime(
|
||||
State(state): State<AppState>,
|
||||
Path(station_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
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?;
|
||||
|
||||
// Attach the latest monitor sample for each bound point.
|
||||
let monitor_guard = state
|
||||
.platform
|
||||
.connection_manager
|
||||
.get_point_monitor_data_read_guard()
|
||||
.await;
|
||||
|
||||
let signal_payload: Vec<_> = signals
|
||||
.iter()
|
||||
.map(|sig| {
|
||||
let monitor = sig
|
||||
.point_id
|
||||
.and_then(|pid| monitor_guard.get(&pid).cloned());
|
||||
json!({
|
||||
"signal": sig,
|
||||
"point_monitor": monitor,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
drop(monitor_guard);
|
||||
|
||||
Ok(Json(json!({
|
||||
"station": station,
|
||||
"signals": signal_payload,
|
||||
})))
|
||||
}
|
||||
|
|
@ -0,0 +1,588 @@
|
|||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
use plc_platform_core::util::response::ApiErr;
|
||||
|
||||
use crate::{service::segment as segment_service, AppState};
|
||||
|
||||
const SEGMENT_TYPES: &[&str] = &[
|
||||
"front_load",
|
||||
"robot",
|
||||
"front_release",
|
||||
"front_transfer",
|
||||
"kiln_infeed",
|
||||
"kiln_step",
|
||||
"kiln_outfeed",
|
||||
"tail_transfer",
|
||||
"tail_step",
|
||||
"unload",
|
||||
"return",
|
||||
];
|
||||
|
||||
const SEGMENT_MODES: &[&str] = &["auto", "remote_manual", "local_manual", "disabled"];
|
||||
|
||||
const ACTION_KINDS: &[&str] = &[
|
||||
"open_door",
|
||||
"close_door",
|
||||
"push_forward",
|
||||
"push_retract",
|
||||
"pull_run",
|
||||
"pull_retract",
|
||||
"transfer_move_to",
|
||||
"step_once",
|
||||
"robot_permit",
|
||||
"robot_release",
|
||||
"wait_signal",
|
||||
"pulse_cmd",
|
||||
];
|
||||
|
||||
const ON_TIMEOUT_VALUES: &[&str] = &["fault", "retry", "block"];
|
||||
|
||||
const INTERLOCK_APPLIES_TO: &[&str] = &["start_allow", "start_deny", "run_halt"];
|
||||
|
||||
const RULE_KINDS: &[&str] = &[
|
||||
"point_eq",
|
||||
"station_vacant",
|
||||
"station_occupied",
|
||||
"equipment_origin",
|
||||
"equipment_no_fault",
|
||||
"equipment_remote",
|
||||
"safety_chain_ok",
|
||||
];
|
||||
|
||||
fn validate_enum(name: &'static str, value: &str, allowed: &[&str]) -> Result<(), ApiErr> {
|
||||
if allowed.contains(&value) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiErr::BadRequest(
|
||||
format!("invalid {}: {}", name, value),
|
||||
Some(json!({ "allowed": allowed })),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct ListSegmentQuery {
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub line_code: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_segments(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListSegmentQuery>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
query.validate()?;
|
||||
let segments =
|
||||
segment_service::list_segments(&state.platform.pool, query.line_code.as_deref()).await?;
|
||||
Ok(Json(segments))
|
||||
}
|
||||
|
||||
pub async fn get_segment(
|
||||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let segment = segment_service::get_segment_by_id(&state.platform.pool, segment_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Segment not found".to_string(), None))?;
|
||||
Ok(Json(segment))
|
||||
}
|
||||
|
||||
pub async fn get_segment_detail(
|
||||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let segment = segment_service::get_segment_by_id(&state.platform.pool, segment_id)
|
||||
.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?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"segment": segment,
|
||||
"steps": steps,
|
||||
"interlocks": interlocks,
|
||||
"resources": resources,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct CreateSegmentReq {
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub code: String,
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub name: String,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub segment_type: String,
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub line_code: Option<String>,
|
||||
pub priority: Option<i32>,
|
||||
pub enabled: Option<bool>,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub mode: Option<String>,
|
||||
pub require_manual_ack_after_fault: Option<bool>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_segment(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateSegmentReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
validate_enum("segment_type", &payload.segment_type, SEGMENT_TYPES)?;
|
||||
let mode = payload.mode.as_deref().unwrap_or("disabled");
|
||||
validate_enum("mode", mode, SEGMENT_MODES)?;
|
||||
|
||||
if segment_service::get_segment_by_code(&state.platform.pool, &payload.code)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiErr::BadRequest(
|
||||
"Segment code already exists".to_string(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
let segment_id = segment_service::create_segment(
|
||||
&state.platform.pool,
|
||||
segment_service::CreateSegmentParams {
|
||||
code: &payload.code,
|
||||
name: &payload.name,
|
||||
segment_type: &payload.segment_type,
|
||||
line_code: payload.line_code.as_deref(),
|
||||
priority: payload.priority.unwrap_or(0),
|
||||
enabled: payload.enabled.unwrap_or(true),
|
||||
mode,
|
||||
require_manual_ack_after_fault: payload.require_manual_ack_after_fault.unwrap_or(true),
|
||||
description: payload.description.as_deref(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(json!({ "id": segment_id, "ok_msg": "Segment created" })),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct UpdateSegmentReq {
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub code: Option<String>,
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub name: Option<String>,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub segment_type: Option<String>,
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub line_code: Option<String>,
|
||||
pub priority: Option<i32>,
|
||||
pub enabled: Option<bool>,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub mode: Option<String>,
|
||||
pub require_manual_ack_after_fault: Option<bool>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_segment(
|
||||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateSegmentReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
if let Some(s) = payload.segment_type.as_deref() {
|
||||
validate_enum("segment_type", s, SEGMENT_TYPES)?;
|
||||
}
|
||||
if let Some(m) = payload.mode.as_deref() {
|
||||
validate_enum("mode", m, SEGMENT_MODES)?;
|
||||
}
|
||||
|
||||
let existing = segment_service::get_segment_by_id(&state.platform.pool, segment_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Segment not found".to_string(), None))?;
|
||||
|
||||
if let Some(code) = payload.code.as_deref() {
|
||||
if let Some(other) =
|
||||
segment_service::get_segment_by_code(&state.platform.pool, code).await?
|
||||
{
|
||||
if other.id != existing.id {
|
||||
return Err(ApiErr::BadRequest(
|
||||
"Segment code already exists".to_string(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
segment_service::update_segment(
|
||||
&state.platform.pool,
|
||||
segment_id,
|
||||
segment_service::UpdateSegmentParams {
|
||||
code: payload.code.as_deref(),
|
||||
name: payload.name.as_deref(),
|
||||
segment_type: payload.segment_type.as_deref(),
|
||||
line_code: payload.line_code.as_deref(),
|
||||
priority: payload.priority,
|
||||
enabled: payload.enabled,
|
||||
mode: payload.mode.as_deref(),
|
||||
require_manual_ack_after_fault: payload.require_manual_ack_after_fault,
|
||||
description: payload.description.as_deref(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "ok_msg": "Segment updated" })))
|
||||
}
|
||||
|
||||
pub async fn delete_segment(
|
||||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let deleted = segment_service::delete_segment(&state.platform.pool, segment_id).await?;
|
||||
if !deleted {
|
||||
return Err(ApiErr::NotFound("Segment not found".to_string(), None));
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// Steps
|
||||
|
||||
pub async fn list_steps(
|
||||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let steps = segment_service::list_steps(&state.platform.pool, segment_id).await?;
|
||||
Ok(Json(steps))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct CreateStepReq {
|
||||
#[validate(range(min = 1))]
|
||||
pub step_no: i32,
|
||||
#[validate(length(min = 1, max = 64))]
|
||||
pub step_code: String,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub action_kind: String,
|
||||
pub target_equipment_id: Option<Uuid>,
|
||||
pub target_station_id: Option<Uuid>,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub confirm_signal_role: Option<String>,
|
||||
pub confirm_point_id: Option<Uuid>,
|
||||
pub expected_value: Option<bool>,
|
||||
#[validate(range(min = 1))]
|
||||
pub timeout_ms: Option<i32>,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub command_role: Option<String>,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub stop_command_role: Option<String>,
|
||||
#[validate(range(min = 1))]
|
||||
pub pulse_ms: Option<i32>,
|
||||
pub hold_until_confirm: Option<bool>,
|
||||
pub cancel_on_fault: Option<bool>,
|
||||
pub next_step_no_on_success: Option<i32>,
|
||||
pub next_step_no_on_failure: Option<i32>,
|
||||
#[validate(length(min = 1, max = 16))]
|
||||
pub on_timeout: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_step(
|
||||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
Json(payload): Json<CreateStepReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
validate_enum("action_kind", &payload.action_kind, ACTION_KINDS)?;
|
||||
let on_timeout = payload.on_timeout.as_deref().unwrap_or("fault");
|
||||
validate_enum("on_timeout", on_timeout, ON_TIMEOUT_VALUES)?;
|
||||
|
||||
segment_service::get_segment_by_id(&state.platform.pool, segment_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Segment not found".to_string(), None))?;
|
||||
|
||||
let step_id = segment_service::create_step(
|
||||
&state.platform.pool,
|
||||
segment_service::CreateStepParams {
|
||||
segment_id,
|
||||
step_no: payload.step_no,
|
||||
step_code: &payload.step_code,
|
||||
action_kind: &payload.action_kind,
|
||||
target_equipment_id: payload.target_equipment_id,
|
||||
target_station_id: payload.target_station_id,
|
||||
confirm_signal_role: payload.confirm_signal_role.as_deref(),
|
||||
confirm_point_id: payload.confirm_point_id,
|
||||
expected_value: payload.expected_value.unwrap_or(true),
|
||||
timeout_ms: payload.timeout_ms.unwrap_or(30000),
|
||||
command_role: payload.command_role.as_deref(),
|
||||
stop_command_role: payload.stop_command_role.as_deref(),
|
||||
pulse_ms: payload.pulse_ms,
|
||||
hold_until_confirm: payload.hold_until_confirm.unwrap_or(false),
|
||||
cancel_on_fault: payload.cancel_on_fault.unwrap_or(true),
|
||||
next_step_no_on_success: payload.next_step_no_on_success,
|
||||
next_step_no_on_failure: payload.next_step_no_on_failure,
|
||||
on_timeout,
|
||||
description: payload.description.as_deref(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(json!({ "id": step_id, "ok_msg": "Step created" })),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct UpdateStepReq {
|
||||
#[validate(length(min = 1, max = 64))]
|
||||
pub step_code: Option<String>,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub action_kind: Option<String>,
|
||||
pub target_equipment_id: Option<Uuid>,
|
||||
pub target_station_id: Option<Uuid>,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub confirm_signal_role: Option<String>,
|
||||
pub confirm_point_id: Option<Uuid>,
|
||||
pub expected_value: Option<bool>,
|
||||
#[validate(range(min = 1))]
|
||||
pub timeout_ms: Option<i32>,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub command_role: Option<String>,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub stop_command_role: Option<String>,
|
||||
#[validate(range(min = 1))]
|
||||
pub pulse_ms: Option<i32>,
|
||||
pub hold_until_confirm: Option<bool>,
|
||||
pub cancel_on_fault: Option<bool>,
|
||||
pub next_step_no_on_success: Option<i32>,
|
||||
pub next_step_no_on_failure: Option<i32>,
|
||||
#[validate(length(min = 1, max = 16))]
|
||||
pub on_timeout: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_step(
|
||||
State(state): State<AppState>,
|
||||
Path((segment_id, step_no)): Path<(Uuid, i32)>,
|
||||
Json(payload): Json<UpdateStepReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
if let Some(a) = payload.action_kind.as_deref() {
|
||||
validate_enum("action_kind", a, ACTION_KINDS)?;
|
||||
}
|
||||
if let Some(t) = payload.on_timeout.as_deref() {
|
||||
validate_enum("on_timeout", t, ON_TIMEOUT_VALUES)?;
|
||||
}
|
||||
|
||||
segment_service::get_step(&state.platform.pool, segment_id, step_no)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Step not found".to_string(), None))?;
|
||||
|
||||
segment_service::update_step(
|
||||
&state.platform.pool,
|
||||
segment_id,
|
||||
step_no,
|
||||
segment_service::UpdateStepParams {
|
||||
step_code: payload.step_code.as_deref(),
|
||||
action_kind: payload.action_kind.as_deref(),
|
||||
target_equipment_id: payload.target_equipment_id,
|
||||
target_station_id: payload.target_station_id,
|
||||
confirm_signal_role: payload.confirm_signal_role.as_deref(),
|
||||
confirm_point_id: payload.confirm_point_id,
|
||||
expected_value: payload.expected_value,
|
||||
timeout_ms: payload.timeout_ms,
|
||||
command_role: payload.command_role.as_deref(),
|
||||
stop_command_role: payload.stop_command_role.as_deref(),
|
||||
pulse_ms: payload.pulse_ms,
|
||||
hold_until_confirm: payload.hold_until_confirm,
|
||||
cancel_on_fault: payload.cancel_on_fault,
|
||||
next_step_no_on_success: payload.next_step_no_on_success,
|
||||
next_step_no_on_failure: payload.next_step_no_on_failure,
|
||||
on_timeout: payload.on_timeout.as_deref(),
|
||||
description: payload.description.as_deref(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "ok_msg": "Step updated" })))
|
||||
}
|
||||
|
||||
pub async fn delete_step(
|
||||
State(state): State<AppState>,
|
||||
Path((segment_id, step_no)): Path<(Uuid, i32)>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let deleted = segment_service::delete_step(&state.platform.pool, segment_id, step_no).await?;
|
||||
if !deleted {
|
||||
return Err(ApiErr::NotFound("Step not found".to_string(), None));
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// Interlocks
|
||||
|
||||
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?;
|
||||
Ok(Json(interlocks))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct CreateInterlockReq {
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub applies_to: String,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub rule_kind: String,
|
||||
pub point_id: Option<Uuid>,
|
||||
pub station_id: Option<Uuid>,
|
||||
pub equipment_id: Option<Uuid>,
|
||||
pub expected_value: Option<bool>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_interlock(
|
||||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
Json(payload): Json<CreateInterlockReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
validate_enum("applies_to", &payload.applies_to, INTERLOCK_APPLIES_TO)?;
|
||||
validate_enum("rule_kind", &payload.rule_kind, RULE_KINDS)?;
|
||||
|
||||
segment_service::get_segment_by_id(&state.platform.pool, segment_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Segment not found".to_string(), None))?;
|
||||
|
||||
let interlock_id = segment_service::create_interlock(
|
||||
&state.platform.pool,
|
||||
segment_service::CreateInterlockParams {
|
||||
segment_id,
|
||||
applies_to: &payload.applies_to,
|
||||
rule_kind: &payload.rule_kind,
|
||||
point_id: payload.point_id,
|
||||
station_id: payload.station_id,
|
||||
equipment_id: payload.equipment_id,
|
||||
expected_value: payload.expected_value,
|
||||
description: payload.description.as_deref(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(json!({ "id": interlock_id, "ok_msg": "Interlock created" })),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_interlock(
|
||||
State(state): State<AppState>,
|
||||
Path((segment_id, interlock_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let deleted =
|
||||
segment_service::delete_interlock(&state.platform.pool, segment_id, interlock_id).await?;
|
||||
if !deleted {
|
||||
return Err(ApiErr::NotFound("Interlock not found".to_string(), None));
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// Resources (declared keys for a segment)
|
||||
|
||||
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?;
|
||||
Ok(Json(resources))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct ReplaceResourcesReq {
|
||||
pub resource_keys: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn replace_resources(
|
||||
State(state): State<AppState>,
|
||||
Path(segment_id): Path<Uuid>,
|
||||
Json(payload): Json<ReplaceResourcesReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
for key in &payload.resource_keys {
|
||||
if key.is_empty() || key.len() > 64 {
|
||||
return Err(ApiErr::BadRequest(
|
||||
"resource_key must be 1..=64 chars".to_string(),
|
||||
Some(json!({ "key": key })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
segment_service::get_segment_by_id(&state.platform.pool, segment_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Segment not found".to_string(), None))?;
|
||||
|
||||
segment_service::replace_resources(&state.platform.pool, segment_id, &payload.resource_keys)
|
||||
.await?;
|
||||
|
||||
Ok(Json(
|
||||
json!({ "ok_msg": "Resources replaced", "count": payload.resource_keys.len() }),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_enum_rejects_unknown() {
|
||||
assert!(validate_enum("mode", "weird", SEGMENT_MODES).is_err());
|
||||
assert!(validate_enum("mode", "auto", SEGMENT_MODES).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_segment_req_rejects_blank_code() {
|
||||
let payload = CreateSegmentReq {
|
||||
code: "".to_string(),
|
||||
name: "Dry-1 infeed".to_string(),
|
||||
segment_type: "kiln_infeed".to_string(),
|
||||
line_code: None,
|
||||
priority: None,
|
||||
enabled: None,
|
||||
mode: None,
|
||||
require_manual_ack_after_fault: None,
|
||||
description: None,
|
||||
};
|
||||
assert!(payload.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_step_req_rejects_zero_step_no() {
|
||||
let payload = CreateStepReq {
|
||||
step_no: 0,
|
||||
step_code: "S1".to_string(),
|
||||
action_kind: "open_door".to_string(),
|
||||
target_equipment_id: None,
|
||||
target_station_id: None,
|
||||
confirm_signal_role: None,
|
||||
confirm_point_id: None,
|
||||
expected_value: None,
|
||||
timeout_ms: None,
|
||||
command_role: None,
|
||||
stop_command_role: None,
|
||||
pulse_ms: None,
|
||||
hold_until_confirm: None,
|
||||
cancel_on_fault: None,
|
||||
next_step_no_on_success: None,
|
||||
next_step_no_on_failure: None,
|
||||
on_timeout: None,
|
||||
description: None,
|
||||
};
|
||||
assert!(payload.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
use plc_platform_core::util::response::ApiErr;
|
||||
|
||||
use crate::{service::station as station_service, AppState};
|
||||
|
||||
const STATION_TYPES: &[&str] = &[
|
||||
"load",
|
||||
"dry_in",
|
||||
"dry_step",
|
||||
"dry_out",
|
||||
"fire_in",
|
||||
"fire_step",
|
||||
"fire_out",
|
||||
"transfer",
|
||||
"unload",
|
||||
"return",
|
||||
];
|
||||
|
||||
const SIGNAL_ROLES: &[&str] = &[
|
||||
"presence", "vacancy", "arrived", "allow_in", "done", "fault",
|
||||
];
|
||||
|
||||
fn validate_station_type(value: &str) -> Result<(), ApiErr> {
|
||||
if STATION_TYPES.contains(&value) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiErr::BadRequest(
|
||||
format!("invalid station_type: {}", value),
|
||||
Some(json!({ "allowed": STATION_TYPES })),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_signal_role(value: &str) -> Result<(), ApiErr> {
|
||||
if SIGNAL_ROLES.contains(&value) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiErr::BadRequest(
|
||||
format!("invalid signal_role: {}", value),
|
||||
Some(json!({ "allowed": SIGNAL_ROLES })),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct ListStationQuery {
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub line_code: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_stations(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListStationQuery>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
query.validate()?;
|
||||
let stations =
|
||||
station_service::list_stations(&state.platform.pool, query.line_code.as_deref()).await?;
|
||||
Ok(Json(stations))
|
||||
}
|
||||
|
||||
pub async fn get_station(
|
||||
State(state): State<AppState>,
|
||||
Path(station_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
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?;
|
||||
Ok(Json(json!({
|
||||
"station": station,
|
||||
"signals": signals,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct CreateStationReq {
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub code: String,
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub name: String,
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub line_code: Option<String>,
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub segment_code: Option<String>,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub station_type: String,
|
||||
pub enabled: Option<bool>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_station(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateStationReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
validate_station_type(&payload.station_type)?;
|
||||
|
||||
if station_service::get_station_by_code(&state.platform.pool, &payload.code)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiErr::BadRequest(
|
||||
"Station code already exists".to_string(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
let station_id = station_service::create_station(
|
||||
&state.platform.pool,
|
||||
station_service::CreateStationParams {
|
||||
code: &payload.code,
|
||||
name: &payload.name,
|
||||
line_code: payload.line_code.as_deref(),
|
||||
segment_code: payload.segment_code.as_deref(),
|
||||
station_type: &payload.station_type,
|
||||
enabled: payload.enabled.unwrap_or(true),
|
||||
description: payload.description.as_deref(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(json!({ "id": station_id, "ok_msg": "Station created" })),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct UpdateStationReq {
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub code: Option<String>,
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub name: Option<String>,
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub line_code: Option<String>,
|
||||
#[validate(length(min = 1, max = 50))]
|
||||
pub segment_code: Option<String>,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub station_type: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_station(
|
||||
State(state): State<AppState>,
|
||||
Path(station_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateStationReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
if let Some(t) = payload.station_type.as_deref() {
|
||||
validate_station_type(t)?;
|
||||
}
|
||||
|
||||
let existing = station_service::get_station_by_id(&state.platform.pool, station_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Station not found".to_string(), None))?;
|
||||
|
||||
if let Some(code) = payload.code.as_deref() {
|
||||
if let Some(other) =
|
||||
station_service::get_station_by_code(&state.platform.pool, code).await?
|
||||
{
|
||||
if other.id != existing.id {
|
||||
return Err(ApiErr::BadRequest(
|
||||
"Station code already exists".to_string(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
station_service::update_station(
|
||||
&state.platform.pool,
|
||||
station_id,
|
||||
station_service::UpdateStationParams {
|
||||
code: payload.code.as_deref(),
|
||||
name: payload.name.as_deref(),
|
||||
line_code: payload.line_code.as_deref(),
|
||||
segment_code: payload.segment_code.as_deref(),
|
||||
station_type: payload.station_type.as_deref(),
|
||||
enabled: payload.enabled,
|
||||
description: payload.description.as_deref(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "ok_msg": "Station updated" })))
|
||||
}
|
||||
|
||||
pub async fn delete_station(
|
||||
State(state): State<AppState>,
|
||||
Path(station_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let deleted = station_service::delete_station(&state.platform.pool, station_id).await?;
|
||||
if !deleted {
|
||||
return Err(ApiErr::NotFound("Station not found".to_string(), None));
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct UpsertStationSignalReq {
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub signal_role: String,
|
||||
pub point_id: Option<Uuid>,
|
||||
#[validate(length(min = 1, max = 32))]
|
||||
pub derived_from_role: Option<String>,
|
||||
pub invert_value: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn upsert_station_signal(
|
||||
State(state): State<AppState>,
|
||||
Path(station_id): Path<Uuid>,
|
||||
Json(payload): Json<UpsertStationSignalReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
validate_signal_role(&payload.signal_role)?;
|
||||
if let Some(role) = payload.derived_from_role.as_deref() {
|
||||
validate_signal_role(role)?;
|
||||
}
|
||||
if payload.point_id.is_none() && payload.derived_from_role.is_none() {
|
||||
return Err(ApiErr::BadRequest(
|
||||
"either point_id or derived_from_role must be provided".to_string(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
station_service::get_station_by_id(&state.platform.pool, station_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiErr::NotFound("Station not found".to_string(), None))?;
|
||||
|
||||
let signal = station_service::upsert_station_signal(
|
||||
&state.platform.pool,
|
||||
station_id,
|
||||
station_service::UpsertStationSignalParams {
|
||||
signal_role: payload.signal_role,
|
||||
point_id: payload.point_id,
|
||||
derived_from_role: payload.derived_from_role,
|
||||
invert_value: payload.invert_value.unwrap_or(false),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(signal))
|
||||
}
|
||||
|
||||
pub async fn delete_station_signal(
|
||||
State(state): State<AppState>,
|
||||
Path((station_id, role)): Path<(Uuid, String)>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
validate_signal_role(&role)?;
|
||||
let deleted =
|
||||
station_service::delete_station_signal(&state.platform.pool, station_id, &role).await?;
|
||||
if !deleted {
|
||||
return Err(ApiErr::NotFound(
|
||||
"Station signal binding not found".to_string(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::StationSignalRole;
|
||||
|
||||
#[test]
|
||||
fn create_station_req_rejects_blank_code() {
|
||||
let payload = CreateStationReq {
|
||||
code: "".to_string(),
|
||||
name: "Dry-1 In".to_string(),
|
||||
line_code: None,
|
||||
segment_code: None,
|
||||
station_type: "dry_in".to_string(),
|
||||
enabled: None,
|
||||
description: None,
|
||||
};
|
||||
assert!(payload.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_station_type_rejects_unknown() {
|
||||
assert!(validate_station_type("nope").is_err());
|
||||
assert!(validate_station_type("dry_in").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn station_signal_role_enum_covers_handler_allowlist() {
|
||||
let known = [
|
||||
StationSignalRole::Presence,
|
||||
StationSignalRole::Vacancy,
|
||||
StationSignalRole::Arrived,
|
||||
StationSignalRole::AllowIn,
|
||||
StationSignalRole::Done,
|
||||
StationSignalRole::Fault,
|
||||
];
|
||||
for role in known {
|
||||
assert!(SIGNAL_ROLES.contains(&role.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
pub mod app;
|
||||
pub mod control;
|
||||
pub mod event;
|
||||
pub mod handler;
|
||||
pub mod model;
|
||||
pub mod router;
|
||||
pub mod seed;
|
||||
pub mod service;
|
||||
|
||||
pub use app::{run, test_state, AppState};
|
||||
pub use router::build_router;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,292 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use plc_platform_core::util::datetime::utc_to_local_str;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Station (design doc §4.2.1)
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Station {
|
||||
pub id: Uuid,
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub line_code: Option<String>,
|
||||
pub segment_code: Option<String>,
|
||||
pub station_type: String,
|
||||
pub enabled: bool,
|
||||
pub description: Option<String>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StationType {
|
||||
Load,
|
||||
DryIn,
|
||||
DryStep,
|
||||
DryOut,
|
||||
FireIn,
|
||||
FireStep,
|
||||
FireOut,
|
||||
Transfer,
|
||||
Unload,
|
||||
Return,
|
||||
}
|
||||
|
||||
impl StationType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
StationType::Load => "load",
|
||||
StationType::DryIn => "dry_in",
|
||||
StationType::DryStep => "dry_step",
|
||||
StationType::DryOut => "dry_out",
|
||||
StationType::FireIn => "fire_in",
|
||||
StationType::FireStep => "fire_step",
|
||||
StationType::FireOut => "fire_out",
|
||||
StationType::Transfer => "transfer",
|
||||
StationType::Unload => "unload",
|
||||
StationType::Return => "return",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StationSignal (design doc §4.2.2)
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct StationSignal {
|
||||
pub id: Uuid,
|
||||
pub station_id: Uuid,
|
||||
pub signal_role: String,
|
||||
pub point_id: Option<Uuid>,
|
||||
pub derived_from_role: Option<String>,
|
||||
pub invert_value: bool,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StationSignalRole {
|
||||
Presence,
|
||||
Vacancy,
|
||||
Arrived,
|
||||
AllowIn,
|
||||
Done,
|
||||
Fault,
|
||||
}
|
||||
|
||||
impl StationSignalRole {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
StationSignalRole::Presence => "presence",
|
||||
StationSignalRole::Vacancy => "vacancy",
|
||||
StationSignalRole::Arrived => "arrived",
|
||||
StationSignalRole::AllowIn => "allow_in",
|
||||
StationSignalRole::Done => "done",
|
||||
StationSignalRole::Fault => "fault",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessSegment (design doc §4.2.3)
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct ProcessSegment {
|
||||
pub id: Uuid,
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub segment_type: String,
|
||||
pub line_code: Option<String>,
|
||||
pub priority: i32,
|
||||
pub enabled: bool,
|
||||
pub mode: String,
|
||||
pub require_manual_ack_after_fault: bool,
|
||||
pub description: Option<String>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SegmentMode {
|
||||
Auto,
|
||||
RemoteManual,
|
||||
LocalManual,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl SegmentMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
SegmentMode::Auto => "auto",
|
||||
SegmentMode::RemoteManual => "remote_manual",
|
||||
SegmentMode::LocalManual => "local_manual",
|
||||
SegmentMode::Disabled => "disabled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SegmentStep (design doc §4.2.4)
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct SegmentStep {
|
||||
pub id: Uuid,
|
||||
pub segment_id: Uuid,
|
||||
pub step_no: i32,
|
||||
pub step_code: String,
|
||||
pub action_kind: String,
|
||||
pub target_equipment_id: Option<Uuid>,
|
||||
pub target_station_id: Option<Uuid>,
|
||||
pub confirm_signal_role: Option<String>,
|
||||
pub confirm_point_id: Option<Uuid>,
|
||||
pub expected_value: bool,
|
||||
pub timeout_ms: i32,
|
||||
pub command_role: Option<String>,
|
||||
pub stop_command_role: Option<String>,
|
||||
pub pulse_ms: Option<i32>,
|
||||
pub hold_until_confirm: bool,
|
||||
pub cancel_on_fault: bool,
|
||||
pub next_step_no_on_success: Option<i32>,
|
||||
pub next_step_no_on_failure: Option<i32>,
|
||||
pub on_timeout: String,
|
||||
pub description: Option<String>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ActionKind {
|
||||
OpenDoor,
|
||||
CloseDoor,
|
||||
PushForward,
|
||||
PushRetract,
|
||||
PullRun,
|
||||
PullRetract,
|
||||
TransferMoveTo,
|
||||
StepOnce,
|
||||
RobotPermit,
|
||||
RobotRelease,
|
||||
WaitSignal,
|
||||
PulseCmd,
|
||||
}
|
||||
|
||||
impl ActionKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ActionKind::OpenDoor => "open_door",
|
||||
ActionKind::CloseDoor => "close_door",
|
||||
ActionKind::PushForward => "push_forward",
|
||||
ActionKind::PushRetract => "push_retract",
|
||||
ActionKind::PullRun => "pull_run",
|
||||
ActionKind::PullRetract => "pull_retract",
|
||||
ActionKind::TransferMoveTo => "transfer_move_to",
|
||||
ActionKind::StepOnce => "step_once",
|
||||
ActionKind::RobotPermit => "robot_permit",
|
||||
ActionKind::RobotRelease => "robot_release",
|
||||
ActionKind::WaitSignal => "wait_signal",
|
||||
ActionKind::PulseCmd => "pulse_cmd",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OnTimeout {
|
||||
Fault,
|
||||
Retry,
|
||||
Block,
|
||||
}
|
||||
|
||||
impl OnTimeout {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
OnTimeout::Fault => "fault",
|
||||
OnTimeout::Retry => "retry",
|
||||
OnTimeout::Block => "block",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SegmentInterlock (design doc §4.2.5)
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct SegmentInterlock {
|
||||
pub id: Uuid,
|
||||
pub segment_id: Uuid,
|
||||
pub applies_to: String,
|
||||
pub rule_kind: String,
|
||||
pub point_id: Option<Uuid>,
|
||||
pub station_id: Option<Uuid>,
|
||||
pub equipment_id: Option<Uuid>,
|
||||
pub expected_value: Option<bool>,
|
||||
pub description: Option<String>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InterlockAppliesTo {
|
||||
StartAllow,
|
||||
StartDeny,
|
||||
RunHalt,
|
||||
}
|
||||
|
||||
impl InterlockAppliesTo {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
InterlockAppliesTo::StartAllow => "start_allow",
|
||||
InterlockAppliesTo::StartDeny => "start_deny",
|
||||
InterlockAppliesTo::RunHalt => "run_halt",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuleKind {
|
||||
PointEq,
|
||||
StationVacant,
|
||||
StationOccupied,
|
||||
EquipmentOrigin,
|
||||
EquipmentNoFault,
|
||||
EquipmentRemote,
|
||||
SafetyChainOk,
|
||||
}
|
||||
|
||||
impl RuleKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
RuleKind::PointEq => "point_eq",
|
||||
RuleKind::StationVacant => "station_vacant",
|
||||
RuleKind::StationOccupied => "station_occupied",
|
||||
RuleKind::EquipmentOrigin => "equipment_origin",
|
||||
RuleKind::EquipmentNoFault => "equipment_no_fault",
|
||||
RuleKind::EquipmentRemote => "equipment_remote",
|
||||
RuleKind::SafetyChainOk => "safety_chain_ok",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SegmentResource (design doc §4.2.7)
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct SegmentResource {
|
||||
pub segment_id: Uuid,
|
||||
pub resource_key: String,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
|
@ -1,37 +1,145 @@
|
|||
use axum::{extract::State, routing::get, Router};
|
||||
use tower_http::services::ServeDir;
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::{get, post, put},
|
||||
Router,
|
||||
};
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
async fn no_cache(
|
||||
req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
let mut response = next.run(req).await;
|
||||
response.headers_mut().insert(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
axum::http::HeaderValue::from_static("no-store"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
// Platform routes (source, point, equipment, tag, page, logs) from core.
|
||||
let platform = plc_platform_core::handler::platform_routes::<AppState>();
|
||||
|
||||
// Ops configuration routes (design doc §9.1).
|
||||
let config_routes = Router::new()
|
||||
.route(
|
||||
"/api/station",
|
||||
get(crate::handler::station::list_stations)
|
||||
.post(crate::handler::station::create_station),
|
||||
)
|
||||
.route(
|
||||
"/api/station/{station_id}",
|
||||
get(crate::handler::station::get_station)
|
||||
.put(crate::handler::station::update_station)
|
||||
.delete(crate::handler::station::delete_station),
|
||||
)
|
||||
.route(
|
||||
"/api/station/{station_id}/signal",
|
||||
post(crate::handler::station::upsert_station_signal),
|
||||
)
|
||||
.route(
|
||||
"/api/station/{station_id}/signal/{role}",
|
||||
axum::routing::delete(crate::handler::station::delete_station_signal),
|
||||
)
|
||||
.route(
|
||||
"/api/segment",
|
||||
get(crate::handler::segment::list_segments)
|
||||
.post(crate::handler::segment::create_segment),
|
||||
)
|
||||
.route(
|
||||
"/api/segment/{segment_id}",
|
||||
get(crate::handler::segment::get_segment)
|
||||
.put(crate::handler::segment::update_segment)
|
||||
.delete(crate::handler::segment::delete_segment),
|
||||
)
|
||||
.route(
|
||||
"/api/segment/{segment_id}/detail",
|
||||
get(crate::handler::segment::get_segment_detail),
|
||||
)
|
||||
.route(
|
||||
"/api/segment/{segment_id}/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),
|
||||
)
|
||||
.route(
|
||||
"/api/segment/{segment_id}/interlock",
|
||||
get(crate::handler::segment::list_interlocks)
|
||||
.post(crate::handler::segment::create_interlock),
|
||||
)
|
||||
.route(
|
||||
"/api/segment/{segment_id}/interlock/{interlock_id}",
|
||||
axum::routing::delete(crate::handler::segment::delete_interlock),
|
||||
)
|
||||
.route(
|
||||
"/api/segment/{segment_id}/resource",
|
||||
get(crate::handler::segment::list_resources)
|
||||
.put(crate::handler::segment::replace_resources),
|
||||
);
|
||||
|
||||
let control_routes = Router::new()
|
||||
.route(
|
||||
"/api/control/segment/{segment_id}/start-auto",
|
||||
post(crate::handler::control::start_auto_segment),
|
||||
)
|
||||
.route(
|
||||
"/api/control/segment/{segment_id}/stop-auto",
|
||||
post(crate::handler::control::stop_auto_segment),
|
||||
)
|
||||
.route(
|
||||
"/api/control/segment/{segment_id}/ack-fault",
|
||||
post(crate::handler::control::ack_fault_segment),
|
||||
)
|
||||
.route(
|
||||
"/api/control/segment/{segment_id}/reset",
|
||||
post(crate::handler::control::reset_segment),
|
||||
)
|
||||
.route(
|
||||
"/api/control/segment/batch-start-auto",
|
||||
post(crate::handler::control::batch_start_auto),
|
||||
)
|
||||
.route(
|
||||
"/api/control/segment/batch-stop-auto",
|
||||
post(crate::handler::control::batch_stop_auto),
|
||||
);
|
||||
|
||||
let runtime_routes = Router::new()
|
||||
.route(
|
||||
"/api/runtime/overview",
|
||||
get(crate::handler::runtime::get_overview),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/segment/{segment_id}",
|
||||
get(crate::handler::runtime::get_segment_runtime),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/station/{station_id}",
|
||||
get(crate::handler::runtime::get_station_runtime),
|
||||
)
|
||||
.route("/api/event", get(crate::handler::event::get_event_list));
|
||||
|
||||
let ops_routes = Router::new()
|
||||
.route("/api/health", get(health_check))
|
||||
.route("/api/logs", get(plc_platform_core::handler::log::get_logs))
|
||||
.route("/api/logs/stream", get(plc_platform_core::handler::log::stream_logs))
|
||||
.route("/api/docs/api-md", get(crate::handler::doc::get_api_md))
|
||||
.route("/api/docs/readme-md", get(crate::handler::doc::get_readme_md))
|
||||
.route(
|
||||
"/api/docs/readme-md",
|
||||
get(crate::handler::doc::get_readme_md),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.merge(platform)
|
||||
.merge(config_routes)
|
||||
.merge(control_routes)
|
||||
.merge(runtime_routes)
|
||||
.merge(ops_routes)
|
||||
.nest(
|
||||
"/ui",
|
||||
Router::new()
|
||||
.fallback_service(
|
||||
ServeDir::new("web/ops")
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(ServeDir::new("web/core")),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(no_cache)),
|
||||
plc_platform_core::http::static_ui_routes("web/ops", "web/core"),
|
||||
)
|
||||
.route(
|
||||
"/ws/public",
|
||||
get(plc_platform_core::websocket::public_websocket_handler::<AppState>),
|
||||
)
|
||||
.route(
|
||||
"/ws/client/{client_id}",
|
||||
get(plc_platform_core::websocket::client_websocket_handler::<AppState>),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(
|
||||
plc_platform_core::http::simple_logger,
|
||||
))
|
||||
.layer(plc_platform_core::http::permissive_cors())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,594 @@
|
|||
//! Default segment templates (design doc §12 P5 + P7).
|
||||
//!
|
||||
//! Idempotently inserts the skeleton stations, segments, steps, and shared
|
||||
//! resource declarations operators need before they can wire equipment and
|
||||
//! signal bindings through the CRUD APIs. Re-running is a no-op once codes
|
||||
//! exist.
|
||||
//!
|
||||
//! Coverage:
|
||||
//!
|
||||
//! - 6 dry-kiln segments (infeed / step / outfeed × 2 kilns).
|
||||
//! - 6 public segments (front load / front release / front transfer /
|
||||
//! tail transfer / unload / return) wiring kiln 1 + kiln 2 with shared
|
||||
//! resource keys (`transfer_front`, `transfer_tail`, `unload_position`,
|
||||
//! `return_line`, `robot_arm`).
|
||||
//!
|
||||
//! Equipment / station-signal bindings stay operator-managed because the
|
||||
//! field config differs per site.
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// Insert kiln + public segment templates. Returns counts so callers can log.
|
||||
pub async fn ensure_default_templates(pool: &PgPool) -> Result<TemplateReport, sqlx::Error> {
|
||||
let mut report = TemplateReport::default();
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
for station in default_template_stations() {
|
||||
let inserted = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO station (code, name, line_code, segment_code, station_type, enabled, description)
|
||||
VALUES ($1, $2, $3, $4, $5, TRUE, $6)
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(station.code)
|
||||
.bind(station.name)
|
||||
.bind(station.line_code)
|
||||
.bind(station.segment_code)
|
||||
.bind(station.station_type)
|
||||
.bind(station.description)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
report.stations_inserted += inserted.rows_affected();
|
||||
}
|
||||
|
||||
for segment in default_template_segments() {
|
||||
let inserted = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO process_segment (
|
||||
code, name, segment_type, line_code, priority,
|
||||
enabled, mode, require_manual_ack_after_fault, description
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, TRUE, $6, TRUE, $7)
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(segment.code)
|
||||
.bind(segment.name)
|
||||
.bind(segment.segment_type)
|
||||
.bind(segment.line_code)
|
||||
.bind(segment.priority)
|
||||
.bind(segment.mode)
|
||||
.bind(segment.description)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
report.segments_inserted += inserted.rows_affected();
|
||||
|
||||
let segment_id: Option<uuid::Uuid> =
|
||||
sqlx::query_scalar(r#"SELECT id FROM process_segment WHERE code = $1"#)
|
||||
.bind(segment.code)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(segment_id) = segment_id else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Resource declarations are idempotent at the row level (UNIQUE
|
||||
// constraint). Insert each declared key.
|
||||
for resource_key in default_segment_resources(segment.code) {
|
||||
let inserted = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO segment_resource (segment_id, resource_key)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(segment_id)
|
||||
.bind(resource_key)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
report.resources_inserted += inserted.rows_affected();
|
||||
}
|
||||
|
||||
// Only seed steps for an empty segment. Once an operator owns the
|
||||
// step list, the seed mustn't trample it.
|
||||
let existing_step_count: i64 =
|
||||
sqlx::query_scalar(r#"SELECT COUNT(*) FROM segment_step WHERE segment_id = $1"#)
|
||||
.bind(segment_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if existing_step_count > 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
for step in build_step_template(segment.segment_type) {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO segment_step (
|
||||
segment_id, step_no, step_code, action_kind,
|
||||
confirm_signal_role, expected_value, timeout_ms,
|
||||
hold_until_confirm, cancel_on_fault, on_timeout, description
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, TRUE, $6, FALSE, TRUE, 'fault', $7)
|
||||
"#,
|
||||
)
|
||||
.bind(segment_id)
|
||||
.bind(step.step_no)
|
||||
.bind(step.step_code)
|
||||
.bind(step.action_kind)
|
||||
.bind(step.confirm_signal_role)
|
||||
.bind(step.timeout_ms)
|
||||
.bind(step.description)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
report.steps_inserted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TemplateReport {
|
||||
pub stations_inserted: u64,
|
||||
pub segments_inserted: u64,
|
||||
pub steps_inserted: u64,
|
||||
pub resources_inserted: u64,
|
||||
}
|
||||
|
||||
struct StationTemplate {
|
||||
code: &'static str,
|
||||
name: &'static str,
|
||||
line_code: &'static str,
|
||||
segment_code: &'static str,
|
||||
station_type: &'static str,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
fn default_template_stations() -> Vec<StationTemplate> {
|
||||
let mut out = Vec::new();
|
||||
out.extend(kiln_template_stations());
|
||||
out.extend(public_template_stations());
|
||||
out
|
||||
}
|
||||
|
||||
fn kiln_template_stations() -> Vec<StationTemplate> {
|
||||
let dry: &[(&'static str, &'static str, &'static str, &'static str)] = &[
|
||||
// (line, code, name, station_type)
|
||||
("KILN_1", "ST-DRY1-IN", "1 号干燥窑进口位", "dry_in"),
|
||||
("KILN_1", "ST-DRY1-STEP", "1 号干燥窑内位", "dry_step"),
|
||||
("KILN_1", "ST-DRY1-OUT", "1 号干燥窑出口位", "dry_out"),
|
||||
("KILN_2", "ST-DRY2-IN", "2 号干燥窑进口位", "dry_in"),
|
||||
("KILN_2", "ST-DRY2-STEP", "2 号干燥窑内位", "dry_step"),
|
||||
("KILN_2", "ST-DRY2-OUT", "2 号干燥窑出口位", "dry_out"),
|
||||
];
|
||||
dry.iter()
|
||||
.map(|(line, code, name, station_type)| StationTemplate {
|
||||
code,
|
||||
name,
|
||||
line_code: line,
|
||||
segment_code: match *station_type {
|
||||
"dry_in" => "DRY_INFEED",
|
||||
"dry_step" => "DRY_STEP",
|
||||
"dry_out" => "DRY_OUTFEED",
|
||||
_ => "DRY",
|
||||
},
|
||||
station_type,
|
||||
description: "Seeded by ensure_default_templates",
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
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-UNLOAD", "卸砖机位", "UNLOAD", "unload"),
|
||||
("ST-RETURN-IN", "回车线入口位", "RETURN", "return"),
|
||||
];
|
||||
stations
|
||||
.iter()
|
||||
.map(|(code, name, segment_code, station_type)| StationTemplate {
|
||||
code,
|
||||
name,
|
||||
line_code: "COMMON",
|
||||
segment_code,
|
||||
station_type,
|
||||
description: "Seeded by ensure_default_templates",
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct SegmentTemplate {
|
||||
code: &'static str,
|
||||
name: &'static str,
|
||||
segment_type: &'static str,
|
||||
line_code: &'static str,
|
||||
priority: i32,
|
||||
mode: &'static str,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
fn default_template_segments() -> Vec<SegmentTemplate> {
|
||||
let mut out = Vec::new();
|
||||
out.extend(kiln_template_segments());
|
||||
out.extend(public_template_segments());
|
||||
out
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
];
|
||||
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.",
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn public_template_segments() -> Vec<SegmentTemplate> {
|
||||
let entries: &[(&'static str, &'static str, &'static str, i32)] = &[
|
||||
// (code, name, segment_type, priority)
|
||||
("SEG-FRONT-LOAD", "前端码车位进车段", "front_load", 10),
|
||||
("SEG-FRONT-RELEASE", "前端码车位放车段", "front_release", 10),
|
||||
("SEG-FRONT-TRANSFER", "前端摆渡分配段", "front_transfer", 8),
|
||||
("SEG-TAIL-TRANSFER", "窑尾摆渡接车段", "tail_transfer", 8),
|
||||
("SEG-UNLOAD", "卸砖机位段", "unload", 6),
|
||||
("SEG-RETURN", "回车线入口段", "return", 4),
|
||||
];
|
||||
entries
|
||||
.iter()
|
||||
.map(|(code, name, segment_type, priority)| SegmentTemplate {
|
||||
code,
|
||||
name,
|
||||
segment_type,
|
||||
line_code: "COMMON",
|
||||
priority: *priority,
|
||||
mode: "disabled",
|
||||
description: "Seeded skeleton; bind equipment + station signals to enable.",
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Shared-resource keys per public segment (design doc §7).
|
||||
/// Returns `[]` for segments that don't claim a public resource.
|
||||
pub fn default_segment_resources(segment_code: &str) -> Vec<&'static str> {
|
||||
match segment_code {
|
||||
"SEG-FRONT-LOAD" | "SEG-FRONT-RELEASE" => vec!["robot_arm"],
|
||||
"SEG-FRONT-TRANSFER" => vec!["transfer_front"],
|
||||
"SEG-TAIL-TRANSFER" => vec!["transfer_tail"],
|
||||
"SEG-UNLOAD" => vec!["unload_position"],
|
||||
"SEG-RETURN" => vec!["return_line"],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StepTemplate {
|
||||
pub step_no: i32,
|
||||
pub step_code: &'static str,
|
||||
pub action_kind: &'static str,
|
||||
pub confirm_signal_role: &'static str,
|
||||
pub timeout_ms: i32,
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
/// Per-segment-type canonical step list (design doc §7.4 / §10).
|
||||
pub fn build_step_template(segment_type: &str) -> Vec<StepTemplate> {
|
||||
match segment_type {
|
||||
"kiln_infeed" => vec![
|
||||
StepTemplate {
|
||||
step_no: 1,
|
||||
step_code: "OPEN_DOOR",
|
||||
action_kind: "open_door",
|
||||
confirm_signal_role: "allow_in",
|
||||
timeout_ms: 15_000,
|
||||
description: "开门并等待门开到位",
|
||||
},
|
||||
StepTemplate {
|
||||
step_no: 2,
|
||||
step_code: "PUSH_FORWARD",
|
||||
action_kind: "push_forward",
|
||||
confirm_signal_role: "arrived",
|
||||
timeout_ms: 30_000,
|
||||
description: "顶车进窑并等待到位",
|
||||
},
|
||||
StepTemplate {
|
||||
step_no: 3,
|
||||
step_code: "PUSH_RETRACT",
|
||||
action_kind: "push_retract",
|
||||
confirm_signal_role: "done",
|
||||
timeout_ms: 30_000,
|
||||
description: "顶车后退复位",
|
||||
},
|
||||
StepTemplate {
|
||||
step_no: 4,
|
||||
step_code: "CLOSE_DOOR",
|
||||
action_kind: "close_door",
|
||||
confirm_signal_role: "done",
|
||||
timeout_ms: 15_000,
|
||||
description: "关门并等待门关到位",
|
||||
},
|
||||
],
|
||||
"kiln_step" => vec![StepTemplate {
|
||||
step_no: 1,
|
||||
step_code: "STEP_ONCE",
|
||||
action_kind: "step_once",
|
||||
confirm_signal_role: "arrived",
|
||||
timeout_ms: 30_000,
|
||||
description: "步进一格并等待到位",
|
||||
}],
|
||||
"kiln_outfeed" => vec![
|
||||
StepTemplate {
|
||||
step_no: 1,
|
||||
step_code: "OPEN_DOOR",
|
||||
action_kind: "open_door",
|
||||
confirm_signal_role: "allow_in",
|
||||
timeout_ms: 15_000,
|
||||
description: "开门并等待门开到位",
|
||||
},
|
||||
StepTemplate {
|
||||
step_no: 2,
|
||||
step_code: "PULL_RUN",
|
||||
action_kind: "pull_run",
|
||||
confirm_signal_role: "arrived",
|
||||
timeout_ms: 30_000,
|
||||
description: "拉引出窑并等待到位",
|
||||
},
|
||||
StepTemplate {
|
||||
step_no: 3,
|
||||
step_code: "PULL_RETRACT",
|
||||
action_kind: "pull_retract",
|
||||
confirm_signal_role: "done",
|
||||
timeout_ms: 30_000,
|
||||
description: "拉引复位",
|
||||
},
|
||||
StepTemplate {
|
||||
step_no: 4,
|
||||
step_code: "CLOSE_DOOR",
|
||||
action_kind: "close_door",
|
||||
confirm_signal_role: "done",
|
||||
timeout_ms: 15_000,
|
||||
description: "关门并等待门关到位",
|
||||
},
|
||||
],
|
||||
"front_load" => vec![
|
||||
StepTemplate {
|
||||
step_no: 1,
|
||||
step_code: "ROBOT_PERMIT",
|
||||
action_kind: "robot_permit",
|
||||
confirm_signal_role: "done",
|
||||
timeout_ms: 30_000,
|
||||
description: "允许机械臂码坯并等待完成",
|
||||
},
|
||||
StepTemplate {
|
||||
step_no: 2,
|
||||
step_code: "WAIT_RELEASE_READY",
|
||||
action_kind: "wait_signal",
|
||||
confirm_signal_role: "arrived",
|
||||
timeout_ms: 60_000,
|
||||
description: "等待码车位放车确认",
|
||||
},
|
||||
],
|
||||
"front_release" => vec![StepTemplate {
|
||||
step_no: 1,
|
||||
step_code: "ROBOT_RELEASE",
|
||||
action_kind: "robot_release",
|
||||
confirm_signal_role: "done",
|
||||
timeout_ms: 30_000,
|
||||
description: "码车放车并等待完成",
|
||||
}],
|
||||
"front_transfer" | "tail_transfer" => vec![
|
||||
StepTemplate {
|
||||
step_no: 1,
|
||||
step_code: "MOVE_TO_TARGET",
|
||||
action_kind: "transfer_move_to",
|
||||
confirm_signal_role: "arrived",
|
||||
timeout_ms: 60_000,
|
||||
description: "摆渡车定位到目标工位",
|
||||
},
|
||||
StepTemplate {
|
||||
step_no: 2,
|
||||
step_code: "WAIT_HANDOFF",
|
||||
action_kind: "wait_signal",
|
||||
confirm_signal_role: "done",
|
||||
timeout_ms: 60_000,
|
||||
description: "等待上下游交接完成",
|
||||
},
|
||||
],
|
||||
"unload" => vec![StepTemplate {
|
||||
step_no: 1,
|
||||
step_code: "STEP_ONCE",
|
||||
action_kind: "step_once",
|
||||
confirm_signal_role: "arrived",
|
||||
timeout_ms: 30_000,
|
||||
description: "卸砖位步进一格并等待到位",
|
||||
}],
|
||||
"return" => vec![StepTemplate {
|
||||
step_no: 1,
|
||||
step_code: "STEP_ONCE",
|
||||
action_kind: "step_once",
|
||||
confirm_signal_role: "arrived",
|
||||
timeout_ms: 30_000,
|
||||
description: "回车线步进一格并等待到位",
|
||||
}],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the OPS_SEED_TEMPLATES env opts the deployment into automatic seeding.
|
||||
pub fn enabled_via_env() -> bool {
|
||||
matches!(
|
||||
std::env::var("OPS_SEED_TEMPLATES").ok().as_deref(),
|
||||
Some("true") | Some("1")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn infeed_template_has_four_sequential_steps() {
|
||||
let steps = build_step_template("kiln_infeed");
|
||||
assert_eq!(steps.len(), 4);
|
||||
let nos: Vec<i32> = steps.iter().map(|s| s.step_no).collect();
|
||||
assert_eq!(nos, vec![1, 2, 3, 4]);
|
||||
assert_eq!(steps[0].action_kind, "open_door");
|
||||
assert_eq!(steps[3].action_kind, "close_door");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_template_has_single_step() {
|
||||
let steps = build_step_template("kiln_step");
|
||||
assert_eq!(steps.len(), 1);
|
||||
assert_eq!(steps[0].action_kind, "step_once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outfeed_template_uses_pull_actions() {
|
||||
let steps = build_step_template("kiln_outfeed");
|
||||
assert!(steps.iter().any(|s| s.action_kind == "pull_run"));
|
||||
assert!(steps.iter().any(|s| s.action_kind == "pull_retract"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_segments_use_move_to_action() {
|
||||
let steps = build_step_template("front_transfer");
|
||||
assert!(steps.iter().any(|s| s.action_kind == "transfer_move_to"));
|
||||
let steps = build_step_template("tail_transfer");
|
||||
assert!(steps.iter().any(|s| s.action_kind == "transfer_move_to"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_segment_type_yields_empty_template() {
|
||||
assert!(build_step_template("unknown").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn station_template_covers_kilns_and_public() {
|
||||
let stations = default_template_stations();
|
||||
assert_eq!(stations.len(), 6 + 5);
|
||||
assert!(stations.iter().any(|s| s.code == "ST-DRY1-IN"));
|
||||
assert!(stations.iter().any(|s| s.code == "ST-FRONT-LOAD"));
|
||||
assert!(stations.iter().any(|s| s.code == "ST-RETURN-IN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_template_covers_kilns_and_public() {
|
||||
let segments = default_template_segments();
|
||||
assert_eq!(segments.len(), 6 + 6);
|
||||
let kiln_1 = segments.iter().filter(|s| s.line_code == "KILN_1").count();
|
||||
let kiln_2 = segments.iter().filter(|s| s.line_code == "KILN_2").count();
|
||||
let common = segments.iter().filter(|s| s.line_code == "COMMON").count();
|
||||
assert_eq!(kiln_1, 3);
|
||||
assert_eq!(kiln_2, 3);
|
||||
assert_eq!(common, 6);
|
||||
}
|
||||
|
||||
#[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-RETURN"), vec!["return_line"]);
|
||||
assert_eq!(
|
||||
default_segment_resources("SEG-FRONT-LOAD"),
|
||||
vec!["robot_arm"]
|
||||
);
|
||||
assert!(default_segment_resources("SEG-DRY1-INFEED").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_via_env_respects_flag() {
|
||||
let prev = std::env::var("OPS_SEED_TEMPLATES").ok();
|
||||
|
||||
std::env::remove_var("OPS_SEED_TEMPLATES");
|
||||
assert!(!enabled_via_env());
|
||||
|
||||
std::env::set_var("OPS_SEED_TEMPLATES", "1");
|
||||
assert!(enabled_via_env());
|
||||
|
||||
std::env::set_var("OPS_SEED_TEMPLATES", "no");
|
||||
assert!(!enabled_via_env());
|
||||
|
||||
match prev {
|
||||
Some(v) => std::env::set_var("OPS_SEED_TEMPLATES", v),
|
||||
None => std::env::remove_var("OPS_SEED_TEMPLATES"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
pub mod segment;
|
||||
pub mod station;
|
||||
|
|
@ -0,0 +1,439 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::{ProcessSegment, SegmentInterlock, SegmentResource, SegmentStep};
|
||||
|
||||
// process_segment
|
||||
|
||||
pub async fn list_segments(
|
||||
pool: &PgPool,
|
||||
line_code: Option<&str>,
|
||||
) -> Result<Vec<ProcessSegment>, sqlx::Error> {
|
||||
match line_code {
|
||||
Some(line) => sqlx::query_as::<_, ProcessSegment>(
|
||||
r#"SELECT * FROM process_segment WHERE line_code = $1 ORDER BY priority DESC, code"#,
|
||||
)
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_segment_by_id(
|
||||
pool: &PgPool,
|
||||
segment_id: Uuid,
|
||||
) -> Result<Option<ProcessSegment>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ProcessSegment>(r#"SELECT * FROM process_segment WHERE id = $1"#)
|
||||
.bind(segment_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_segment_by_code(
|
||||
pool: &PgPool,
|
||||
code: &str,
|
||||
) -> Result<Option<ProcessSegment>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ProcessSegment>(r#"SELECT * FROM process_segment WHERE code = $1"#)
|
||||
.bind(code)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub struct CreateSegmentParams<'a> {
|
||||
pub code: &'a str,
|
||||
pub name: &'a str,
|
||||
pub segment_type: &'a str,
|
||||
pub line_code: Option<&'a str>,
|
||||
pub priority: i32,
|
||||
pub enabled: bool,
|
||||
pub mode: &'a str,
|
||||
pub require_manual_ack_after_fault: bool,
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn create_segment(
|
||||
pool: &PgPool,
|
||||
params: CreateSegmentParams<'_>,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let segment_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO process_segment (
|
||||
id, code, name, segment_type, line_code, priority,
|
||||
enabled, mode, require_manual_ack_after_fault, description
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(segment_id)
|
||||
.bind(params.code)
|
||||
.bind(params.name)
|
||||
.bind(params.segment_type)
|
||||
.bind(params.line_code)
|
||||
.bind(params.priority)
|
||||
.bind(params.enabled)
|
||||
.bind(params.mode)
|
||||
.bind(params.require_manual_ack_after_fault)
|
||||
.bind(params.description)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(segment_id)
|
||||
}
|
||||
|
||||
pub struct UpdateSegmentParams<'a> {
|
||||
pub code: Option<&'a str>,
|
||||
pub name: Option<&'a str>,
|
||||
pub segment_type: Option<&'a str>,
|
||||
pub line_code: Option<&'a str>,
|
||||
pub priority: Option<i32>,
|
||||
pub enabled: Option<bool>,
|
||||
pub mode: Option<&'a str>,
|
||||
pub require_manual_ack_after_fault: Option<bool>,
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn update_segment(
|
||||
pool: &PgPool,
|
||||
segment_id: Uuid,
|
||||
params: UpdateSegmentParams<'_>,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE process_segment SET
|
||||
code = COALESCE($2, code),
|
||||
name = COALESCE($3, name),
|
||||
segment_type = COALESCE($4, segment_type),
|
||||
line_code = COALESCE($5, line_code),
|
||||
priority = COALESCE($6, priority),
|
||||
enabled = COALESCE($7, enabled),
|
||||
mode = COALESCE($8, mode),
|
||||
require_manual_ack_after_fault = COALESCE($9, require_manual_ack_after_fault),
|
||||
description = COALESCE($10, description),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(segment_id)
|
||||
.bind(params.code)
|
||||
.bind(params.name)
|
||||
.bind(params.segment_type)
|
||||
.bind(params.line_code)
|
||||
.bind(params.priority)
|
||||
.bind(params.enabled)
|
||||
.bind(params.mode)
|
||||
.bind(params.require_manual_ack_after_fault)
|
||||
.bind(params.description)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn delete_segment(pool: &PgPool, segment_id: Uuid) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query(r#"DELETE FROM process_segment WHERE id = $1"#)
|
||||
.bind(segment_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
// segment_step
|
||||
|
||||
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"#,
|
||||
)
|
||||
.bind(segment_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_step(
|
||||
pool: &PgPool,
|
||||
segment_id: Uuid,
|
||||
step_no: i32,
|
||||
) -> Result<Option<SegmentStep>, sqlx::Error> {
|
||||
sqlx::query_as::<_, SegmentStep>(
|
||||
r#"SELECT * FROM segment_step WHERE segment_id = $1 AND step_no = $2"#,
|
||||
)
|
||||
.bind(segment_id)
|
||||
.bind(step_no)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub struct CreateStepParams<'a> {
|
||||
pub segment_id: Uuid,
|
||||
pub step_no: i32,
|
||||
pub step_code: &'a str,
|
||||
pub action_kind: &'a str,
|
||||
pub target_equipment_id: Option<Uuid>,
|
||||
pub target_station_id: Option<Uuid>,
|
||||
pub confirm_signal_role: Option<&'a str>,
|
||||
pub confirm_point_id: Option<Uuid>,
|
||||
pub expected_value: bool,
|
||||
pub timeout_ms: i32,
|
||||
pub command_role: Option<&'a str>,
|
||||
pub stop_command_role: Option<&'a str>,
|
||||
pub pulse_ms: Option<i32>,
|
||||
pub hold_until_confirm: bool,
|
||||
pub cancel_on_fault: bool,
|
||||
pub next_step_no_on_success: Option<i32>,
|
||||
pub next_step_no_on_failure: Option<i32>,
|
||||
pub on_timeout: &'a str,
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn create_step(pool: &PgPool, params: CreateStepParams<'_>) -> Result<Uuid, sqlx::Error> {
|
||||
let step_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO segment_step (
|
||||
id, segment_id, step_no, step_code, action_kind,
|
||||
target_equipment_id, target_station_id,
|
||||
confirm_signal_role, confirm_point_id, expected_value,
|
||||
timeout_ms, command_role, stop_command_role, pulse_ms,
|
||||
hold_until_confirm, cancel_on_fault,
|
||||
next_step_no_on_success, next_step_no_on_failure,
|
||||
on_timeout, description
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7,
|
||||
$8, $9, $10,
|
||||
$11, $12, $13, $14,
|
||||
$15, $16,
|
||||
$17, $18,
|
||||
$19, $20
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(step_id)
|
||||
.bind(params.segment_id)
|
||||
.bind(params.step_no)
|
||||
.bind(params.step_code)
|
||||
.bind(params.action_kind)
|
||||
.bind(params.target_equipment_id)
|
||||
.bind(params.target_station_id)
|
||||
.bind(params.confirm_signal_role)
|
||||
.bind(params.confirm_point_id)
|
||||
.bind(params.expected_value)
|
||||
.bind(params.timeout_ms)
|
||||
.bind(params.command_role)
|
||||
.bind(params.stop_command_role)
|
||||
.bind(params.pulse_ms)
|
||||
.bind(params.hold_until_confirm)
|
||||
.bind(params.cancel_on_fault)
|
||||
.bind(params.next_step_no_on_success)
|
||||
.bind(params.next_step_no_on_failure)
|
||||
.bind(params.on_timeout)
|
||||
.bind(params.description)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(step_id)
|
||||
}
|
||||
|
||||
pub struct UpdateStepParams<'a> {
|
||||
pub step_code: Option<&'a str>,
|
||||
pub action_kind: Option<&'a str>,
|
||||
pub target_equipment_id: Option<Uuid>,
|
||||
pub target_station_id: Option<Uuid>,
|
||||
pub confirm_signal_role: Option<&'a str>,
|
||||
pub confirm_point_id: Option<Uuid>,
|
||||
pub expected_value: Option<bool>,
|
||||
pub timeout_ms: Option<i32>,
|
||||
pub command_role: Option<&'a str>,
|
||||
pub stop_command_role: Option<&'a str>,
|
||||
pub pulse_ms: Option<i32>,
|
||||
pub hold_until_confirm: Option<bool>,
|
||||
pub cancel_on_fault: Option<bool>,
|
||||
pub next_step_no_on_success: Option<i32>,
|
||||
pub next_step_no_on_failure: Option<i32>,
|
||||
pub on_timeout: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn update_step(
|
||||
pool: &PgPool,
|
||||
segment_id: Uuid,
|
||||
step_no: i32,
|
||||
params: UpdateStepParams<'_>,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE segment_step SET
|
||||
step_code = COALESCE($3, step_code),
|
||||
action_kind = COALESCE($4, action_kind),
|
||||
target_equipment_id = COALESCE($5, target_equipment_id),
|
||||
target_station_id = COALESCE($6, target_station_id),
|
||||
confirm_signal_role = COALESCE($7, confirm_signal_role),
|
||||
confirm_point_id = COALESCE($8, confirm_point_id),
|
||||
expected_value = COALESCE($9, expected_value),
|
||||
timeout_ms = COALESCE($10, timeout_ms),
|
||||
command_role = COALESCE($11, command_role),
|
||||
stop_command_role = COALESCE($12, stop_command_role),
|
||||
pulse_ms = COALESCE($13, pulse_ms),
|
||||
hold_until_confirm = COALESCE($14, hold_until_confirm),
|
||||
cancel_on_fault = COALESCE($15, cancel_on_fault),
|
||||
next_step_no_on_success = COALESCE($16, next_step_no_on_success),
|
||||
next_step_no_on_failure = COALESCE($17, next_step_no_on_failure),
|
||||
on_timeout = COALESCE($18, on_timeout),
|
||||
description = COALESCE($19, description),
|
||||
updated_at = NOW()
|
||||
WHERE segment_id = $1 AND step_no = $2
|
||||
"#,
|
||||
)
|
||||
.bind(segment_id)
|
||||
.bind(step_no)
|
||||
.bind(params.step_code)
|
||||
.bind(params.action_kind)
|
||||
.bind(params.target_equipment_id)
|
||||
.bind(params.target_station_id)
|
||||
.bind(params.confirm_signal_role)
|
||||
.bind(params.confirm_point_id)
|
||||
.bind(params.expected_value)
|
||||
.bind(params.timeout_ms)
|
||||
.bind(params.command_role)
|
||||
.bind(params.stop_command_role)
|
||||
.bind(params.pulse_ms)
|
||||
.bind(params.hold_until_confirm)
|
||||
.bind(params.cancel_on_fault)
|
||||
.bind(params.next_step_no_on_success)
|
||||
.bind(params.next_step_no_on_failure)
|
||||
.bind(params.on_timeout)
|
||||
.bind(params.description)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn delete_step(
|
||||
pool: &PgPool,
|
||||
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?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
// segment_interlock
|
||||
|
||||
pub async fn list_interlocks(
|
||||
pool: &PgPool,
|
||||
segment_id: Uuid,
|
||||
) -> Result<Vec<SegmentInterlock>, sqlx::Error> {
|
||||
sqlx::query_as::<_, SegmentInterlock>(
|
||||
r#"SELECT * FROM segment_interlock WHERE segment_id = $1 ORDER BY applies_to, id"#,
|
||||
)
|
||||
.bind(segment_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub struct CreateInterlockParams<'a> {
|
||||
pub segment_id: Uuid,
|
||||
pub applies_to: &'a str,
|
||||
pub rule_kind: &'a str,
|
||||
pub point_id: Option<Uuid>,
|
||||
pub station_id: Option<Uuid>,
|
||||
pub equipment_id: Option<Uuid>,
|
||||
pub expected_value: Option<bool>,
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn create_interlock(
|
||||
pool: &PgPool,
|
||||
params: CreateInterlockParams<'_>,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let interlock_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO segment_interlock (
|
||||
id, segment_id, applies_to, rule_kind,
|
||||
point_id, station_id, equipment_id,
|
||||
expected_value, description
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
"#,
|
||||
)
|
||||
.bind(interlock_id)
|
||||
.bind(params.segment_id)
|
||||
.bind(params.applies_to)
|
||||
.bind(params.rule_kind)
|
||||
.bind(params.point_id)
|
||||
.bind(params.station_id)
|
||||
.bind(params.equipment_id)
|
||||
.bind(params.expected_value)
|
||||
.bind(params.description)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(interlock_id)
|
||||
}
|
||||
|
||||
pub async fn delete_interlock(
|
||||
pool: &PgPool,
|
||||
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?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
// segment_resource
|
||||
|
||||
pub async fn list_resources(
|
||||
pool: &PgPool,
|
||||
segment_id: Uuid,
|
||||
) -> Result<Vec<SegmentResource>, sqlx::Error> {
|
||||
sqlx::query_as::<_, SegmentResource>(
|
||||
r#"SELECT * FROM segment_resource WHERE segment_id = $1 ORDER BY resource_key"#,
|
||||
)
|
||||
.bind(segment_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Replace the entire resource set for a segment.
|
||||
/// Empty `keys` clears all resources for the segment.
|
||||
pub async fn replace_resources(
|
||||
pool: &PgPool,
|
||||
segment_id: Uuid,
|
||||
keys: &[String],
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query(r#"DELETE FROM segment_resource WHERE segment_id = $1"#)
|
||||
.bind(segment_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for key in keys {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO segment_resource (segment_id, resource_key)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(segment_id)
|
||||
.bind(key)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::{Station, StationSignal};
|
||||
|
||||
pub async fn list_stations(
|
||||
pool: &PgPool,
|
||||
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"#)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_station_by_id(
|
||||
pool: &PgPool,
|
||||
station_id: Uuid,
|
||||
) -> Result<Option<Station>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Station>(r#"SELECT * FROM station WHERE id = $1"#)
|
||||
.bind(station_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_station_by_code(
|
||||
pool: &PgPool,
|
||||
code: &str,
|
||||
) -> Result<Option<Station>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Station>(r#"SELECT * FROM station WHERE code = $1"#)
|
||||
.bind(code)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub struct CreateStationParams<'a> {
|
||||
pub code: &'a str,
|
||||
pub name: &'a str,
|
||||
pub line_code: Option<&'a str>,
|
||||
pub segment_code: Option<&'a str>,
|
||||
pub station_type: &'a str,
|
||||
pub enabled: bool,
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn create_station(
|
||||
pool: &PgPool,
|
||||
params: CreateStationParams<'_>,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let station_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO station (
|
||||
id, code, name, line_code, segment_code, station_type, enabled, description
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
"#,
|
||||
)
|
||||
.bind(station_id)
|
||||
.bind(params.code)
|
||||
.bind(params.name)
|
||||
.bind(params.line_code)
|
||||
.bind(params.segment_code)
|
||||
.bind(params.station_type)
|
||||
.bind(params.enabled)
|
||||
.bind(params.description)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(station_id)
|
||||
}
|
||||
|
||||
pub struct UpdateStationParams<'a> {
|
||||
pub code: Option<&'a str>,
|
||||
pub name: Option<&'a str>,
|
||||
pub line_code: Option<&'a str>,
|
||||
pub segment_code: Option<&'a str>,
|
||||
pub station_type: Option<&'a str>,
|
||||
pub enabled: Option<bool>,
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn update_station(
|
||||
pool: &PgPool,
|
||||
station_id: Uuid,
|
||||
params: UpdateStationParams<'_>,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE station SET
|
||||
code = COALESCE($2, code),
|
||||
name = COALESCE($3, name),
|
||||
line_code = COALESCE($4, line_code),
|
||||
segment_code = COALESCE($5, segment_code),
|
||||
station_type = COALESCE($6, station_type),
|
||||
enabled = COALESCE($7, enabled),
|
||||
description = COALESCE($8, description),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(station_id)
|
||||
.bind(params.code)
|
||||
.bind(params.name)
|
||||
.bind(params.line_code)
|
||||
.bind(params.segment_code)
|
||||
.bind(params.station_type)
|
||||
.bind(params.enabled)
|
||||
.bind(params.description)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn delete_station(pool: &PgPool, station_id: Uuid) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query(r#"DELETE FROM station WHERE id = $1"#)
|
||||
.bind(station_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn list_station_signals(
|
||||
pool: &PgPool,
|
||||
station_id: Uuid,
|
||||
) -> Result<Vec<StationSignal>, sqlx::Error> {
|
||||
sqlx::query_as::<_, StationSignal>(
|
||||
r#"SELECT * FROM station_signal WHERE station_id = $1 ORDER BY signal_role"#,
|
||||
)
|
||||
.bind(station_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub struct UpsertStationSignalParams {
|
||||
pub signal_role: String,
|
||||
pub point_id: Option<Uuid>,
|
||||
pub derived_from_role: Option<String>,
|
||||
pub invert_value: bool,
|
||||
}
|
||||
|
||||
pub async fn upsert_station_signal(
|
||||
pool: &PgPool,
|
||||
station_id: Uuid,
|
||||
params: UpsertStationSignalParams,
|
||||
) -> Result<StationSignal, sqlx::Error> {
|
||||
sqlx::query_as::<_, StationSignal>(
|
||||
r#"
|
||||
INSERT INTO station_signal (
|
||||
station_id, signal_role, point_id, derived_from_role, invert_value
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (station_id, signal_role) DO UPDATE SET
|
||||
point_id = EXCLUDED.point_id,
|
||||
derived_from_role = EXCLUDED.derived_from_role,
|
||||
invert_value = EXCLUDED.invert_value,
|
||||
updated_at = NOW()
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(station_id)
|
||||
.bind(¶ms.signal_role)
|
||||
.bind(params.point_id)
|
||||
.bind(¶ms.derived_from_role)
|
||||
.bind(params.invert_value)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_station_signal(
|
||||
pool: &PgPool,
|
||||
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?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
|
@ -4,11 +4,13 @@ use axum::{
|
|||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn build_app() -> axum::Router {
|
||||
app_operation_system::build_router(app_operation_system::app::test_state())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn operation_system_router_exposes_health_endpoint() {
|
||||
let app = app_operation_system::build_router(app_operation_system::app::test_state());
|
||||
|
||||
let response = app
|
||||
let response = build_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
|
|
@ -21,3 +23,89 @@ async fn operation_system_router_exposes_health_endpoint() {
|
|||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
/// Verify the station collection route is registered (DELETE on the collection
|
||||
/// isn't a real method, so axum should answer METHOD_NOT_ALLOWED, not 404).
|
||||
#[tokio::test]
|
||||
async fn operation_system_router_exposes_station_collection() {
|
||||
let response = build_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.uri("/api/station")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should answer request");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn operation_system_router_exposes_segment_collection() {
|
||||
let response = build_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.uri("/api/segment")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should answer request");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
/// Runtime overview is GET-only; a POST should be METHOD_NOT_ALLOWED rather
|
||||
/// than 404 — proving the route is registered.
|
||||
#[tokio::test]
|
||||
async fn operation_system_router_exposes_runtime_overview() {
|
||||
let response = build_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/runtime/overview")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should answer request");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
/// Control endpoints are POST-only; GETting one should be METHOD_NOT_ALLOWED.
|
||||
#[tokio::test]
|
||||
async fn operation_system_router_exposes_control_batch_routes() {
|
||||
let response = build_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/control/segment/batch-start-auto")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should answer request");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
/// Event timeline endpoint is GET-only — POST should be METHOD_NOT_ALLOWED.
|
||||
#[tokio::test]
|
||||
async fn operation_system_router_exposes_event_timeline() {
|
||||
let response = build_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/event")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should answer request");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::config::ServerConfig;
|
||||
use crate::connection::ConnectionManager;
|
||||
use crate::db::init_database;
|
||||
use crate::platform_context::PlatformContext;
|
||||
use crate::telemetry_processor::TelemetryProcessor;
|
||||
use crate::util::single_instance::SingleInstanceGuard;
|
||||
use crate::websocket::WebSocketManager;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub struct PlatformBuilder {
|
||||
pub pool: sqlx::PgPool,
|
||||
|
|
@ -12,7 +16,22 @@ pub struct PlatformBuilder {
|
|||
}
|
||||
|
||||
impl PlatformBuilder {
|
||||
pub fn build(self) -> PlatformContext {
|
||||
/// Finalize the platform: wire up telemetry processing, reconnect task,
|
||||
/// and wrap everything into `PlatformContext`.
|
||||
pub fn build(mut self) -> PlatformContext {
|
||||
// Telemetry processor: handles PointNewValue batching, monitor updates, WS broadcast.
|
||||
let cm_for_telemetry = Arc::new(self.connection_manager.clone());
|
||||
let telemetry_processor = Arc::new(TelemetryProcessor::new(
|
||||
cm_for_telemetry,
|
||||
self.ws_manager.clone(),
|
||||
));
|
||||
self.connection_manager
|
||||
.set_event_manager(telemetry_processor);
|
||||
|
||||
// Start reconnect task (auto-reconnects on connection loss).
|
||||
self.connection_manager
|
||||
.set_pool_and_start_reconnect_task(Arc::new(self.pool.clone()));
|
||||
|
||||
PlatformContext::new(
|
||||
self.pool,
|
||||
Arc::new(self.connection_manager),
|
||||
|
|
@ -35,3 +54,102 @@ pub async fn bootstrap_platform(database_url: &str) -> Result<PlatformBuilder, S
|
|||
ws_manager,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn connect_all_enabled_sources(platform: &PlatformContext) -> Result<(), String> {
|
||||
let sources = crate::service::get_all_enabled_sources(&platform.pool)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch sources: {}", e))?;
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for source in sources {
|
||||
let cm = platform.connection_manager.clone();
|
||||
let pool = platform.pool.clone();
|
||||
let source_name = source.name.clone();
|
||||
let source_id = source.id;
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
if let Err(err) = cm.connect_from_source(&pool, source_id).await {
|
||||
tracing::error!("Failed to connect to source {}: {}", source_name, err);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
if let Err(err) = task.await {
|
||||
tracing::error!("Source connection task failed: {:?}", err);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn init_process(
|
||||
single_instance_name: &str,
|
||||
duplicate_instance_message: &str,
|
||||
) -> Option<SingleInstanceGuard> {
|
||||
dotenv::dotenv().ok();
|
||||
crate::util::log::init_logger();
|
||||
|
||||
match crate::util::single_instance::try_acquire(single_instance_name) {
|
||||
Ok(guard) => Some(guard),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
tracing::warn!("{}", duplicate_instance_message);
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to initialize single instance guard: {}", err);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn serve_app(
|
||||
config: &ServerConfig,
|
||||
app_name: &str,
|
||||
app: axum::Router,
|
||||
) -> std::io::Result<()> {
|
||||
let addr = config.addr();
|
||||
tracing::info!("Starting {} server at http://{}", app_name, addr);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
axum::serve(listener, app).await
|
||||
}
|
||||
|
||||
pub async fn serve_app_with_graceful_shutdown<F>(
|
||||
config: &ServerConfig,
|
||||
app_name: &str,
|
||||
app: axum::Router,
|
||||
shutdown_signal: F,
|
||||
) -> std::io::Result<()>
|
||||
where
|
||||
F: std::future::Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
let addr = config.addr();
|
||||
tracing::info!("Starting {} server at http://{}", app_name, addr);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn install_ctrl_c_shutdown(shutdown_tx: mpsc::Sender<()>) {
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to install Ctrl+C handler");
|
||||
let _ = shutdown_tx.send(()).await;
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn disconnect_all_on_shutdown(
|
||||
mut shutdown_rx: mpsc::Receiver<()>,
|
||||
connection_manager: Arc<ConnectionManager>,
|
||||
app_name: &'static str,
|
||||
) {
|
||||
let _ = shutdown_rx.recv().await;
|
||||
tracing::info!(
|
||||
"Received shutdown signal, closing all {} connections...",
|
||||
app_name
|
||||
);
|
||||
connection_manager.disconnect_all().await;
|
||||
tracing::info!("All {} connections closed", app_name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
use std::env;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ServerConfig {
|
||||
pub database_url: String,
|
||||
pub server_host: String,
|
||||
pub server_port: u16,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
pub fn from_env(
|
||||
host_key: &str,
|
||||
host_default: &str,
|
||||
port_key: &str,
|
||||
port_default: u16,
|
||||
) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
database_url: required_env("DATABASE_URL")?,
|
||||
server_host: env_string(host_key, host_default),
|
||||
server_port: env_u16(port_key, port_default)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn addr(&self) -> String {
|
||||
format!("{}:{}", self.server_host, self.server_port)
|
||||
}
|
||||
|
||||
pub fn local_ui_url(&self) -> String {
|
||||
format!("http://{}:{}/ui", "localhost", self.server_port)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn required_env(key: &str) -> Result<String, String> {
|
||||
env::var(key).map_err(|_| format!("Missing environment variable: {}", key))
|
||||
}
|
||||
|
||||
pub fn env_string(key: &str, default: &str) -> String {
|
||||
env::var(key).unwrap_or_else(|_| default.to_string())
|
||||
}
|
||||
|
||||
pub fn env_u16(key: &str, default: u16) -> Result<u16, String> {
|
||||
match env::var(key) {
|
||||
Ok(value) => value
|
||||
.parse::<u16>()
|
||||
.map_err(|_| format!("{} must be a number", key)),
|
||||
Err(_) => Ok(default),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn env_bool(key: &str, default: bool) -> bool {
|
||||
env::var(key)
|
||||
.map(|value| {
|
||||
matches!(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
})
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
|
@ -1,2 +1 @@
|
|||
pub mod command;
|
||||
pub mod runtime;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,65 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::EventRecord;
|
||||
use crate::websocket::{WebSocketManager, WsMessage};
|
||||
|
||||
/// In-memory cache for unit/equipment `code` fields used in event messages.
|
||||
/// Lazily populated on first access; entries are invalidated when the
|
||||
/// corresponding row is updated or deleted (see invalidate_* methods).
|
||||
#[derive(Default)]
|
||||
pub struct MetadataCache {
|
||||
unit_codes: RwLock<HashMap<Uuid, String>>,
|
||||
equipment_codes: RwLock<HashMap<Uuid, String>>,
|
||||
}
|
||||
|
||||
impl MetadataCache {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub async fn unit_code(&self, pool: &sqlx::PgPool, id: Uuid) -> String {
|
||||
if let Some(code) = self.unit_codes.read().await.get(&id) {
|
||||
return code.clone();
|
||||
}
|
||||
let code = sqlx::query_scalar::<_, String>("SELECT code FROM unit WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| id.to_string());
|
||||
self.unit_codes.write().await.insert(id, code.clone());
|
||||
code
|
||||
}
|
||||
|
||||
pub async fn equipment_code(&self, pool: &sqlx::PgPool, id: Uuid) -> String {
|
||||
if let Some(code) = self.equipment_codes.read().await.get(&id) {
|
||||
return code.clone();
|
||||
}
|
||||
let code = sqlx::query_scalar::<_, String>("SELECT code FROM equipment WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| id.to_string());
|
||||
self.equipment_codes.write().await.insert(id, code.clone());
|
||||
code
|
||||
}
|
||||
|
||||
pub async fn invalidate_unit(&self, id: Uuid) {
|
||||
self.unit_codes.write().await.remove(&id);
|
||||
}
|
||||
|
||||
pub async fn invalidate_equipment(&self, id: Uuid) {
|
||||
self.equipment_codes.write().await.remove(&id);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EventEnvelope {
|
||||
|
|
@ -15,3 +75,198 @@ impl EventEnvelope {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-level events emitted by core handlers.
|
||||
/// Each variant carries enough context for persistence and platform side effects.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PlatformEvent {
|
||||
SourceCreated {
|
||||
source_id: Uuid,
|
||||
},
|
||||
SourceUpdated {
|
||||
source_id: Uuid,
|
||||
},
|
||||
SourceDeleted {
|
||||
source_id: Uuid,
|
||||
source_name: String,
|
||||
},
|
||||
PointsCreated {
|
||||
source_id: Uuid,
|
||||
point_ids: Vec<Uuid>,
|
||||
},
|
||||
PointsDeleted {
|
||||
source_id: Uuid,
|
||||
point_ids: Vec<Uuid>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Platform-owned row for the `event` table.
|
||||
/// Apps construct this to write business events through [`record_event`].
|
||||
pub struct EventInsert {
|
||||
pub event_type: &'static str,
|
||||
pub level: &'static str,
|
||||
pub unit_id: Option<Uuid>,
|
||||
pub equipment_id: Option<Uuid>,
|
||||
pub source_id: Option<Uuid>,
|
||||
/// Generic owner-type tag (e.g. "segment" / "station") used by ops business
|
||||
/// events. Design doc §4.2.8 attribution columns.
|
||||
pub subject_type: Option<&'static str>,
|
||||
pub subject_id: Option<Uuid>,
|
||||
pub message: String,
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
/// Inserts an event into the `event` table and optionally broadcasts via WebSocket.
|
||||
/// This is the platform primitive used by both core platform events and app business events.
|
||||
pub async fn record_event(
|
||||
pool: &sqlx::PgPool,
|
||||
ws_manager: Option<&WebSocketManager>,
|
||||
event: EventInsert,
|
||||
) {
|
||||
let event_type = event.event_type;
|
||||
|
||||
match event.level {
|
||||
"error" => tracing::error!(event_type, "{}", event.message),
|
||||
"warn" => tracing::warn!(event_type, "{}", event.message),
|
||||
_ => tracing::info!(event_type, "{}", event.message),
|
||||
}
|
||||
|
||||
let envelope = EventEnvelope::new(event_type, event.payload);
|
||||
|
||||
let inserted = sqlx::query_as::<_, EventRecord>(
|
||||
r#"
|
||||
INSERT INTO event (
|
||||
event_type, level, unit_id, equipment_id, source_id,
|
||||
subject_type, subject_id, message, payload
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(envelope.event_type)
|
||||
.bind(event.level)
|
||||
.bind(event.unit_id)
|
||||
.bind(event.equipment_id)
|
||||
.bind(event.source_id)
|
||||
.bind(event.subject_type)
|
||||
.bind(event.subject_id)
|
||||
.bind(event.message)
|
||||
.bind(sqlx::types::Json(envelope.payload))
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
|
||||
match inserted {
|
||||
Ok(record) => {
|
||||
if let Some(ws_manager) = ws_manager {
|
||||
let ws_message = WsMessage::EventCreated(record);
|
||||
if let Err(err) = ws_manager.send_to_public(ws_message).await {
|
||||
tracing::warn!("Failed to broadcast event {}: {}", event_type, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to persist event {}: {}", event_type, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates a PlatformEvent to an EventInsert and delegates to record_event.
|
||||
pub async fn record_platform_event(
|
||||
event: &PlatformEvent,
|
||||
pool: &sqlx::PgPool,
|
||||
ws_manager: &WebSocketManager,
|
||||
) {
|
||||
let record = match event {
|
||||
PlatformEvent::SourceCreated { source_id } => {
|
||||
let name = fetch_source_name(pool, *source_id).await;
|
||||
Some(EventInsert {
|
||||
event_type: "platform.source.created",
|
||||
level: "info",
|
||||
unit_id: None,
|
||||
equipment_id: None,
|
||||
source_id: Some(*source_id),
|
||||
subject_type: Some("source"),
|
||||
subject_id: Some(*source_id),
|
||||
message: format!("Source {} created", name),
|
||||
payload: serde_json::json!({ "source_id": source_id }),
|
||||
})
|
||||
}
|
||||
PlatformEvent::SourceUpdated { source_id } => {
|
||||
let name = fetch_source_name(pool, *source_id).await;
|
||||
Some(EventInsert {
|
||||
event_type: "platform.source.updated",
|
||||
level: "info",
|
||||
unit_id: None,
|
||||
equipment_id: None,
|
||||
source_id: Some(*source_id),
|
||||
subject_type: Some("source"),
|
||||
subject_id: Some(*source_id),
|
||||
message: format!("Source {} updated", name),
|
||||
payload: serde_json::json!({ "source_id": source_id }),
|
||||
})
|
||||
}
|
||||
PlatformEvent::SourceDeleted {
|
||||
source_id,
|
||||
source_name,
|
||||
} => Some(EventInsert {
|
||||
event_type: "platform.source.deleted",
|
||||
level: "warn",
|
||||
unit_id: None,
|
||||
equipment_id: None,
|
||||
source_id: None,
|
||||
subject_type: Some("source"),
|
||||
subject_id: Some(*source_id),
|
||||
message: format!("Source {} deleted", source_name),
|
||||
payload: serde_json::json!({ "source_id": source_id }),
|
||||
}),
|
||||
PlatformEvent::PointsCreated {
|
||||
source_id,
|
||||
point_ids,
|
||||
} => {
|
||||
let name = fetch_source_name(pool, *source_id).await;
|
||||
Some(EventInsert {
|
||||
event_type: "platform.point.batch_created",
|
||||
level: "info",
|
||||
unit_id: None,
|
||||
equipment_id: None,
|
||||
source_id: Some(*source_id),
|
||||
subject_type: Some("source"),
|
||||
subject_id: Some(*source_id),
|
||||
message: format!("Created {} points for source {}", point_ids.len(), name),
|
||||
payload: serde_json::json!({ "source_id": source_id, "point_ids": point_ids }),
|
||||
})
|
||||
}
|
||||
PlatformEvent::PointsDeleted {
|
||||
source_id,
|
||||
point_ids,
|
||||
} => {
|
||||
let name = fetch_source_name(pool, *source_id).await;
|
||||
Some(EventInsert {
|
||||
event_type: "platform.point.batch_deleted",
|
||||
level: "warn",
|
||||
unit_id: None,
|
||||
equipment_id: None,
|
||||
source_id: Some(*source_id),
|
||||
subject_type: Some("source"),
|
||||
subject_id: Some(*source_id),
|
||||
message: format!("Deleted {} points for source {}", point_ids.len(), name),
|
||||
payload: serde_json::json!({ "source_id": source_id, "point_ids": point_ids }),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let Some(record) = record else {
|
||||
return;
|
||||
};
|
||||
record_event(pool, Some(ws_manager), record).await;
|
||||
}
|
||||
|
||||
async fn fetch_source_name(pool: &sqlx::PgPool, id: Uuid) -> String {
|
||||
sqlx::query_scalar::<_, String>("SELECT name FROM source WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,10 @@
|
|||
pub mod doc;
|
||||
pub mod equipment;
|
||||
pub mod log;
|
||||
pub mod page;
|
||||
pub mod point;
|
||||
pub mod router;
|
||||
pub mod source;
|
||||
pub mod tag;
|
||||
|
||||
pub use router::platform_routes;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use axum::{
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
|
|
@ -8,22 +8,17 @@ use serde::{Deserialize, Serialize};
|
|||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
use plc_platform_core::util::{
|
||||
use crate::platform_context::PlatformContext;
|
||||
use crate::util::{
|
||||
pagination::{PaginatedResponse, PaginationParams},
|
||||
response::ApiErr,
|
||||
};
|
||||
use crate::AppState;
|
||||
|
||||
async fn notify_units(
|
||||
state: &AppState,
|
||||
unit_ids: impl IntoIterator<Item = Uuid>,
|
||||
) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for unit_id in unit_ids {
|
||||
if seen.insert(unit_id) {
|
||||
state.control_runtime.notify_unit(unit_id).await;
|
||||
}
|
||||
}
|
||||
async fn unit_row_exists(pool: &sqlx::PgPool, unit_id: Uuid) -> Result<bool, sqlx::Error> {
|
||||
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM unit WHERE id = $1)")
|
||||
.bind(unit_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
|
|
@ -44,20 +39,20 @@ pub struct SignalRolePoint {
|
|||
#[derive(Serialize)]
|
||||
pub struct EquipmentListItem {
|
||||
#[serde(flatten)]
|
||||
pub equipment: plc_platform_core::model::Equipment,
|
||||
pub equipment: crate::model::Equipment,
|
||||
pub point_count: i64,
|
||||
pub role_points: Vec<SignalRolePoint>,
|
||||
}
|
||||
|
||||
pub async fn get_equipment_list(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Query(query): Query<GetEquipmentListQuery>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
query.validate()?;
|
||||
|
||||
let total = crate::service::get_equipment_count(&state.platform.pool, query.keyword.as_deref()).await?;
|
||||
let total = crate::service::get_equipment_count(&state.pool, query.keyword.as_deref()).await?;
|
||||
let items = crate::service::get_equipment_paginated(
|
||||
&state.platform.pool,
|
||||
&state.pool,
|
||||
query.keyword.as_deref(),
|
||||
query.pagination.page_size,
|
||||
query.pagination.offset(),
|
||||
|
|
@ -66,10 +61,10 @@ pub async fn get_equipment_list(
|
|||
|
||||
let equipment_ids: Vec<uuid::Uuid> = items.iter().map(|item| item.equipment.id).collect();
|
||||
let role_point_rows =
|
||||
crate::service::get_signal_role_points_batch(&state.platform.pool, &equipment_ids).await?;
|
||||
crate::service::get_signal_role_points_batch(&state.pool, &equipment_ids).await?;
|
||||
|
||||
let monitor_guard = state
|
||||
.platform.connection_manager
|
||||
.connection_manager
|
||||
.get_point_monitor_data_read_guard()
|
||||
.await;
|
||||
|
||||
|
|
@ -107,10 +102,10 @@ pub async fn get_equipment_list(
|
|||
}
|
||||
|
||||
pub async fn get_equipment(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(equipment_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let equipment = crate::service::get_equipment_by_id(&state.platform.pool, equipment_id).await?;
|
||||
let equipment = crate::service::get_equipment_by_id(&state.pool, equipment_id).await?;
|
||||
|
||||
match equipment {
|
||||
Some(item) => Ok(Json(item)),
|
||||
|
|
@ -119,15 +114,15 @@ pub async fn get_equipment(
|
|||
}
|
||||
|
||||
pub async fn get_equipment_points(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(equipment_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let exists = crate::service::get_equipment_by_id(&state.platform.pool, equipment_id).await?;
|
||||
let exists = crate::service::get_equipment_by_id(&state.pool, equipment_id).await?;
|
||||
if exists.is_none() {
|
||||
return Err(ApiErr::NotFound("Equipment not found".to_string(), None));
|
||||
}
|
||||
|
||||
let points = crate::service::get_points_by_equipment_id(&state.platform.pool, equipment_id).await?;
|
||||
let points = crate::service::get_points_by_equipment_id(&state.pool, equipment_id).await?;
|
||||
Ok(Json(points))
|
||||
}
|
||||
|
||||
|
|
@ -160,12 +155,12 @@ pub struct BatchSetEquipmentUnitReq {
|
|||
}
|
||||
|
||||
pub async fn create_equipment(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Json(payload): Json<CreateEquipmentReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
|
||||
let exists = crate::service::get_equipment_by_code(&state.platform.pool, &payload.code).await?;
|
||||
let exists = crate::service::get_equipment_by_code(&state.pool, &payload.code).await?;
|
||||
if exists.is_some() {
|
||||
return Err(ApiErr::BadRequest(
|
||||
"Equipment code already exists".to_string(),
|
||||
|
|
@ -174,14 +169,14 @@ pub async fn create_equipment(
|
|||
}
|
||||
|
||||
if let Some(unit_id) = payload.unit_id {
|
||||
let unit_exists = crate::service::get_unit_by_id(&state.platform.pool, unit_id).await?;
|
||||
if unit_exists.is_none() {
|
||||
let unit_exists = unit_row_exists(&state.pool, unit_id).await?;
|
||||
if !unit_exists {
|
||||
return Err(ApiErr::NotFound("Unit not found".to_string(), None));
|
||||
}
|
||||
}
|
||||
|
||||
let equipment_id = crate::service::create_equipment(
|
||||
&state.platform.pool,
|
||||
&state.pool,
|
||||
payload.unit_id,
|
||||
&payload.code,
|
||||
&payload.name,
|
||||
|
|
@ -190,10 +185,6 @@ pub async fn create_equipment(
|
|||
)
|
||||
.await?;
|
||||
|
||||
if let Some(unit_id) = payload.unit_id {
|
||||
notify_units(&state, [unit_id]).await;
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(serde_json::json!({
|
||||
|
|
@ -204,7 +195,7 @@ pub async fn create_equipment(
|
|||
}
|
||||
|
||||
pub async fn update_equipment(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(equipment_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateEquipmentReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
|
|
@ -219,22 +210,22 @@ pub async fn update_equipment(
|
|||
return Ok(Json(serde_json::json!({"ok_msg": "No fields to update"})));
|
||||
}
|
||||
|
||||
let exists = crate::service::get_equipment_by_id(&state.platform.pool, equipment_id).await?;
|
||||
let existing_equipment = if let Some(equipment) = exists {
|
||||
equipment
|
||||
} else {
|
||||
if crate::service::get_equipment_by_id(&state.pool, equipment_id)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(ApiErr::NotFound("Equipment not found".to_string(), None));
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(Some(unit_id)) = payload.unit_id {
|
||||
let unit_exists = crate::service::get_unit_by_id(&state.platform.pool, unit_id).await?;
|
||||
if unit_exists.is_none() {
|
||||
let unit_exists = unit_row_exists(&state.pool, unit_id).await?;
|
||||
if !unit_exists {
|
||||
return Err(ApiErr::NotFound("Unit not found".to_string(), None));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(code) = payload.code.as_deref() {
|
||||
let duplicate = crate::service::get_equipment_by_code(&state.platform.pool, code).await?;
|
||||
let duplicate = crate::service::get_equipment_by_code(&state.pool, code).await?;
|
||||
if duplicate
|
||||
.as_ref()
|
||||
.is_some_and(|item| item.id != equipment_id)
|
||||
|
|
@ -247,7 +238,7 @@ pub async fn update_equipment(
|
|||
}
|
||||
|
||||
crate::service::update_equipment(
|
||||
&state.platform.pool,
|
||||
&state.pool,
|
||||
equipment_id,
|
||||
payload.unit_id,
|
||||
payload.code.as_deref(),
|
||||
|
|
@ -257,18 +248,7 @@ pub async fn update_equipment(
|
|||
)
|
||||
.await?;
|
||||
|
||||
let mut unit_ids = Vec::new();
|
||||
if let Some(unit_id) = existing_equipment.unit_id {
|
||||
unit_ids.push(unit_id);
|
||||
}
|
||||
let next_unit_id = match payload.unit_id {
|
||||
Some(next) => next,
|
||||
None => existing_equipment.unit_id,
|
||||
};
|
||||
if let Some(unit_id) = next_unit_id {
|
||||
unit_ids.push(unit_id);
|
||||
}
|
||||
notify_units(&state, unit_ids).await;
|
||||
state.metadata.invalidate_equipment(equipment_id).await;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok_msg": "Equipment updated successfully"
|
||||
|
|
@ -276,7 +256,7 @@ pub async fn update_equipment(
|
|||
}
|
||||
|
||||
pub async fn batch_set_equipment_unit(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Json(payload): Json<BatchSetEquipmentUnitReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
|
|
@ -289,28 +269,19 @@ pub async fn batch_set_equipment_unit(
|
|||
}
|
||||
|
||||
if let Some(unit_id) = payload.unit_id {
|
||||
let unit_exists = crate::service::get_unit_by_id(&state.platform.pool, unit_id).await?;
|
||||
if unit_exists.is_none() {
|
||||
let unit_exists = unit_row_exists(&state.pool, unit_id).await?;
|
||||
if !unit_exists {
|
||||
return Err(ApiErr::NotFound("Unit not found".to_string(), None));
|
||||
}
|
||||
}
|
||||
|
||||
let before_unit_ids =
|
||||
crate::service::get_unit_ids_by_equipment_ids(&state.platform.pool, &payload.equipment_ids).await?;
|
||||
|
||||
let updated_count = crate::service::batch_set_equipment_unit(
|
||||
&state.platform.pool,
|
||||
&state.pool,
|
||||
&payload.equipment_ids,
|
||||
payload.unit_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut unit_ids = before_unit_ids;
|
||||
if let Some(unit_id) = payload.unit_id {
|
||||
unit_ids.push(unit_id);
|
||||
}
|
||||
notify_units(&state, unit_ids).await;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok_msg": "Equipment unit updated successfully",
|
||||
"updated_count": updated_count
|
||||
|
|
@ -318,18 +289,15 @@ pub async fn batch_set_equipment_unit(
|
|||
}
|
||||
|
||||
pub async fn delete_equipment(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(equipment_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let unit_ids = crate::service::get_unit_ids_by_equipment_ids(&state.platform.pool, &[equipment_id]).await?;
|
||||
let deleted = crate::service::delete_equipment(&state.platform.pool, equipment_id).await?;
|
||||
let deleted = crate::service::delete_equipment(&state.pool, equipment_id).await?;
|
||||
if !deleted {
|
||||
return Err(ApiErr::NotFound("Equipment not found".to_string(), None));
|
||||
}
|
||||
|
||||
notify_units(&state, unit_ids).await;
|
||||
state.metadata.invalidate_equipment(equipment_id).await;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
use axum::{Json, extract::{Path, Query, State}, http::StatusCode, response::IntoResponse};
|
||||
use axum::{Json, extract::{Path, Query, State}, http::StatusCode, response::IntoResponse};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use sqlx::types::Json as SqlxJson;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
use plc_platform_core::model::Page;
|
||||
use plc_platform_core::util::response::ApiErr;
|
||||
use crate::AppState;
|
||||
use crate::model::Page;
|
||||
use crate::platform_context::PlatformContext;
|
||||
use crate::util::response::ApiErr;
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct GetPageListQuery {
|
||||
|
|
@ -16,11 +16,11 @@ pub struct GetPageListQuery {
|
|||
}
|
||||
|
||||
pub async fn get_page_list(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Query(query): Query<GetPageListQuery>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
query.validate()?;
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
|
||||
let pages: Vec<Page> = if let Some(name) = query.name {
|
||||
sqlx::query_as::<_, Page>(
|
||||
|
|
@ -45,12 +45,12 @@ pub async fn get_page_list(
|
|||
}
|
||||
|
||||
pub async fn get_page(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(page_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let page = sqlx::query_as::<_, Page>("SELECT * FROM page WHERE id = $1")
|
||||
.bind(page_id)
|
||||
.fetch_optional(&state.platform.pool)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
match page {
|
||||
|
|
@ -74,7 +74,7 @@ pub struct UpdatePageReq {
|
|||
}
|
||||
|
||||
pub async fn create_page(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Json(payload): Json<CreatePageReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
|
|
@ -88,7 +88,7 @@ pub async fn create_page(
|
|||
)
|
||||
.bind(&payload.name)
|
||||
.bind(SqlxJson(payload.data))
|
||||
.fetch_one(&state.platform.pool)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(serde_json::json!({
|
||||
|
|
@ -98,7 +98,7 @@ pub async fn create_page(
|
|||
}
|
||||
|
||||
pub async fn update_page(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(page_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdatePageReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
|
|
@ -106,7 +106,7 @@ pub async fn update_page(
|
|||
|
||||
let exists = sqlx::query("SELECT 1 FROM page WHERE id = $1")
|
||||
.bind(page_id)
|
||||
.fetch_optional(&state.platform.pool)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
if exists.is_none() {
|
||||
return Err(ApiErr::NotFound("Page not found".to_string(), None));
|
||||
|
|
@ -145,7 +145,7 @@ pub async fn update_page(
|
|||
}
|
||||
query = query.bind(page_id);
|
||||
|
||||
query.execute(&state.platform.pool).await?;
|
||||
query.execute(&state.pool).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok_msg": "Page updated successfully"
|
||||
|
|
@ -153,12 +153,12 @@ pub async fn update_page(
|
|||
}
|
||||
|
||||
pub async fn delete_page(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(page_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let result = sqlx::query("DELETE FROM page WHERE id = $1")
|
||||
.bind(page_id)
|
||||
.execute(&state.platform.pool)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::HeaderMap,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
|
|
@ -11,28 +10,13 @@ use std::collections::{HashMap, HashSet};
|
|||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
use plc_platform_core::util::{
|
||||
use crate::model::{Node, Point};
|
||||
use crate::platform_context::PlatformContext;
|
||||
use crate::util::{
|
||||
pagination::{PaginatedResponse, PaginationParams},
|
||||
response::ApiErr,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
};
|
||||
use plc_platform_core::model::{Node, Point};
|
||||
|
||||
async fn notify_units(
|
||||
state: &AppState,
|
||||
unit_ids: impl IntoIterator<Item = Uuid>,
|
||||
) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for unit_id in unit_ids {
|
||||
if seen.insert(unit_id) {
|
||||
state.control_runtime.notify_unit(unit_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all points.
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct GetPointListQuery {
|
||||
|
|
@ -56,7 +40,7 @@ pub struct GetPointHistoryQuery {
|
|||
|
||||
#[derive(Serialize)]
|
||||
pub struct PointHistoryItem {
|
||||
#[serde(serialize_with = "plc_platform_core::util::datetime::option_utc_to_local_str")]
|
||||
#[serde(serialize_with = "crate::util::datetime::option_utc_to_local_str")]
|
||||
pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub quality: crate::telemetry::PointQuality,
|
||||
pub value: Option<crate::telemetry::DataValue>,
|
||||
|
|
@ -64,12 +48,24 @@ pub struct PointHistoryItem {
|
|||
pub value_number: Option<f64>,
|
||||
}
|
||||
|
||||
pub async fn batch_set_point_value(
|
||||
State(state): State<PlatformContext>,
|
||||
Json(payload): Json<crate::connection::BatchSetPointValueReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let result = state
|
||||
.connection_manager
|
||||
.write_point_values_batch(payload)
|
||||
.await
|
||||
.map_err(|e| ApiErr::Internal(e, None))?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn get_point_list(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Query(query): Query<GetPointListQuery>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
query.validate()?;
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
|
||||
// Count total rows.
|
||||
let total = crate::service::get_points_count(pool, query.source_id, query.equipment_id).await?;
|
||||
|
|
@ -85,7 +81,7 @@ pub async fn get_point_list(
|
|||
.await?;
|
||||
|
||||
let monitor_guard = state
|
||||
.platform.connection_manager
|
||||
.connection_manager
|
||||
.get_point_monitor_data_read_guard()
|
||||
.await;
|
||||
|
||||
|
|
@ -111,21 +107,21 @@ pub async fn get_point_list(
|
|||
}
|
||||
/// Get a point by id.
|
||||
pub async fn get_point(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(point_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
let point = crate::service::get_point_by_id(pool, point_id).await?;
|
||||
|
||||
Ok(Json(point))
|
||||
}
|
||||
|
||||
pub async fn get_point_history(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(point_id): Path<Uuid>,
|
||||
Query(query): Query<GetPointHistoryQuery>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
let point = crate::service::get_point_by_id(pool, point_id).await?;
|
||||
if point.is_none() {
|
||||
return Err(ApiErr::NotFound("Point not found".to_string(), None));
|
||||
|
|
@ -133,7 +129,7 @@ pub async fn get_point_history(
|
|||
|
||||
let limit = query.limit.unwrap_or(120).clamp(1, 1000);
|
||||
let history = state
|
||||
.platform.connection_manager
|
||||
.connection_manager
|
||||
.get_point_history(point_id, limit)
|
||||
.await;
|
||||
|
||||
|
|
@ -188,13 +184,13 @@ pub struct BatchSetPointEquipmentReq {
|
|||
|
||||
/// Update point metadata (name/description/unit only).
|
||||
pub async fn update_point(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(point_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdatePointReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
|
||||
if payload.name.is_none()
|
||||
&& payload.description.is_none()
|
||||
|
|
@ -239,8 +235,6 @@ pub async fn update_point(
|
|||
if existing_point.is_none() {
|
||||
return Err(ApiErr::NotFound("Point not found".to_string(), None));
|
||||
}
|
||||
let before_unit_ids = crate::service::get_unit_ids_by_point_ids(pool, &[point_id]).await?;
|
||||
|
||||
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new("UPDATE point SET ");
|
||||
let mut wrote_field = false;
|
||||
|
||||
|
|
@ -295,9 +289,6 @@ pub async fn update_point(
|
|||
qb.push(" WHERE id = ").push_bind(point_id);
|
||||
qb.build().execute(pool).await?;
|
||||
|
||||
let after_unit_ids = crate::service::get_unit_ids_by_point_ids(pool, &[point_id]).await?;
|
||||
notify_units(&state, before_unit_ids.into_iter().chain(after_unit_ids)).await;
|
||||
|
||||
Ok(Json(
|
||||
serde_json::json!({"ok_msg": "Point updated successfully"}),
|
||||
))
|
||||
|
|
@ -305,7 +296,7 @@ pub async fn update_point(
|
|||
|
||||
/// Batch set point tags.
|
||||
pub async fn batch_set_point_tags(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Json(payload): Json<BatchSetPointTagsReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
|
|
@ -317,7 +308,7 @@ pub async fn batch_set_point_tags(
|
|||
));
|
||||
}
|
||||
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
|
||||
// If tag_id is provided, ensure tag exists.
|
||||
if let Some(tag_id) = payload.tag_id {
|
||||
|
|
@ -360,7 +351,7 @@ pub async fn batch_set_point_tags(
|
|||
}
|
||||
|
||||
pub async fn batch_set_point_equipment(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Json(payload): Json<BatchSetPointEquipmentReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
|
|
@ -372,7 +363,7 @@ pub async fn batch_set_point_equipment(
|
|||
));
|
||||
}
|
||||
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
|
||||
if let Some(equipment_id) = payload.equipment_id {
|
||||
let equipment_exists = sqlx::query(r#"SELECT 1 FROM equipment WHERE id = $1"#)
|
||||
|
|
@ -398,8 +389,6 @@ pub async fn batch_set_point_equipment(
|
|||
return Err(ApiErr::NotFound("No valid points found".to_string(), None));
|
||||
}
|
||||
|
||||
let before_unit_ids = crate::service::get_unit_ids_by_point_ids(pool, &existing_points).await?;
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE point
|
||||
|
|
@ -415,9 +404,6 @@ pub async fn batch_set_point_equipment(
|
|||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let after_unit_ids = crate::service::get_unit_ids_by_point_ids(pool, &existing_points).await?;
|
||||
notify_units(&state, before_unit_ids.into_iter().chain(after_unit_ids)).await;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok_msg": "Point equipment updated successfully",
|
||||
"updated_count": result.rows_affected()
|
||||
|
|
@ -426,12 +412,10 @@ pub async fn batch_set_point_equipment(
|
|||
|
||||
/// Delete one point by id.
|
||||
pub async fn delete_point(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(point_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let pool = &state.platform.pool;
|
||||
let affected_unit_ids = crate::service::get_unit_ids_by_point_ids(pool, &[point_id]).await?;
|
||||
|
||||
let pool = &state.pool;
|
||||
let source_id = {
|
||||
let grouped = crate::service::get_points_grouped_by_source(pool, &[point_id]).await?;
|
||||
grouped.keys().next().copied()
|
||||
|
|
@ -453,19 +437,12 @@ pub async fn delete_point(
|
|||
.await?;
|
||||
|
||||
if let Some(source_id) = source_id {
|
||||
if let Err(e) = state
|
||||
.event_manager
|
||||
.send(crate::event::AppEvent::PointDeleteBatch {
|
||||
source_id,
|
||||
point_ids: vec![point_id],
|
||||
})
|
||||
{
|
||||
tracing::error!("Failed to send PointDeleteBatch event: {}", e);
|
||||
}
|
||||
state.emit_event(crate::event::PlatformEvent::PointsDeleted {
|
||||
source_id,
|
||||
point_ids: vec![point_id],
|
||||
});
|
||||
}
|
||||
|
||||
notify_units(&state, affected_unit_ids).await;
|
||||
|
||||
Ok(Json(
|
||||
serde_json::json!({"ok_msg": "Point deleted successfully"}),
|
||||
))
|
||||
|
|
@ -489,12 +466,12 @@ pub struct BatchCreatePointsRes {
|
|||
|
||||
/// Batch create points by node ids.
|
||||
pub async fn batch_create_points(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Json(payload): Json<BatchCreatePointsReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
|
||||
if payload.node_ids.is_empty() {
|
||||
return Err(ApiErr::BadRequest(
|
||||
|
|
@ -567,15 +544,10 @@ pub async fn batch_create_points(
|
|||
crate::service::get_points_grouped_by_source(pool, &created_point_ids).await?;
|
||||
for (source_id, points) in grouped {
|
||||
let point_ids: Vec<Uuid> = points.into_iter().map(|p| p.point_id).collect();
|
||||
if let Err(e) = state
|
||||
.event_manager
|
||||
.send(crate::event::AppEvent::PointCreateBatch {
|
||||
source_id,
|
||||
point_ids,
|
||||
})
|
||||
{
|
||||
tracing::error!("Failed to send PointCreateBatch event: {}", e);
|
||||
}
|
||||
state.emit_event(crate::event::PlatformEvent::PointsCreated {
|
||||
source_id,
|
||||
point_ids,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -602,7 +574,7 @@ pub struct BatchDeletePointsRes {
|
|||
|
||||
/// Batch delete points and emit grouped delete events by source.
|
||||
pub async fn batch_delete_points(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Json(payload): Json<BatchDeletePointsReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
|
|
@ -614,11 +586,10 @@ pub async fn batch_delete_points(
|
|||
));
|
||||
}
|
||||
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
let point_ids = payload.point_ids;
|
||||
|
||||
let grouped = crate::service::get_points_grouped_by_source(pool, &point_ids).await?;
|
||||
let affected_unit_ids = crate::service::get_unit_ids_by_point_ids(pool, &point_ids).await?;
|
||||
let existing_point_ids: Vec<Uuid> = grouped
|
||||
.values()
|
||||
.flat_map(|points| points.iter().map(|p| p.point_id))
|
||||
|
|
@ -635,51 +606,17 @@ pub async fn batch_delete_points(
|
|||
|
||||
for (source_id, points) in grouped {
|
||||
let ids: Vec<Uuid> = points.into_iter().map(|p| p.point_id).collect();
|
||||
if let Err(e) = state
|
||||
.event_manager
|
||||
.send(crate::event::AppEvent::PointDeleteBatch {
|
||||
source_id,
|
||||
point_ids: ids,
|
||||
})
|
||||
{
|
||||
tracing::error!("Failed to send PointDeleteBatch event: {}", e);
|
||||
}
|
||||
state.emit_event(crate::event::PlatformEvent::PointsDeleted {
|
||||
source_id,
|
||||
point_ids: ids,
|
||||
});
|
||||
}
|
||||
|
||||
notify_units(&state, affected_unit_ids).await;
|
||||
|
||||
Ok(Json(BatchDeletePointsRes {
|
||||
deleted_count: result.rows_affected(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn batch_set_point_value(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<crate::connection::BatchSetPointValueReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let write_key = headers
|
||||
.get("X-Write-Key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !state.config.verify_write_key(write_key) {
|
||||
return Err(ApiErr::Forbidden(
|
||||
"write permission denied".to_string(),
|
||||
Some(serde_json::json!({
|
||||
"hint": "set WRITE_API_KEY (or legacy WRITE_KEY) and pass header X-Write-Key"
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let result = state
|
||||
.platform.connection_manager
|
||||
.write_point_values_batch(payload)
|
||||
.await
|
||||
.map_err(|e| ApiErr::Internal(e, None))?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
fn monitor_value_to_number(item: &crate::telemetry::PointMonitorInfo) -> Option<f64> {
|
||||
match item.value.as_ref()? {
|
||||
crate::telemetry::DataValue::Int(v) => Some(*v as f64),
|
||||
|
|
@ -690,4 +627,3 @@ fn monitor_value_to_number(item: &crate::telemetry::PointMonitorInfo) -> Option<
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
use axum::{
|
||||
extract::FromRef,
|
||||
routing::{get, post, put},
|
||||
Router,
|
||||
};
|
||||
|
||||
use crate::platform_context::PlatformContext;
|
||||
|
||||
/// Returns all platform CRUD routes.
|
||||
///
|
||||
/// The generic `S` is the app's top-level state type. It must implement
|
||||
/// `FromRef<S> for PlatformContext` so that axum can extract `State<PlatformContext>`
|
||||
/// from handlers registered with the app's state.
|
||||
pub fn platform_routes<S>() -> Router<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
PlatformContext: FromRef<S>,
|
||||
{
|
||||
Router::new()
|
||||
// Source
|
||||
.route(
|
||||
"/api/source",
|
||||
get(super::source::get_source_list).post(super::source::create_source),
|
||||
)
|
||||
.route(
|
||||
"/api/source/{source_id}",
|
||||
axum::routing::delete(super::source::delete_source).put(super::source::update_source),
|
||||
)
|
||||
.route(
|
||||
"/api/source/{source_id}/reconnect",
|
||||
post(super::source::reconnect_source),
|
||||
)
|
||||
.route(
|
||||
"/api/source/{source_id}/browse",
|
||||
post(super::source::browse_and_save_nodes),
|
||||
)
|
||||
.route(
|
||||
"/api/source/{source_id}/node-tree",
|
||||
get(super::source::get_node_tree),
|
||||
)
|
||||
// Point
|
||||
.route("/api/point", get(super::point::get_point_list))
|
||||
.route(
|
||||
"/api/point/value/batch",
|
||||
post(super::point::batch_set_point_value),
|
||||
)
|
||||
.route(
|
||||
"/api/point/batch",
|
||||
post(super::point::batch_create_points).delete(super::point::batch_delete_points),
|
||||
)
|
||||
.route(
|
||||
"/api/point/{point_id}/history",
|
||||
get(super::point::get_point_history),
|
||||
)
|
||||
.route(
|
||||
"/api/point/{point_id}",
|
||||
get(super::point::get_point)
|
||||
.put(super::point::update_point)
|
||||
.delete(super::point::delete_point),
|
||||
)
|
||||
.route(
|
||||
"/api/point/batch/set-tags",
|
||||
put(super::point::batch_set_point_tags),
|
||||
)
|
||||
.route(
|
||||
"/api/point/batch/set-equipment",
|
||||
put(super::point::batch_set_point_equipment),
|
||||
)
|
||||
// Equipment
|
||||
.route(
|
||||
"/api/equipment",
|
||||
get(super::equipment::get_equipment_list).post(super::equipment::create_equipment),
|
||||
)
|
||||
.route(
|
||||
"/api/equipment/{equipment_id}",
|
||||
get(super::equipment::get_equipment)
|
||||
.put(super::equipment::update_equipment)
|
||||
.delete(super::equipment::delete_equipment),
|
||||
)
|
||||
.route(
|
||||
"/api/equipment/batch/set-unit",
|
||||
put(super::equipment::batch_set_equipment_unit),
|
||||
)
|
||||
.route(
|
||||
"/api/equipment/{equipment_id}/points",
|
||||
get(super::equipment::get_equipment_points),
|
||||
)
|
||||
// Tag
|
||||
.route(
|
||||
"/api/tag",
|
||||
get(super::tag::get_tag_list).post(super::tag::create_tag),
|
||||
)
|
||||
.route(
|
||||
"/api/tag/{tag_id}",
|
||||
get(super::tag::get_tag_points)
|
||||
.put(super::tag::update_tag)
|
||||
.delete(super::tag::delete_tag),
|
||||
)
|
||||
// Page
|
||||
.route(
|
||||
"/api/page",
|
||||
get(super::page::get_page_list).post(super::page::create_page),
|
||||
)
|
||||
.route(
|
||||
"/api/page/{page_id}",
|
||||
get(super::page::get_page)
|
||||
.put(super::page::update_page)
|
||||
.delete(super::page::delete_page),
|
||||
)
|
||||
// Logs
|
||||
.route("/api/logs", get(super::log::get_logs))
|
||||
.route("/api/logs/stream", get(super::log::stream_logs))
|
||||
}
|
||||
|
|
@ -11,11 +11,11 @@ use opcua::types::ReferenceTypeId;
|
|||
use opcua::client::Session;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
use plc_platform_core::util::response::ApiErr;
|
||||
use crate::util::response::ApiErr;
|
||||
|
||||
use anyhow::{Context};
|
||||
use plc_platform_core::model::{Node, Source};
|
||||
use crate::AppState;
|
||||
use crate::model::{Node, Source};
|
||||
use crate::platform_context::PlatformContext;
|
||||
use sqlx::QueryBuilder;
|
||||
|
||||
// 鏍戣妭鐐圭粨鏋勪綋
|
||||
|
|
@ -62,7 +62,7 @@ pub struct SourceWithStatus {
|
|||
pub source: SourcePublic,
|
||||
pub is_connected: bool,
|
||||
pub last_error: Option<String>,
|
||||
#[serde(serialize_with = "plc_platform_core::util::datetime::option_utc_to_local_str")]
|
||||
#[serde(serialize_with = "crate::util::datetime::option_utc_to_local_str")]
|
||||
pub last_time: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
|
|
@ -75,9 +75,9 @@ pub struct SourcePublic {
|
|||
pub security_policy: Option<String>,
|
||||
pub security_mode: Option<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(serialize_with = "plc_platform_core::util::datetime::utc_to_local_str")]
|
||||
#[serde(serialize_with = "crate::util::datetime::utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(serialize_with = "plc_platform_core::util::datetime::utc_to_local_str")]
|
||||
#[serde(serialize_with = "crate::util::datetime::utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
|
|
@ -97,13 +97,13 @@ impl From<Source> for SourcePublic {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn get_source_list(State(state): State<AppState>) -> Result<impl IntoResponse, ApiErr> {
|
||||
let pool = &state.platform.pool;
|
||||
pub async fn get_source_list(State(state): State<PlatformContext>) -> Result<impl IntoResponse, ApiErr> {
|
||||
let pool = &state.pool;
|
||||
let sources: Vec<Source> = crate::service::get_all_enabled_sources(pool).await?;
|
||||
|
||||
// 鑾峰彇鎵€鏈夎繛鎺ョ姸鎬?
|
||||
let status_map: std::collections::HashMap<Uuid, (bool, Option<String>, Option<DateTime<Utc>>)> =
|
||||
state.platform.connection_manager.get_all_status().await
|
||||
state.connection_manager.get_all_status().await
|
||||
.into_iter()
|
||||
.map(|(source_id, s)| (source_id, (s.is_connected, s.last_error, Some(s.last_time))))
|
||||
.collect();
|
||||
|
|
@ -129,10 +129,10 @@ pub async fn get_source_list(State(state): State<AppState>) -> Result<impl IntoR
|
|||
}
|
||||
|
||||
pub async fn get_node_tree(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(source_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
|
||||
// 鏌ヨ鎵€鏈夊睘浜庤source鐨勮妭鐐?
|
||||
let nodes: Vec<Node> = sqlx::query_as::<_, Node>(
|
||||
|
|
@ -207,12 +207,12 @@ pub struct CreateSourceRes {
|
|||
}
|
||||
|
||||
pub async fn create_source(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Json(payload): Json<CreateSourceReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
let new_id = Uuid::new_v4();
|
||||
|
||||
sqlx::query(
|
||||
|
|
@ -226,8 +226,7 @@ pub async fn create_source(
|
|||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// 瑙﹀彂 SourceCreate 浜嬩欢
|
||||
let _ = state.event_manager.send(crate::event::AppEvent::SourceCreate { source_id: new_id });
|
||||
state.emit_event(crate::event::PlatformEvent::SourceCreated { source_id: new_id });
|
||||
|
||||
Ok((StatusCode::CREATED, Json(CreateSourceRes { id: new_id })))
|
||||
}
|
||||
|
|
@ -244,7 +243,7 @@ pub struct UpdateSourceReq {
|
|||
}
|
||||
|
||||
pub async fn update_source(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(source_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateSourceReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
|
|
@ -261,7 +260,7 @@ pub async fn update_source(
|
|||
return Ok(Json(serde_json::json!({"ok_msg": "No fields to update"})));
|
||||
}
|
||||
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
|
||||
let exists = sqlx::query("SELECT 1 FROM source WHERE id = $1")
|
||||
.bind(source_id)
|
||||
|
|
@ -302,16 +301,16 @@ pub async fn update_source(
|
|||
qb.push(" WHERE id = ").push_bind(source_id);
|
||||
qb.build().execute(pool).await?;
|
||||
|
||||
let _ = state.event_manager.send(crate::event::AppEvent::SourceUpdate { source_id });
|
||||
state.emit_event(crate::event::PlatformEvent::SourceUpdated { source_id });
|
||||
|
||||
Ok(Json(serde_json::json!({"ok_msg": "Source updated successfully"})))
|
||||
}
|
||||
|
||||
pub async fn delete_source(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(source_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
|
||||
let source_name = sqlx::query_scalar::<_, String>("SELECT name FROM source WHERE id = $1")
|
||||
.bind(source_id)
|
||||
|
|
@ -324,17 +323,16 @@ pub async fn delete_source(
|
|||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// 瑙﹀彂 SourceDelete 浜嬩欢
|
||||
let _ = state.event_manager.send(crate::event::AppEvent::SourceDelete { source_id, source_name });
|
||||
state.emit_event(crate::event::PlatformEvent::SourceDeleted { source_id, source_name });
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn reconnect_source(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(source_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
|
||||
let exists = sqlx::query("SELECT 1 FROM source WHERE id = $1")
|
||||
.bind(source_id)
|
||||
|
|
@ -349,7 +347,7 @@ pub async fn reconnect_source(
|
|||
}
|
||||
|
||||
state
|
||||
.platform.connection_manager
|
||||
.connection_manager
|
||||
.reconnect(pool, source_id)
|
||||
.await
|
||||
.map_err(|e| ApiErr::Internal(e, None))?;
|
||||
|
|
@ -358,11 +356,11 @@ pub async fn reconnect_source(
|
|||
}
|
||||
|
||||
pub async fn browse_and_save_nodes(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(source_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
|
||||
// 纭 source 瀛樺湪
|
||||
sqlx::query("SELECT 1 FROM source WHERE id = $1")
|
||||
|
|
@ -370,7 +368,7 @@ pub async fn browse_and_save_nodes(
|
|||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let session = state.platform.connection_manager
|
||||
let session = state.connection_manager
|
||||
.get_session(source_id)
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("Source not connected"))?;
|
||||
|
|
@ -3,13 +3,12 @@ use serde::Deserialize;
|
|||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
use plc_platform_core::util::{
|
||||
use crate::platform_context::PlatformContext;
|
||||
use crate::util::{
|
||||
pagination::{PaginatedResponse, PaginationParams},
|
||||
response::ApiErr,
|
||||
};
|
||||
use crate::{AppState};
|
||||
|
||||
/// List all tags.
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct GetTagListQuery {
|
||||
#[serde(flatten)]
|
||||
|
|
@ -17,16 +16,13 @@ pub struct GetTagListQuery {
|
|||
}
|
||||
|
||||
pub async fn get_tag_list(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Query(query): Query<GetTagListQuery>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
query.validate()?;
|
||||
let pool = &state.platform.pool;
|
||||
let pool = &state.pool;
|
||||
|
||||
// Count total rows.
|
||||
let total = crate::service::get_tags_count(pool).await?;
|
||||
|
||||
// Load current page rows.
|
||||
let tags = crate::service::get_tags_paginated(
|
||||
pool,
|
||||
query.pagination.page_size,
|
||||
|
|
@ -34,16 +30,14 @@ pub async fn get_tag_list(
|
|||
).await?;
|
||||
|
||||
let response = PaginatedResponse::new(tags, total, query.pagination.page, query.pagination.page_size);
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// List points under a tag.
|
||||
pub async fn get_tag_points(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(tag_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let points = crate::service::get_tag_points(&state.platform.pool, tag_id).await?;
|
||||
let points = crate::service::get_tag_points(&state.pool, tag_id).await?;
|
||||
Ok(Json(points))
|
||||
}
|
||||
|
||||
|
|
@ -63,16 +57,15 @@ pub struct UpdateTagReq {
|
|||
pub point_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
/// Create a tag.
|
||||
pub async fn create_tag(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Json(payload): Json<CreateTagReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
|
||||
let point_ids = payload.point_ids.as_deref().unwrap_or(&[]);
|
||||
let tag_id = crate::service::create_tag(
|
||||
&state.platform.pool,
|
||||
&state.pool,
|
||||
&payload.name,
|
||||
payload.description.as_deref(),
|
||||
point_ids,
|
||||
|
|
@ -84,22 +77,20 @@ pub async fn create_tag(
|
|||
}))))
|
||||
}
|
||||
|
||||
/// Update a tag.
|
||||
pub async fn update_tag(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(tag_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateTagReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
|
||||
// Ensure the target tag exists.
|
||||
let exists = crate::service::get_tag_by_id(&state.platform.pool, tag_id).await?;
|
||||
let exists = crate::service::get_tag_by_id(&state.pool, tag_id).await?;
|
||||
if exists.is_none() {
|
||||
return Err(ApiErr::NotFound("Tag not found".to_string(), None));
|
||||
}
|
||||
|
||||
crate::service::update_tag(
|
||||
&state.platform.pool,
|
||||
&state.pool,
|
||||
tag_id,
|
||||
payload.name.as_deref(),
|
||||
payload.description.as_deref(),
|
||||
|
|
@ -111,16 +102,13 @@ pub async fn update_tag(
|
|||
})))
|
||||
}
|
||||
|
||||
/// Delete a tag.
|
||||
pub async fn delete_tag(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PlatformContext>,
|
||||
Path(tag_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let deleted = crate::service::delete_tag(&state.platform.pool, tag_id).await?;
|
||||
|
||||
let deleted = crate::service::delete_tag(&state.pool, tag_id).await?;
|
||||
if !deleted {
|
||||
return Err(ApiErr::NotFound("Tag not found".to_string(), None));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
use std::time::Instant;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::Request,
|
||||
http::{header, HeaderValue},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
Router,
|
||||
};
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
services::ServeDir,
|
||||
};
|
||||
|
||||
pub async fn no_cache(req: Request, next: Next) -> Response {
|
||||
let mut response = next.run(req).await;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
|
||||
pub async fn simple_logger(req: axum::http::Request<Body>, next: Next) -> Response {
|
||||
let method = req.method().to_string();
|
||||
let uri = req.uri().to_string();
|
||||
|
||||
let start = Instant::now();
|
||||
let res = next.run(req).await;
|
||||
let duration = start.elapsed();
|
||||
let status = res.status();
|
||||
|
||||
match status.as_u16() {
|
||||
100..=399 => tracing::info!("{} {} {} {:?}", method, uri, status, duration),
|
||||
400..=499 => tracing::warn!("{} {} {} {:?}", method, uri, status, duration),
|
||||
500..=599 => tracing::error!("{} {} {} {:?}", method, uri, status, duration),
|
||||
_ => tracing::warn!("{} {} {} {:?}", method, uri, status, duration),
|
||||
}
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
pub fn permissive_cors() -> CorsLayer {
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
}
|
||||
|
||||
pub fn static_ui_routes<S>(app_dir: &'static str, core_dir: &'static str) -> Router<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.fallback_service(
|
||||
ServeDir::new(app_dir)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(ServeDir::new(core_dir)),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(no_cache))
|
||||
}
|
||||
|
|
@ -1,15 +1,17 @@
|
|||
pub mod bootstrap;
|
||||
pub mod bootstrap;
|
||||
pub mod config;
|
||||
pub mod connection;
|
||||
pub mod control;
|
||||
pub mod db;
|
||||
pub mod event;
|
||||
pub mod handler;
|
||||
pub mod http;
|
||||
pub mod model;
|
||||
pub mod platform_context;
|
||||
pub mod service;
|
||||
pub mod telemetry;
|
||||
pub mod telemetry_processor;
|
||||
pub mod util;
|
||||
pub mod websocket;
|
||||
|
||||
pub use event::EventEnvelope;
|
||||
|
||||
|
|
|
|||
|
|
@ -134,24 +134,6 @@ pub struct Equipment {
|
|||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
|
||||
pub struct ControlUnit {
|
||||
pub id: Uuid,
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub run_time_sec: i32,
|
||||
pub stop_time_sec: i32,
|
||||
pub acc_time_sec: i32,
|
||||
pub bl_time_sec: i32,
|
||||
pub require_manual_ack_after_fault: bool,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
|
||||
pub struct EventRecord {
|
||||
pub id: Uuid,
|
||||
|
|
@ -160,6 +142,8 @@ pub struct EventRecord {
|
|||
pub unit_id: Option<Uuid>,
|
||||
pub equipment_id: Option<Uuid>,
|
||||
pub source_id: Option<Uuid>,
|
||||
pub subject_type: Option<String>,
|
||||
pub subject_id: Option<Uuid>,
|
||||
pub message: String,
|
||||
pub payload: Option<Json<serde_json::Value>>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::connection::ConnectionManager;
|
||||
use crate::event::{MetadataCache, PlatformEvent};
|
||||
use crate::websocket::WebSocketManager;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PlatformContext {
|
||||
pub pool: sqlx::PgPool,
|
||||
pub connection_manager: Arc<ConnectionManager>,
|
||||
pub ws_manager: Arc<WebSocketManager>,
|
||||
pub metadata: Arc<MetadataCache>,
|
||||
}
|
||||
|
||||
impl PlatformContext {
|
||||
|
|
@ -20,6 +21,82 @@ impl PlatformContext {
|
|||
pool,
|
||||
connection_manager,
|
||||
ws_manager,
|
||||
metadata: Arc::new(MetadataCache::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a platform event.
|
||||
///
|
||||
/// Spawns async work for event persistence, WebSocket broadcast, and
|
||||
/// connection management side effects (connect, subscribe, etc.).
|
||||
pub fn emit_event(&self, event: PlatformEvent) {
|
||||
let pool = self.pool.clone();
|
||||
let ws_manager = self.ws_manager.clone();
|
||||
let cm = self.connection_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
// Persist + broadcast.
|
||||
crate::event::record_platform_event(&event, &pool, &ws_manager).await;
|
||||
|
||||
// Connection management side effects.
|
||||
match &event {
|
||||
PlatformEvent::SourceCreated { source_id } => {
|
||||
tracing::info!("Processing SourceCreated for {}", source_id);
|
||||
if let Err(e) = cm.connect_from_source(&pool, *source_id).await {
|
||||
tracing::error!("Failed to connect to source {}: {}", source_id, e);
|
||||
}
|
||||
}
|
||||
PlatformEvent::SourceUpdated { source_id } => {
|
||||
tracing::info!("Processing SourceUpdated for {}", source_id);
|
||||
if let Err(e) = cm.reconnect(&pool, *source_id).await {
|
||||
tracing::error!("Failed to reconnect source {}: {}", source_id, e);
|
||||
}
|
||||
}
|
||||
PlatformEvent::SourceDeleted { source_id, .. } => {
|
||||
tracing::info!("Processing SourceDeleted for {}", source_id);
|
||||
if let Err(e) = cm.disconnect(*source_id).await {
|
||||
tracing::error!("Failed to disconnect source {}: {}", source_id, e);
|
||||
}
|
||||
}
|
||||
PlatformEvent::PointsCreated {
|
||||
source_id,
|
||||
point_ids,
|
||||
} => {
|
||||
let requested_count = point_ids.len();
|
||||
match cm
|
||||
.subscribe_points_from_source(*source_id, Some(point_ids.clone()), &pool)
|
||||
.await
|
||||
{
|
||||
Ok(stats) => {
|
||||
let subscribed = *stats.get("subscribed").unwrap_or(&0);
|
||||
let polled = *stats.get("polled").unwrap_or(&0);
|
||||
let total = *stats.get("total").unwrap_or(&0);
|
||||
tracing::info!(
|
||||
"PointsCreated subscribe for source {}: requested={}, subscribed={}, polled={}, total={}",
|
||||
source_id, requested_count, subscribed, polled, total
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to subscribe points: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
PlatformEvent::PointsDeleted {
|
||||
source_id,
|
||||
point_ids,
|
||||
} => {
|
||||
tracing::info!(
|
||||
"Processing PointsDeleted for source {} ({} points)",
|
||||
source_id,
|
||||
point_ids.len()
|
||||
);
|
||||
if let Err(e) = cm
|
||||
.unsubscribe_points_from_source(*source_id, point_ids.clone())
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to unsubscribe points: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
use crate::model::{ControlUnit, EventRecord};
|
||||
use crate::model::EventRecord;
|
||||
use sqlx::{PgPool, QueryBuilder, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn unit_order_clause() -> &'static str {
|
||||
"code"
|
||||
}
|
||||
|
||||
fn equipment_order_clause_with_unit() -> &'static str {
|
||||
"unit_id, code"
|
||||
}
|
||||
|
|
@ -16,268 +12,33 @@ pub struct EquipmentRolePoint {
|
|||
pub signal_role: String,
|
||||
}
|
||||
|
||||
pub async fn get_units_count(pool: &PgPool, keyword: Option<&str>) -> Result<i64, sqlx::Error> {
|
||||
match keyword {
|
||||
Some(keyword) => {
|
||||
let like = format!("%{}%", keyword);
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM unit
|
||||
WHERE code ILIKE $1 OR name ILIKE $1
|
||||
"#,
|
||||
)
|
||||
.bind(like)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
None => sqlx::query_scalar::<_, i64>(r#"SELECT COUNT(*) FROM unit"#)
|
||||
.fetch_one(pool)
|
||||
.await,
|
||||
}
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct EventFilter<'a> {
|
||||
pub unit_id: Option<Uuid>,
|
||||
pub event_type: Option<&'a str>,
|
||||
/// `event_type` LIKE prefix, e.g. `ops.` matches all ops events.
|
||||
pub event_type_prefix: Option<&'a str>,
|
||||
pub subject_type: Option<&'a str>,
|
||||
pub subject_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn get_units_paginated(
|
||||
pool: &PgPool,
|
||||
keyword: Option<&str>,
|
||||
page_size: i32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<ControlUnit>, sqlx::Error> {
|
||||
let unit_order = unit_order_clause();
|
||||
match keyword {
|
||||
Some(keyword) => {
|
||||
let like = format!("%{}%", keyword);
|
||||
if page_size == -1 {
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT *
|
||||
FROM unit
|
||||
WHERE code ILIKE $1 OR name ILIKE $1
|
||||
ORDER BY {}
|
||||
"#,
|
||||
unit_order
|
||||
);
|
||||
sqlx::query_as::<_, ControlUnit>(&sql)
|
||||
.bind(like)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else {
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT *
|
||||
FROM unit
|
||||
WHERE code ILIKE $1 OR name ILIKE $1
|
||||
ORDER BY {}
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
unit_order
|
||||
);
|
||||
sqlx::query_as::<_, ControlUnit>(&sql)
|
||||
.bind(like)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if page_size == -1 {
|
||||
let sql = format!("SELECT * FROM unit ORDER BY {}", unit_order);
|
||||
sqlx::query_as::<_, ControlUnit>(&sql)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else {
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT *
|
||||
FROM unit
|
||||
ORDER BY {}
|
||||
LIMIT $1 OFFSET $2
|
||||
"#,
|
||||
unit_order
|
||||
);
|
||||
sqlx::query_as::<_, ControlUnit>(&sql)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
fn apply_event_filters<'a>(qb: &mut QueryBuilder<'a, sqlx::Postgres>, filter: &EventFilter<'a>) {
|
||||
if let Some(unit_id) = filter.unit_id {
|
||||
qb.push(" AND unit_id = ").push_bind(unit_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_unit_by_id(
|
||||
pool: &PgPool,
|
||||
unit_id: Uuid,
|
||||
) -> Result<Option<ControlUnit>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ControlUnit>(r#"SELECT * FROM unit WHERE id = $1"#)
|
||||
.bind(unit_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_unit_by_code(
|
||||
pool: &PgPool,
|
||||
code: &str,
|
||||
) -> Result<Option<ControlUnit>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ControlUnit>(r#"SELECT * FROM unit WHERE code = $1"#)
|
||||
.bind(code)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub struct CreateUnitParams<'a> {
|
||||
pub code: &'a str,
|
||||
pub name: &'a str,
|
||||
pub description: Option<&'a str>,
|
||||
pub enabled: bool,
|
||||
pub run_time_sec: i32,
|
||||
pub stop_time_sec: i32,
|
||||
pub acc_time_sec: i32,
|
||||
pub bl_time_sec: i32,
|
||||
pub require_manual_ack_after_fault: bool,
|
||||
}
|
||||
|
||||
pub async fn create_unit(
|
||||
pool: &PgPool,
|
||||
params: CreateUnitParams<'_>,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let unit_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO unit (
|
||||
id, code, name, description, enabled,
|
||||
run_time_sec, stop_time_sec, acc_time_sec, bl_time_sec,
|
||||
require_manual_ack_after_fault
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(unit_id)
|
||||
.bind(params.code)
|
||||
.bind(params.name)
|
||||
.bind(params.description)
|
||||
.bind(params.enabled)
|
||||
.bind(params.run_time_sec)
|
||||
.bind(params.stop_time_sec)
|
||||
.bind(params.acc_time_sec)
|
||||
.bind(params.bl_time_sec)
|
||||
.bind(params.require_manual_ack_after_fault)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(unit_id)
|
||||
}
|
||||
|
||||
pub struct UpdateUnitParams<'a> {
|
||||
pub code: Option<&'a str>,
|
||||
pub name: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
pub enabled: Option<bool>,
|
||||
pub run_time_sec: Option<i32>,
|
||||
pub stop_time_sec: Option<i32>,
|
||||
pub acc_time_sec: Option<i32>,
|
||||
pub bl_time_sec: Option<i32>,
|
||||
pub require_manual_ack_after_fault: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn update_unit(
|
||||
pool: &PgPool,
|
||||
unit_id: Uuid,
|
||||
params: UpdateUnitParams<'_>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut updates = Vec::new();
|
||||
let mut param_count = 1;
|
||||
|
||||
if params.code.is_some() {
|
||||
updates.push(format!("code = ${}", param_count));
|
||||
param_count += 1;
|
||||
if let Some(event_type) = filter.event_type {
|
||||
qb.push(" AND event_type = ").push_bind(event_type);
|
||||
}
|
||||
if params.name.is_some() {
|
||||
updates.push(format!("name = ${}", param_count));
|
||||
param_count += 1;
|
||||
if let Some(prefix) = filter.event_type_prefix {
|
||||
let pattern = format!("{}%", prefix);
|
||||
qb.push(" AND event_type LIKE ").push_bind(pattern);
|
||||
}
|
||||
if params.description.is_some() {
|
||||
updates.push(format!("description = ${}", param_count));
|
||||
param_count += 1;
|
||||
if let Some(subject_type) = filter.subject_type {
|
||||
qb.push(" AND subject_type = ").push_bind(subject_type);
|
||||
}
|
||||
if params.enabled.is_some() {
|
||||
updates.push(format!("enabled = ${}", param_count));
|
||||
param_count += 1;
|
||||
if let Some(subject_id) = filter.subject_id {
|
||||
qb.push(" AND subject_id = ").push_bind(subject_id);
|
||||
}
|
||||
if params.run_time_sec.is_some() {
|
||||
updates.push(format!("run_time_sec = ${}", param_count));
|
||||
param_count += 1;
|
||||
}
|
||||
if params.stop_time_sec.is_some() {
|
||||
updates.push(format!("stop_time_sec = ${}", param_count));
|
||||
param_count += 1;
|
||||
}
|
||||
if params.acc_time_sec.is_some() {
|
||||
updates.push(format!("acc_time_sec = ${}", param_count));
|
||||
param_count += 1;
|
||||
}
|
||||
if params.bl_time_sec.is_some() {
|
||||
updates.push(format!("bl_time_sec = ${}", param_count));
|
||||
param_count += 1;
|
||||
}
|
||||
if params.require_manual_ack_after_fault.is_some() {
|
||||
updates.push(format!(
|
||||
"require_manual_ack_after_fault = ${}",
|
||||
param_count
|
||||
));
|
||||
param_count += 1;
|
||||
}
|
||||
|
||||
updates.push("updated_at = NOW()".to_string());
|
||||
|
||||
let sql = format!(
|
||||
r#"UPDATE unit SET {} WHERE id = ${}"#,
|
||||
updates.join(", "),
|
||||
param_count
|
||||
);
|
||||
|
||||
let mut query = sqlx::query(&sql);
|
||||
|
||||
if let Some(code) = params.code {
|
||||
query = query.bind(code);
|
||||
}
|
||||
if let Some(name) = params.name {
|
||||
query = query.bind(name);
|
||||
}
|
||||
if let Some(description) = params.description {
|
||||
query = query.bind(description);
|
||||
}
|
||||
if let Some(enabled) = params.enabled {
|
||||
query = query.bind(enabled);
|
||||
}
|
||||
if let Some(run_time_sec) = params.run_time_sec {
|
||||
query = query.bind(run_time_sec);
|
||||
}
|
||||
if let Some(stop_time_sec) = params.stop_time_sec {
|
||||
query = query.bind(stop_time_sec);
|
||||
}
|
||||
if let Some(acc_time_sec) = params.acc_time_sec {
|
||||
query = query.bind(acc_time_sec);
|
||||
}
|
||||
if let Some(bl_time_sec) = params.bl_time_sec {
|
||||
query = query.bind(bl_time_sec);
|
||||
}
|
||||
if let Some(require_manual_ack_after_fault) = params.require_manual_ack_after_fault {
|
||||
query = query.bind(require_manual_ack_after_fault);
|
||||
}
|
||||
|
||||
query.bind(unit_id).execute(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_unit(pool: &PgPool, unit_id: Uuid) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query(r#"DELETE FROM unit WHERE id = $1"#)
|
||||
.bind(unit_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn get_events_count(
|
||||
|
|
@ -285,17 +46,12 @@ pub async fn get_events_count(
|
|||
unit_id: Option<Uuid>,
|
||||
event_type: Option<&str>,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
let mut qb =
|
||||
QueryBuilder::new("SELECT COUNT(*)::BIGINT FROM event WHERE 1 = 1");
|
||||
|
||||
if let Some(unit_id) = unit_id {
|
||||
qb.push(" AND unit_id = ").push_bind(unit_id);
|
||||
}
|
||||
if let Some(event_type) = event_type {
|
||||
qb.push(" AND event_type = ").push_bind(event_type);
|
||||
}
|
||||
|
||||
qb.build_query_scalar().fetch_one(pool).await
|
||||
let filter = EventFilter {
|
||||
unit_id,
|
||||
event_type,
|
||||
..EventFilter::default()
|
||||
};
|
||||
get_events_count_filtered(pool, &filter).await
|
||||
}
|
||||
|
||||
pub async fn get_events_paginated(
|
||||
|
|
@ -305,14 +61,31 @@ pub async fn get_events_paginated(
|
|||
page_size: i32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<EventRecord>, sqlx::Error> {
|
||||
let mut qb = QueryBuilder::new("SELECT * FROM event WHERE 1 = 1");
|
||||
let filter = EventFilter {
|
||||
unit_id,
|
||||
event_type,
|
||||
..EventFilter::default()
|
||||
};
|
||||
get_events_paginated_filtered(pool, &filter, page_size, offset).await
|
||||
}
|
||||
|
||||
if let Some(unit_id) = unit_id {
|
||||
qb.push(" AND unit_id = ").push_bind(unit_id);
|
||||
}
|
||||
if let Some(event_type) = event_type {
|
||||
qb.push(" AND event_type = ").push_bind(event_type);
|
||||
}
|
||||
pub async fn get_events_count_filtered(
|
||||
pool: &PgPool,
|
||||
filter: &EventFilter<'_>,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
let mut qb = QueryBuilder::new("SELECT COUNT(*)::BIGINT FROM event WHERE 1 = 1");
|
||||
apply_event_filters(&mut qb, filter);
|
||||
qb.build_query_scalar().fetch_one(pool).await
|
||||
}
|
||||
|
||||
pub async fn get_events_paginated_filtered(
|
||||
pool: &PgPool,
|
||||
filter: &EventFilter<'_>,
|
||||
page_size: i32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<EventRecord>, sqlx::Error> {
|
||||
let mut qb = QueryBuilder::new("SELECT * FROM event WHERE 1 = 1");
|
||||
apply_event_filters(&mut qb, filter);
|
||||
|
||||
qb.push(" ORDER BY created_at DESC");
|
||||
|
||||
|
|
@ -324,16 +97,6 @@ pub async fn get_events_paginated(
|
|||
qb.build_query_as::<EventRecord>().fetch_all(pool).await
|
||||
}
|
||||
|
||||
pub async fn get_all_enabled_units(pool: &PgPool) -> Result<Vec<ControlUnit>, sqlx::Error> {
|
||||
let sql = format!(
|
||||
"SELECT * FROM unit WHERE enabled = TRUE ORDER BY {}",
|
||||
unit_order_clause()
|
||||
);
|
||||
sqlx::query_as::<_, ControlUnit>(&sql)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_equipment_by_unit_ids(
|
||||
pool: &PgPool,
|
||||
unit_ids: &[Uuid],
|
||||
|
|
@ -346,20 +109,18 @@ pub async fn get_equipment_by_unit_ids(
|
|||
equipment_order_clause_with_unit()
|
||||
);
|
||||
sqlx::query_as::<_, crate::model::Equipment>(&sql)
|
||||
.bind(unit_ids)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.bind(unit_ids)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_equipment_by_unit_id(
|
||||
pool: &PgPool,
|
||||
unit_id: Uuid,
|
||||
) -> Result<Vec<crate::model::Equipment>, sqlx::Error> {
|
||||
let sql = format!(
|
||||
"SELECT * FROM equipment WHERE unit_id = $1 ORDER BY {}",
|
||||
unit_order_clause()
|
||||
);
|
||||
sqlx::query_as::<_, crate::model::Equipment>(&sql)
|
||||
sqlx::query_as::<_, crate::model::Equipment>(
|
||||
"SELECT * FROM equipment WHERE unit_id = $1 ORDER BY code",
|
||||
)
|
||||
.bind(unit_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
|
|
@ -403,30 +164,6 @@ pub async fn get_unit_ids_by_equipment_ids(
|
|||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get_unit_ids_by_point_ids(
|
||||
pool: &PgPool,
|
||||
point_ids: &[Uuid],
|
||||
) -> Result<Vec<Uuid>, sqlx::Error> {
|
||||
if point_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let rows = sqlx::query_scalar::<_, Uuid>(
|
||||
r#"
|
||||
SELECT DISTINCT e.unit_id
|
||||
FROM point p
|
||||
INNER JOIN equipment e ON e.id = p.equipment_id
|
||||
WHERE p.id = ANY($1)
|
||||
AND e.unit_id IS NOT NULL
|
||||
"#,
|
||||
)
|
||||
.bind(point_ids)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub struct EquipmentSignalRole {
|
||||
pub equipment_id: Uuid,
|
||||
pub point_id: Uuid,
|
||||
|
|
@ -464,21 +201,6 @@ pub async fn get_signal_role_points_batch(
|
|||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{equipment_order_clause_with_unit, unit_order_clause};
|
||||
|
||||
#[test]
|
||||
fn unit_ordering_defaults_to_code() {
|
||||
assert_eq!(unit_order_clause(), "code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_equipment_ordering_uses_code_within_unit() {
|
||||
assert_eq!(equipment_order_clause_with_unit(), "unit_id, code");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_equipment_role_points(
|
||||
pool: &PgPool,
|
||||
equipment_id: Uuid,
|
||||
|
|
@ -508,3 +230,12 @@ pub async fn get_equipment_role_points(
|
|||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::equipment_order_clause_with_unit;
|
||||
|
||||
#[test]
|
||||
fn unit_equipment_ordering_uses_code_within_unit() {
|
||||
assert_eq!(equipment_order_clause_with_unit(), "unit_id, code");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
//! Platform-level telemetry event processor.
|
||||
//!
|
||||
//! Handles `PointNewValue` events from `ConnectionManager`:
|
||||
//! - Batches/deduplicates high-frequency telemetry data
|
||||
//! - Updates point monitor data in `ConnectionManager`
|
||||
//! - Broadcasts changes via WebSocket
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::connection::{ConnectionManager, PointEventSink};
|
||||
use crate::telemetry::{PointMonitorInfo, PointNewValue};
|
||||
use crate::websocket::{WebSocketManager, WsMessage};
|
||||
|
||||
const TELEMETRY_CHANNEL_CAPACITY: usize = 4096;
|
||||
|
||||
pub struct TelemetryProcessor {
|
||||
sender: mpsc::Sender<PointNewValue>,
|
||||
}
|
||||
|
||||
impl TelemetryProcessor {
|
||||
pub fn new(
|
||||
connection_manager: Arc<ConnectionManager>,
|
||||
ws_manager: Arc<WebSocketManager>,
|
||||
) -> Self {
|
||||
let (sender, mut receiver) = mpsc::channel::<PointNewValue>(TELEMETRY_CHANNEL_CAPACITY);
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(payload) = receiver.recv().await {
|
||||
// Batch: drain all pending messages, keeping only the latest per (source, handle).
|
||||
let mut latest_by_key: HashMap<(Uuid, u32), PointNewValue> = HashMap::new();
|
||||
latest_by_key.insert((payload.source_id, payload.client_handle), payload);
|
||||
|
||||
loop {
|
||||
match receiver.try_recv() {
|
||||
Ok(next) => {
|
||||
latest_by_key.insert((next.source_id, next.client_handle), next);
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Empty) => break,
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
|
||||
for point_payload in latest_by_key.into_values() {
|
||||
process_point_new_value(point_payload, &connection_manager, &ws_manager).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self { sender }
|
||||
}
|
||||
}
|
||||
|
||||
impl PointEventSink for TelemetryProcessor {
|
||||
fn send_point_new_value(&self, payload: PointNewValue) -> Result<(), String> {
|
||||
match self.sender.try_send(payload) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(mpsc::error::TrySendError::Closed(e)) => {
|
||||
Err(format!(
|
||||
"Telemetry channel closed ({e:?})"
|
||||
))
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Full(payload)) => {
|
||||
tracing::warn!(
|
||||
"Dropping PointNewValue due to full telemetry queue: source={}, handle={}",
|
||||
payload.source_id,
|
||||
payload.client_handle
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_point_new_value(
|
||||
payload: PointNewValue,
|
||||
connection_manager: &Arc<ConnectionManager>,
|
||||
ws_manager: &Arc<WebSocketManager>,
|
||||
) {
|
||||
let source_id = payload.source_id;
|
||||
let client_handle = payload.client_handle;
|
||||
|
||||
let point_id = if let Some(point_id) = payload.point_id {
|
||||
Some(point_id)
|
||||
} else {
|
||||
let status = connection_manager.get_status_read_guard().await;
|
||||
status
|
||||
.get(&source_id)
|
||||
.and_then(|s| s.client_handle_map.get(&client_handle).copied())
|
||||
};
|
||||
|
||||
let Some(point_id) = point_id else {
|
||||
tracing::warn!(
|
||||
"Point not found for source {} client_handle {}",
|
||||
source_id,
|
||||
client_handle
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
// Read the previous value from the in-memory cache.
|
||||
let (old_value, old_timestamp, value_changed) = {
|
||||
let monitor_data = connection_manager.get_point_monitor_data_read_guard().await;
|
||||
let old_monitor_info = monitor_data.get(&point_id);
|
||||
|
||||
if let Some(old_info) = old_monitor_info {
|
||||
let changed =
|
||||
old_info.value != payload.value || old_info.timestamp != payload.timestamp;
|
||||
(old_info.value.clone(), old_info.timestamp, changed)
|
||||
} else {
|
||||
(None, None, false)
|
||||
}
|
||||
};
|
||||
|
||||
let monitor = PointMonitorInfo {
|
||||
protocol: payload.protocol,
|
||||
source_id,
|
||||
point_id,
|
||||
client_handle,
|
||||
scan_mode: payload.scan_mode,
|
||||
timestamp: payload.timestamp,
|
||||
quality: payload.quality,
|
||||
value: payload.value,
|
||||
value_type: payload.value_type,
|
||||
value_text: payload.value_text,
|
||||
old_value,
|
||||
old_timestamp,
|
||||
value_changed,
|
||||
};
|
||||
|
||||
if let Err(e) = connection_manager
|
||||
.update_point_monitor_data(monitor.clone())
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to update point monitor data for point {}: {}",
|
||||
point_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
let ws_message = WsMessage::PointNewValue(monitor);
|
||||
if let Err(e) = ws_manager.send_to_public(ws_message).await {
|
||||
tracing::warn!("Failed to send WebSocket message to public room: {}", e);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,19 @@
|
|||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
FromRef, Path, State,
|
||||
},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::platform_context::PlatformContext;
|
||||
use crate::{
|
||||
connection::{BatchSetPointValueReq, BatchSetPointValueRes},
|
||||
control::runtime::UnitRuntime,
|
||||
model::EventRecord,
|
||||
telemetry::PointMonitorInfo,
|
||||
};
|
||||
|
|
@ -17,21 +24,27 @@ pub enum WsMessage {
|
|||
PointNewValue(PointMonitorInfo),
|
||||
PointSetValueBatchResult(BatchSetPointValueRes),
|
||||
EventCreated(EventRecord),
|
||||
UnitRuntimeChanged(UnitRuntime),
|
||||
AppEvent(AppWsEvent),
|
||||
}
|
||||
|
||||
/// Business-event payload carried by `WsMessage::AppEvent`.
|
||||
///
|
||||
/// Apps construct this so core stays free of business types. Frontend dispatches
|
||||
/// by `app` first, then `event_type`. `data` is opaque to core; each app
|
||||
/// documents its schema.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppWsEvent {
|
||||
pub app: String,
|
||||
pub event_type: String,
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
||||
pub enum WsClientMessage {
|
||||
AuthWrite(WsAuthWriteReq),
|
||||
PointSetValueBatch(BatchSetPointValueReq),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WsAuthWriteReq {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RoomManager {
|
||||
rooms: Arc<RwLock<HashMap<String, broadcast::Sender<WsMessage>>>>,
|
||||
|
|
@ -139,3 +152,121 @@ impl Default for WebSocketManager {
|
|||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn public_websocket_handler<S>(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<S>,
|
||||
) -> impl IntoResponse
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
PlatformContext: FromRef<S>,
|
||||
{
|
||||
let platform = PlatformContext::from_ref(&state);
|
||||
let ws_manager = platform.ws_manager.clone();
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, ws_manager, "public".to_string(), platform))
|
||||
}
|
||||
|
||||
pub async fn client_websocket_handler<S>(
|
||||
ws: WebSocketUpgrade,
|
||||
Path(client_id): Path<Uuid>,
|
||||
State(state): State<S>,
|
||||
) -> impl IntoResponse
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
PlatformContext: FromRef<S>,
|
||||
{
|
||||
let platform = PlatformContext::from_ref(&state);
|
||||
let ws_manager = platform.ws_manager.clone();
|
||||
let room_id = client_id.to_string();
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, ws_manager, room_id, platform))
|
||||
}
|
||||
|
||||
async fn handle_socket(
|
||||
mut socket: WebSocket,
|
||||
ws_manager: Arc<WebSocketManager>,
|
||||
room_id: String,
|
||||
platform: PlatformContext,
|
||||
) {
|
||||
let mut rx = ws_manager.subscribe_room(&room_id).await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
maybe_msg = socket.recv() => {
|
||||
match maybe_msg {
|
||||
Some(Ok(msg)) => {
|
||||
if matches!(msg, Message::Close(_)) {
|
||||
break;
|
||||
}
|
||||
match msg {
|
||||
Message::Text(text) => {
|
||||
match serde_json::from_str::<WsClientMessage>(&text) {
|
||||
Ok(WsClientMessage::PointSetValueBatch(payload)) => {
|
||||
let response = match platform.connection_manager.write_point_values_batch(payload).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => BatchSetPointValueRes {
|
||||
success: false,
|
||||
err_msg: Some(e),
|
||||
success_count: 0,
|
||||
failed_count: 1,
|
||||
results: vec![crate::connection::SetPointValueResItem {
|
||||
point_id: Uuid::nil(),
|
||||
success: false,
|
||||
err_msg: Some("Internal write error".to_string()),
|
||||
}],
|
||||
},
|
||||
};
|
||||
if let Err(e) = ws_manager
|
||||
.send_to_room(&room_id, WsMessage::PointSetValueBatchResult(response))
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to send PointSetValueBatchResult to room {}: {}",
|
||||
room_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Invalid websocket message in room {}: {}",
|
||||
room_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("Received WebSocket message from room {}: {:?}", room_id, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
tracing::error!("WebSocket error in room {}: {}", room_id, e);
|
||||
break;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
room_message = rx.recv() => {
|
||||
match room_message {
|
||||
Ok(message) => match serde_json::to_string(&message) {
|
||||
Ok(json_str) => {
|
||||
if socket.send(Message::Text(json_str.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to serialize websocket message: {}", e);
|
||||
}
|
||||
},
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
tracing::warn!("WebSocket room {} lagged, skipped {} messages", room_id, skipped);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ws_manager.remove_room_if_empty(&room_id).await;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,10 +160,6 @@
|
|||
|
||||
批量写点。
|
||||
|
||||
请求头:
|
||||
|
||||
- `X-Write-Key: <key>`
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# 运转系统 API
|
||||
|
||||
> 参考来源:`docs/superpowers/specs/2026-05-18-operation-system-engine-design.md`
|
||||
|
||||
## 健康检查
|
||||
|
||||
- `GET /api/health` — 返回应用名称和状态
|
||||
|
|
@ -11,5 +13,73 @@
|
|||
|
||||
## 文档
|
||||
|
||||
- `GET /api/docs/api-md` — 获取 API 文档
|
||||
- `GET /api/docs/api-md` — 获取本 API 文档
|
||||
- `GET /api/docs/readme-md` — 获取 README
|
||||
|
||||
## 平台基础接口
|
||||
|
||||
复用 `plc_platform_core::handler::platform_routes`:源 / 设备 / 点位 / 标签 / 页面。
|
||||
|
||||
## 工位配置(§9.1.1)
|
||||
|
||||
- `GET /api/station` — 列出工位(可选 `?line_code=`)
|
||||
- `POST /api/station` — 新建工位
|
||||
- `GET /api/station/{id}` — 工位详情含信号绑定
|
||||
- `PUT /api/station/{id}` — 更新工位
|
||||
- `DELETE /api/station/{id}`
|
||||
- `POST /api/station/{id}/signal` — Upsert 工位信号绑定
|
||||
- `DELETE /api/station/{id}/signal/{role}`
|
||||
|
||||
## 流程段配置(§9.1.1)
|
||||
|
||||
- `GET /api/segment`(可选 `?line_code=`)
|
||||
- `POST /api/segment`
|
||||
- `GET /api/segment/{id}`
|
||||
- `GET /api/segment/{id}/detail` — 包含 step / interlock / resource
|
||||
- `PUT /api/segment/{id}`
|
||||
- `DELETE /api/segment/{id}`
|
||||
- `GET /api/segment/{id}/step`
|
||||
- `POST /api/segment/{id}/step`
|
||||
- `PUT /api/segment/{id}/step/{step_no}`
|
||||
- `DELETE /api/segment/{id}/step/{step_no}`
|
||||
- `GET /api/segment/{id}/interlock`
|
||||
- `POST /api/segment/{id}/interlock`
|
||||
- `DELETE /api/segment/{id}/interlock/{interlock_id}`
|
||||
- `GET /api/segment/{id}/resource`
|
||||
- `PUT /api/segment/{id}/resource` — 用新的 `resource_keys` 数组整体替换
|
||||
|
||||
## 段运行控制(§9.2)
|
||||
|
||||
- `POST /api/control/segment/{id}/start-auto`
|
||||
- `POST /api/control/segment/{id}/stop-auto`
|
||||
- `POST /api/control/segment/{id}/ack-fault`
|
||||
- `POST /api/control/segment/{id}/reset` — 仅在 Blocked / Faulted / ManualAckRequired 状态允许
|
||||
- `POST /api/control/segment/batch-start-auto`
|
||||
- `POST /api/control/segment/batch-stop-auto`
|
||||
|
||||
## 运行态查询(§9.3)
|
||||
|
||||
- `GET /api/runtime/overview` — 所有段 + 资源占用快照
|
||||
- `GET /api/runtime/segment/{id}` — 单段配置 + runtime
|
||||
- `GET /api/runtime/station/{id}` — 工位信号 + 最新点位监控值
|
||||
- `GET /api/event` — 事件时间线,参数:
|
||||
- `event_type` — 精确匹配,例如 `ops.segment.fault_locked`
|
||||
- `event_type_prefix` — 前缀匹配,例如 `ops.` 拉取全部 ops 事件
|
||||
- `subject_type` / `subject_id` — 设计文档 §4.2.8 归因字段,可按段 / 工位 / 设备过滤
|
||||
- 分页参数 `page` / `page_size`
|
||||
|
||||
## WebSocket(§8.2)
|
||||
|
||||
- `GET /ws/public` — 推送
|
||||
- `point_new_value`(核心)
|
||||
- `event_created`(核心)
|
||||
- `app_event`:`{ app: "operation-system", event_type: "segment_runtime_changed", data: SegmentRuntime }`
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `SIMULATE_PLC=1` — 调试模式,引擎发出命令后通过模拟器把确认信号写回监控缓存,使段流程可在无 PLC 现场时端到端运行。
|
||||
- `OPS_SEED_TEMPLATES=1` — 应用启动时自动写入默认骨架:
|
||||
- 6 个干燥窑段(infeed / step / outfeed × 1 号 / 2 号)
|
||||
- 6 个公共段(前端码车 / 前端放车 / 前端摆渡 / 窑尾摆渡 / 卸砖 / 回车),并写入对应共享资源 key(`transfer_front` / `transfer_tail` / `unload_position` / `return_line` / `robot_arm`)
|
||||
- 关联工位(含 5 个公共工位)
|
||||
- 仅插入缺失的记录,不覆盖已有配置。设备与工位信号绑定仍需通过 CRUD API 完成。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,631 @@
|
|||
# 运转系统顺控引擎设计
|
||||
|
||||
日期:2026-05-18
|
||||
|
||||
参考来源:
|
||||
- `运转系统逻辑说明.doc`(说明书 14 章)
|
||||
- `docs/运转系统实现方案.md`(高层方案)
|
||||
- `docs/superpowers/specs/2026-04-14-dual-app-shared-core-design.md`(双应用共享核心架构)
|
||||
- 现有 `crates/app_feeder_distributor` 实现作为工程参考
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在已经搭好的 `crates/app_operation_system` 骨架内,落地说明书中规定的整线自动控制能力:
|
||||
|
||||
- 覆盖 8 个业务子系统:回车线、前端码车道、机械臂、摆渡车、1 号干燥/焙烧窑、2 号干燥/焙烧窑、窑尾下摆渡车、卸砖机位。
|
||||
- 引擎语义遵循说明书第 1.4 与 13 章:"顺序控制 + 联锁保护 + 检测信号闭环确认 + 异常停留人工恢复"。
|
||||
- 双窑线(1 号 / 2 号)采用同一套段模板,仅通过参数差异化,不写两套代码。
|
||||
- 复用 `plc_platform_core` 的接入层(OPC UA / 点位 / 设备 / 事件 / WebSocket / 日志)。
|
||||
- 不引入 `app_feeder_distributor` 的 `unit + run_time/stop_time/acc_time/bl_time` 业务模型。
|
||||
|
||||
非目标(首期):
|
||||
|
||||
- 不做规则引擎或 DSL,只支持固定 `rule_kind` 联锁判定。
|
||||
- 不做高级排程(最大化吞吐、动态优化),只做基于空位/资源占用的放行决策。
|
||||
- 不做权限/审计/历史回放。
|
||||
|
||||
## 2. 设计结论
|
||||
|
||||
| 决策 | 选择 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| 业务模型 | **station + segment + step + interlock** | 说明书是工位驱动的整线顺控,不是节拍式设备启停 |
|
||||
| `unit` 表 | **不复用** | 语义不匹配;ops 自己建 `process_segment` |
|
||||
| 引擎调度单位 | **段(segment)** | 每个 enabled segment 一个 tokio task,对齐 feeder 引擎结构 |
|
||||
| 双窑线参数化 | **同一段模板 + line_code 区分实例** | 对齐说明书第 11 章 |
|
||||
| 联锁配置 | **数据库表 + 固定 rule_kind 枚举** | 首期不引入表达式语言 |
|
||||
| WebSocket 消息扩展 | **core 保持通用通道,ops 使用业务 payload 分支** | 避免 `plc_platform_core` 反向依赖 ops 领域类型;前端仍只连一处 |
|
||||
| 报警 | **走 `event` 表 + `subject_type/subject_id` + `level=warn/error`** | 复用现有事件表,同时支持按段 / 工位查询 |
|
||||
| 公共资源互斥 | **app 内部命名锁注册表 + 租约/恢复策略** | 摆渡车 / 机械臂 / 卸砖机位等共享资源,防止 task 异常退出后长期占锁 |
|
||||
|
||||
## 3. 不沿用 feeder 模型的理由
|
||||
|
||||
`ControlUnit` 当前字段是 `run_time_sec / stop_time_sec / acc_time_sec / bl_time_sec`,语义是"运行 N 秒 → 停 M 秒 → 累计 K 秒后启动布料机 → 布料 L 秒"。
|
||||
|
||||
运转系统的核心动作完全不是这种节拍:
|
||||
- 说明书 8.2 要求"码车位到车确认 → 输送机构停止",是检测信号驱动,不是定时。
|
||||
- 说明书 10.1 要求"开门 → 门开到位确认 → 顶车 → 前位确认 → 顶车后退 → 后位确认 → 关门 → 门关到位确认",是 8 步串行带闭环。
|
||||
- 说明书 13 章明确要求"动作完成不得仅靠时间,必须结合限位、检测或反馈信号"。
|
||||
|
||||
因此引擎需要换语义:**段(segment)状态机 + 步骤(step)顺序 + 每步等待闭环信号**。
|
||||
|
||||
## 4. 领域模型
|
||||
|
||||
### 4.1 实体一览
|
||||
|
||||
```
|
||||
source ──┐
|
||||
│
|
||||
point ───┼─→ equipment
|
||||
│
|
||||
├─→ station_signal ──→ station ──┐
|
||||
│ │
|
||||
└──────────────→ segment_step ──→ process_segment ──→ segment_runtime
|
||||
│ │
|
||||
│ ├──→ segment_interlock
|
||||
│ └──→ segment_resource
|
||||
│
|
||||
└──→ action_kind (枚举)
|
||||
```
|
||||
|
||||
`source / point / equipment` 沿用平台层定义,不改动。
|
||||
|
||||
信号边界:
|
||||
|
||||
- `point.signal_role` 是设备信号角色,例如 `rem / flt / home / run / start_cmd / stop_cmd / open_cmd / close_cmd`。
|
||||
- `station_signal.signal_role` 是工位信号角色,例如 `presence / vacancy / arrived / allow_in / done / fault`。
|
||||
- 同一个 `point` 可以同时被设备角色和工位角色引用,但两者语义分开维护。
|
||||
- `vacancy` 可由独立点位绑定,也可由 `presence = false` 推导。首期通过 `station_signal.derived_from_role` 表达推导关系,避免现场必须额外提供空位点。
|
||||
|
||||
### 4.2 新增对象
|
||||
|
||||
#### 4.2.1 `station`(工位)
|
||||
|
||||
表示流程中的一个位置或交接位。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | UUID | |
|
||||
| `code` | TEXT UNIQUE | 例 `ST-DRY1-IN` |
|
||||
| `name` | TEXT | 例 "1 号干燥窑进口位" |
|
||||
| `line_code` | TEXT NULL | 例 `KILN_1` / `KILN_2` / `COMMON` |
|
||||
| `segment_code` | TEXT NULL | 用于分组(前端码车 / 双窑线 / 窑尾) |
|
||||
| `station_type` | TEXT | `load / dry_in / dry_step / dry_out / fire_in / fire_step / fire_out / transfer / unload / return` |
|
||||
| `enabled` | BOOL | |
|
||||
| `description` | TEXT NULL | |
|
||||
|
||||
#### 4.2.2 `station_signal`(工位 ↔ 信号绑定)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | UUID | |
|
||||
| `station_id` | UUID FK | |
|
||||
| `signal_role` | TEXT | `presence / vacancy / arrived / allow_in / done / fault` |
|
||||
| `point_id` | UUID FK | 绑定到具体点位 |
|
||||
| `derived_from_role` | TEXT NULL | 例 `presence`,表示由同工位其他角色反向推导 |
|
||||
| `invert_value` | BOOL | 推导或读取时是否取反,默认 false |
|
||||
| UNIQUE | (`station_id`, `signal_role`) | |
|
||||
|
||||
#### 4.2.3 `process_segment`(流程段)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | UUID | |
|
||||
| `code` | TEXT UNIQUE | 例 `SEG-DRY1-INFEED` |
|
||||
| `name` | TEXT | |
|
||||
| `segment_type` | TEXT | `front_load / robot / front_release / front_transfer / kiln_infeed / kiln_step / kiln_outfeed / tail_transfer / tail_step / unload / return` |
|
||||
| `line_code` | TEXT NULL | `KILN_1` / `KILN_2` / `COMMON` |
|
||||
| `priority` | INT | 公共资源冲突时使用 |
|
||||
| `enabled` | BOOL | |
|
||||
| `mode` | TEXT | `auto / remote_manual / local_manual / disabled` |
|
||||
| `require_manual_ack_after_fault` | BOOL | 故障解除后是否需要人工确认,默认 true |
|
||||
| `description` | TEXT NULL | |
|
||||
|
||||
模式语义:
|
||||
|
||||
- `local_manual`:现场就地优先,软件不推进自动顺控;自动运行中检测到任一相关设备 `rem=false` 时,停止当前自动段并进入人工恢复路径。
|
||||
- `remote_manual`:允许通过软件发单步 / 单设备命令,但仍必须执行设备故障、通信质量、安全链和关键门位联锁。
|
||||
- `auto`:允许 supervisor 自动推进段状态机。
|
||||
- `disabled`:段任务不启动;已运行任务在下一次配置重载后退出。
|
||||
|
||||
#### 4.2.4 `segment_step`(段步骤)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | UUID | |
|
||||
| `segment_id` | UUID FK | |
|
||||
| `step_no` | INT | 序号 |
|
||||
| `step_code` | TEXT | 步骤代号 |
|
||||
| `action_kind` | TEXT | 见下方动作模板表 |
|
||||
| `target_equipment_id` | UUID NULL FK | 例如顶车机 |
|
||||
| `target_station_id` | UUID NULL FK | 例如目标摆渡位 |
|
||||
| `confirm_signal_role` | TEXT NULL | 等待哪个信号角色为真 |
|
||||
| `confirm_point_id` | UUID NULL FK | 直接指定确认点位(覆盖 role) |
|
||||
| `expected_value` | BOOL | 信号到位的期望值(默认 true) |
|
||||
| `timeout_ms` | INT | 超时即报警转 Faulted |
|
||||
| `command_role` | TEXT NULL | 设备命令角色,例 `start_cmd / open_cmd / forward_cmd` |
|
||||
| `stop_command_role` | TEXT NULL | 到位或异常时需要发出的停止命令角色,例 `stop_cmd` |
|
||||
| `pulse_ms` | INT NULL | 脉冲命令宽度;为空时按 action 默认值 |
|
||||
| `hold_until_confirm` | BOOL | true 表示命令保持到确认信号或故障;false 表示脉冲后等待 |
|
||||
| `cancel_on_fault` | BOOL | 故障 / 模式切换 / 通信异常时是否执行停止命令,默认 true |
|
||||
| `next_step_no_on_success` | INT NULL | 成功后跳转;为空表示顺序进入下一 step |
|
||||
| `next_step_no_on_failure` | INT NULL | 失败后跳转;首期通常为空并进入 Faulted |
|
||||
| `on_timeout` | TEXT | `fault / retry / block`,首期默认 `fault` |
|
||||
| `description` | TEXT NULL | |
|
||||
| UNIQUE | (`segment_id`, `step_no`) | |
|
||||
|
||||
`action_kind` 枚举(首期):
|
||||
|
||||
| 值 | 含义 |
|
||||
| --- | --- |
|
||||
| `open_door` | 开门:向门机 `open_cmd` 发脉冲 |
|
||||
| `close_door` | 关门 |
|
||||
| `push_forward` | 顶车机前进 |
|
||||
| `push_retract` | 顶车机后退复位 |
|
||||
| `pull_run` | 拉引机拉车 |
|
||||
| `pull_retract` | 拉引机复位 |
|
||||
| `transfer_move_to` | 摆渡车移动到目标工位 |
|
||||
| `step_once` | 节拍步进机执行一步 |
|
||||
| `robot_permit` | 允许机械臂自动作业 |
|
||||
| `robot_release` | 允许码车道放车 |
|
||||
| `wait_signal` | 不发命令,仅等待 `confirm_*` |
|
||||
| `pulse_cmd` | 通用脉冲命令(fallback) |
|
||||
|
||||
动作执行策略:
|
||||
|
||||
- 对 `open_door / close_door / robot_permit` 等短命令,默认 `pulse_ms=300`,命令发出后等待确认信号。
|
||||
- 对输送、顶车、拉引、步进等持续动作,默认 `hold_until_confirm=true`,到位后执行 `stop_command_role`。
|
||||
- 对故障、急停、通信质量异常、自动切就地等中断场景,若 `cancel_on_fault=true`,先发停止 / 复位命令,再进入 `Faulted` 或 `ManualAckRequired`。
|
||||
|
||||
#### 4.2.5 `segment_interlock`(段联锁)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | UUID | |
|
||||
| `segment_id` | UUID FK | |
|
||||
| `applies_to` | TEXT | `start_allow / start_deny / run_halt` |
|
||||
| `rule_kind` | TEXT | 见下方 |
|
||||
| `point_id` | UUID NULL FK | |
|
||||
| `station_id` | UUID NULL FK | |
|
||||
| `equipment_id` | UUID NULL FK | |
|
||||
| `expected_value` | BOOL NULL | |
|
||||
| `description` | TEXT NULL | |
|
||||
|
||||
`rule_kind` 枚举(首期):
|
||||
|
||||
- `point_eq` —— 指定 point 的值等于 `expected_value`
|
||||
- `station_vacant` —— 工位空(绑定的 `vacancy` 信号 = true 且 `presence` = false)
|
||||
- `station_occupied` —— 工位有车
|
||||
- `equipment_origin` —— 设备在原位(角色 `home`)
|
||||
- `equipment_no_fault` —— 设备无故障(`flt` = false)
|
||||
- `equipment_remote` —— 设备远程(`rem` = true)
|
||||
- `safety_chain_ok` —— 安全链路正常
|
||||
|
||||
未来可扩展 `expression` 类型,但首期不引入。
|
||||
|
||||
#### 4.2.6 `segment_runtime`(段运行态,内存)
|
||||
|
||||
不落库(与 feeder `UnitRuntime` 一致,重启重置):
|
||||
|
||||
```rust
|
||||
pub enum SegmentState {
|
||||
Idle,
|
||||
Checking,
|
||||
Executing,
|
||||
Confirming,
|
||||
Resetting,
|
||||
Completed,
|
||||
Blocked,
|
||||
Faulted,
|
||||
ManualAckRequired,
|
||||
}
|
||||
|
||||
pub struct SegmentRuntime {
|
||||
pub segment_id: Uuid,
|
||||
pub state: SegmentState,
|
||||
pub auto_enabled: bool,
|
||||
pub current_step_no: Option<i32>,
|
||||
pub step_started_at: Option<DateTime<Utc>>,
|
||||
pub last_completed_at: Option<DateTime<Utc>>,
|
||||
pub blocked_reason: Option<String>,
|
||||
pub fault_message: Option<String>,
|
||||
pub manual_ack_required: bool,
|
||||
pub comm_locked: bool,
|
||||
pub rem_local: bool,
|
||||
pub held_resources: Vec<String>,
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2.7 `segment_resource`(段资源声明)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `segment_id` | UUID FK | |
|
||||
| `resource_key` | TEXT | 例 `transfer_front / transfer_tail / robot_arm / unload_position / return_line` |
|
||||
| UNIQUE | (`segment_id`, `resource_key`) | |
|
||||
|
||||
#### 4.2.8 `event` 表归因扩展
|
||||
|
||||
现有 `event` 表保留 `unit_id / equipment_id / source_id`,为了支持 ops 按段、工位检索,新增通用归因字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `subject_type` | TEXT NULL | `segment / station / equipment / source / platform` |
|
||||
| `subject_id` | UUID NULL | 对应业务对象 ID |
|
||||
|
||||
ops 事件写入规则:
|
||||
|
||||
- 段级事件:`subject_type='segment'`,`subject_id=segment_id`。
|
||||
- 工位状态事件:`subject_type='station'`,`subject_id=station_id`。
|
||||
- 设备动作事件:优先保留 `equipment_id`,同时可按上下文设置 `subject_type='segment'`。
|
||||
|
||||
### 4.3 双窑线参数化
|
||||
|
||||
不写两套硬编码逻辑。1 号与 2 号窑线的差异由:
|
||||
|
||||
- `process_segment.line_code`(`KILN_1` / `KILN_2`)
|
||||
- `segment_step.target_equipment_id` 与 `target_station_id`(指向各自的门机、顶车机、工位)
|
||||
- `segment_interlock.point_id` / `station_id`(指向各自工位的检测点)
|
||||
|
||||
承载。引擎读到的就是统一的 step 列表,与窑线无关。
|
||||
|
||||
## 5. 顺控引擎设计
|
||||
|
||||
### 5.1 结构(与 feeder 对齐)
|
||||
|
||||
```
|
||||
crates/app_operation_system/src/
|
||||
app.rs // AppState 接入 segment_runtime + event_manager + resource_registry
|
||||
router.rs
|
||||
event.rs // AppEvent(ops.*)
|
||||
control/
|
||||
mod.rs
|
||||
engine.rs // supervisor + per-segment task
|
||||
runtime.rs // SegmentRuntime / SegmentRuntimeStore
|
||||
state.rs // SegmentState enum
|
||||
step_executor.rs // 按 action_kind 调度
|
||||
interlock.rs // 通用允许/禁止/停机判定
|
||||
resource.rs // 摆渡车 / 机械臂 / 卸砖位 互斥
|
||||
simulate.rs // 开发态信号回灌
|
||||
handler/
|
||||
doc.rs (已存在)
|
||||
station.rs // CRUD + 信号绑定
|
||||
segment.rs // CRUD + step / interlock 配置
|
||||
control.rs // 段启停 / 手动动作 / 故障确认
|
||||
runtime.rs // overview / segment detail / station detail
|
||||
```
|
||||
|
||||
### 5.2 段状态机
|
||||
|
||||
对应说明书 13.6:
|
||||
|
||||
| SegmentState | 含义 | 出口 |
|
||||
| --- | --- | --- |
|
||||
| `Idle` | 等待 auto 启动 | → `Checking` |
|
||||
| `Checking` | 评估 `start_allow` / `start_deny` 联锁 | 通过 → `Executing`;否则 → `Blocked` |
|
||||
| `Executing` | 已发出当前 step 的命令 | → `Confirming` |
|
||||
| `Confirming` | 等待 `confirm_signal` 到位 | 收到 → 下一步;超时 → `Faulted` |
|
||||
| `Resetting` | 等待执行机构复位(如顶车机后退) | → 下一步或 `Completed` |
|
||||
| `Completed` | 段完成,输出完成信号 | 回 `Idle`(自动循环段) |
|
||||
| `Blocked` | 允许条件不满足 | 条件再次满足 → `Checking` |
|
||||
| `Faulted` | 故障或超时 | 故障解除 + 满足复位 → `ManualAckRequired` 或 `Idle` |
|
||||
| `ManualAckRequired` | 等待人工确认 | API ack → `Idle` |
|
||||
|
||||
### 5.3 段内执行循环
|
||||
|
||||
伪代码:
|
||||
|
||||
```
|
||||
loop {
|
||||
reload segment + steps + interlocks
|
||||
run check_interlocks(state, run_halt) // 运行中停机检测
|
||||
match state {
|
||||
Idle if auto_enabled => state = Checking,
|
||||
Checking => {
|
||||
if pass(start_allow) && !any(start_deny) {
|
||||
step = first_step
|
||||
state = Executing
|
||||
} else {
|
||||
blocked_reason = ...
|
||||
state = Blocked
|
||||
}
|
||||
}
|
||||
Executing => {
|
||||
execute(step.action_kind, step.target_*) // 发命令
|
||||
state = Confirming
|
||||
}
|
||||
Confirming => {
|
||||
wait_signal(step.confirm_*, step.timeout_ms)
|
||||
on timeout → fault / retry / block by step.on_timeout
|
||||
on ok → next_step_no_on_success or next step or Completed
|
||||
}
|
||||
Faulted => break and wait manual recovery
|
||||
...
|
||||
}
|
||||
notify or fault_tick
|
||||
}
|
||||
```
|
||||
|
||||
`wait_signal` 复用与 feeder `wait_phase` 类似的 `tokio::select! { sleep_until(deadline), notify, fault_tick }` 模式,但终止条件是"绑定信号到达期望值"而非时间到。
|
||||
|
||||
### 5.4 step_executor
|
||||
|
||||
集中处理 `action_kind` 到具体写点动作:
|
||||
|
||||
- 短命令类 `action_kind` 调 `plc_platform_core::control::command::send_pulse_command`。
|
||||
- 持续命令类 `action_kind` 先写 `command_role`,确认到位、超时或故障中断时按 `stop_command_role` 收尾。
|
||||
- `transfer_move_to`:写目标工位编号到摆渡车定位命令点位,等待 `arrived` 信号。
|
||||
- `wait_signal`:不发命令。
|
||||
- 各设备的 `start_cmd / stop_cmd / open_cmd / close_cmd` 信号角色复用 feeder 已有的 `signal_role` 命名空间,equipment 表无需新表结构。
|
||||
|
||||
命令执行前必须重新检查:
|
||||
|
||||
- 设备 `rem=true`
|
||||
- 设备 `flt=false`
|
||||
- 命令点与确认点 `quality=Good`
|
||||
- 当前段仍处于允许执行模式
|
||||
- 当前 step 仍是 runtime 中的 `current_step_no`
|
||||
|
||||
## 6. 联锁与异常
|
||||
|
||||
### 6.1 联锁判定顺序(对齐说明书 8.1 / 13)
|
||||
|
||||
1. 通信质量(任一绑定点 quality != Good) → `comm_locked`
|
||||
2. 就地 / 远程状态(`rem=false`)→ 停止自动并转人工恢复
|
||||
3. 安全联锁 / 急停 → `Faulted`
|
||||
4. 设备故障(`flt` = true) → `Faulted`
|
||||
5. 门位联锁
|
||||
6. 机械臂联锁
|
||||
7. 工艺允许条件(空位 / 到位)
|
||||
8. 普通顺控条件
|
||||
|
||||
高优先级不满足时低优先级不再判断。
|
||||
|
||||
### 6.2 通用允许检查(自动注入到每段)
|
||||
|
||||
每段无论是否有显式 `segment_interlock`,引擎都执行以下通用检查(说明书 13.1):
|
||||
|
||||
- 目标工位空位
|
||||
- 本工位有车或动作前提
|
||||
- 执行机构原位
|
||||
- 设备无故障
|
||||
- 设备处于远程
|
||||
- 信号质量正常
|
||||
- 段引用的资源未被占用
|
||||
|
||||
### 6.3 异常恢复(说明书 13.5)
|
||||
|
||||
- 故障优先停止当前 step 的命令。
|
||||
- `Faulted` 保留 `current_step_no`,不跳步。
|
||||
- `remote_manual` 下允许人工执行复位动作,但复位动作仍执行安全、故障、门位和通信检查。
|
||||
- 故障物理消失后:
|
||||
- 若 `require_manual_ack_after_fault`(默认 true) → `ManualAckRequired`
|
||||
- 否则自动回 `Idle`。
|
||||
- `POST /api/control/segment/{id}/ack-fault` 用于人工确认。
|
||||
|
||||
## 7. 公共资源调度
|
||||
|
||||
说明书 3.3 / 3.4 指出:前端码车系统、窑尾摆渡、回车线、卸砖线为公共段,1 号 / 2 号窑线在此处汇合。
|
||||
|
||||
实现:
|
||||
|
||||
```rust
|
||||
pub struct ResourceRegistry {
|
||||
inner: RwLock<HashMap<String, ResourceLease>>,
|
||||
}
|
||||
|
||||
pub struct ResourceLease {
|
||||
pub owner_segment_id: Uuid,
|
||||
pub acquired_at: DateTime<Utc>,
|
||||
pub heartbeat_at: DateTime<Utc>,
|
||||
}
|
||||
```
|
||||
|
||||
资源 key 示例:`transfer_front` / `transfer_tail` / `robot_arm` / `unload_position` / `return_line`。
|
||||
|
||||
段配置中以新表 `segment_resource(segment_id, resource_key)` 声明所需资源;段进入 `Executing` 前必须 `try_acquire`,进入 `Completed` 时 `release`。冲突时停留 `Blocked`,附 `blocked_reason = "resource_busy: transfer_front"`。
|
||||
|
||||
资源恢复策略:
|
||||
|
||||
- 资源持有段每个状态循环刷新 `heartbeat_at`。
|
||||
- 若 owner task 已退出、段被禁用、或 owner 已回到 `Idle/Completed`,supervisor 可回收租约。
|
||||
- `Faulted` 时是否释放资源按资源类型决定:机械臂区、卸砖位等可释放;摆渡车正在载车时不释放,必须人工确认或到达安全位后释放。
|
||||
- 资源等待超时只报警和进入 `Blocked`,不抢占低优先级段。首期不做死锁自动解除。
|
||||
|
||||
## 8. 事件与 WebSocket
|
||||
|
||||
### 8.1 业务事件命名空间 `ops.*`
|
||||
|
||||
| event_type | level |
|
||||
| --- | --- |
|
||||
| `ops.segment.auto_started` | info |
|
||||
| `ops.segment.auto_stopped` | info |
|
||||
| `ops.segment.step_advanced` | info |
|
||||
| `ops.segment.completed` | info |
|
||||
| `ops.segment.blocked` | warn |
|
||||
| `ops.segment.fault_locked` | error |
|
||||
| `ops.segment.fault_acked` | info |
|
||||
| `ops.segment.comm_locked` | warn |
|
||||
| `ops.segment.comm_recovered` | info |
|
||||
| `ops.station.state_changed` | info |
|
||||
| `ops.alarm.action_timeout` | error |
|
||||
| `ops.alarm.signal_conflict` | error |
|
||||
| `ops.alarm.resource_busy` | warn |
|
||||
|
||||
所有事件经 `record_event` 落 `event` 表(复用平台机制)。
|
||||
|
||||
### 8.2 WebSocket 消息扩展
|
||||
|
||||
不把 ops 的 `SegmentRuntime` 类型放进 core。`plc_platform_core::websocket::WsMessage` 增加一个通用业务消息分支,业务 payload 由 app crate 构造:
|
||||
|
||||
```rust
|
||||
pub enum WsMessage {
|
||||
// 已有 ...
|
||||
AppEvent(AppWsEvent),
|
||||
}
|
||||
|
||||
pub struct AppWsEvent {
|
||||
pub app: String, // "operation-system"
|
||||
pub event_type: String, // "segment_runtime_changed" / "station_state_changed"
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
```
|
||||
|
||||
ops 侧约定:
|
||||
|
||||
- `event_type="segment_runtime_changed"`:`data` 序列化 `SegmentRuntime`。
|
||||
- `event_type="station_state_changed"`:`data` 包含 `station_id / presence / vacancy / arrived / updated_at`。
|
||||
- feeder 前端忽略未知 `AppEvent` 或非本 app 的消息;ops 前端只处理 `app="operation-system"`。
|
||||
|
||||
> 这样仍保留单一 websocket 入口,但 core 不需要知道 ops 的领域模型。
|
||||
|
||||
## 9. API 设计
|
||||
|
||||
### 9.1 配置 API
|
||||
|
||||
```
|
||||
GET /api/station
|
||||
POST /api/station
|
||||
GET /api/station/{id}
|
||||
PUT /api/station/{id}
|
||||
DELETE /api/station/{id}
|
||||
POST /api/station/{id}/signal // 绑定信号
|
||||
DELETE /api/station/{id}/signal/{role}
|
||||
|
||||
GET /api/segment
|
||||
POST /api/segment
|
||||
GET /api/segment/{id}
|
||||
GET /api/segment/{id}/detail // 含 step / interlock / resource
|
||||
PUT /api/segment/{id}
|
||||
DELETE /api/segment/{id}
|
||||
POST /api/segment/{id}/step
|
||||
PUT /api/segment/{id}/step/{step_no}
|
||||
DELETE /api/segment/{id}/step/{step_no}
|
||||
POST /api/segment/{id}/interlock
|
||||
DELETE /api/segment/{id}/interlock/{interlock_id}
|
||||
```
|
||||
|
||||
### 9.2 控制 API
|
||||
|
||||
```
|
||||
POST /api/control/segment/{id}/start-auto
|
||||
POST /api/control/segment/{id}/stop-auto
|
||||
POST /api/control/segment/{id}/reset // 强制回 Idle,仅在 Faulted/Blocked 状态可用
|
||||
POST /api/control/segment/{id}/ack-fault
|
||||
POST /api/control/segment/{id}/manual-step // remote_manual 下单步执行
|
||||
POST /api/control/segment/batch-start-auto
|
||||
POST /api/control/segment/batch-stop-auto
|
||||
|
||||
POST /api/control/equipment/{id}/manual-action // remote_manual 下单设备动作,仍执行联锁
|
||||
```
|
||||
|
||||
### 9.3 运行态 API
|
||||
|
||||
```
|
||||
GET /api/runtime/overview // 所有段 + 关键工位 + 报警计数
|
||||
GET /api/runtime/segment/{id}
|
||||
GET /api/runtime/station/{id}
|
||||
GET /api/event?type=ops.*
|
||||
```
|
||||
|
||||
## 10. 前端
|
||||
|
||||
复用 `web/core` 的源码、点位、设备、事件、日志、文档抽屉。
|
||||
|
||||
`web/ops/` 增加:
|
||||
|
||||
- 总览页:双窑线 + 公共段流程图(首版静态 SVG + 区域绑定段 / 工位状态)
|
||||
- 段卡片列表:展示 `state / current_step / blocked_reason / fault_message`
|
||||
- 工位状态视图:有车 / 空位 / 到位
|
||||
- 配置页:站点 / 段 / step / interlock 表格 + 表单
|
||||
- 手动操作:段启停 / 故障确认 / 复位
|
||||
|
||||
WebSocket 订阅 `AppEvent(app="operation-system")`,按 `event_type` 分发 `segment_runtime_changed` 和 `station_state_changed` 实时刷新。
|
||||
|
||||
## 11. 复用 vs 新增对照
|
||||
|
||||
| 模块 | 来源 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `plc_platform_core::connection` | 复用 | OPC UA 读写 |
|
||||
| `plc_platform_core::control::command::send_pulse_command` | 复用 | 所有动作命令底层 |
|
||||
| `plc_platform_core::event::record_event` + `EventInsert` | 复用 | 事件落库 |
|
||||
| `plc_platform_core::event::MetadataCache` | 复用 + 扩展 | 通用化为按 `(table, id)` 查 code;feeder 用 unit/equipment,ops 加 station/segment |
|
||||
| `plc_platform_core::websocket::WsMessage` | 重构 | 删除 `UnitRuntimeChanged`(feeder 业务),新增通用 `AppEvent(AppWsEvent)`;feeder 和 ops 都走 AppEvent |
|
||||
| `plc_platform_core::handler::platform_routes` | 复用 | source / point / equipment / tag / page |
|
||||
| `plc_platform_core::model::ControlUnit` | **迁出 core** | P-1 阶段下放到 feeder;语义本就是 feeder 业务 |
|
||||
| `plc_platform_core::control::runtime::{UnitRuntime, ControlRuntimeStore}` | **迁出 core** | 同上,含 `DistributorRunning` 这种 feeder 专属状态 |
|
||||
| `plc_platform_core::service::control` unit CRUD | **迁出 core** | 下放到 feeder;event 查询留 core |
|
||||
| `app_feeder_distributor::control::*` | **不复用** | 结构参考 |
|
||||
|
||||
> **P-1 阶段说明**:上表中的"迁出 core"是清理动作,发生在 P0 之前。详见 §12。
|
||||
|
||||
## 12. 阶段计划
|
||||
|
||||
| 阶段 | 目标 | 主要工作 |
|
||||
| --- | --- | --- |
|
||||
| **P-1 Core 业务清理** | core 不再持有 feeder 业务模型 | 把 `UnitRuntime / UnitRuntimeState / ControlRuntimeStore / ControlUnit / unit CRUD / WsMessage::UnitRuntimeChanged` 从 `plc_platform_core` 迁到 `app_feeder_distributor`;`WsMessage` 新增 `AppEvent(AppWsEvent)` 分支并删除 `UnitRuntimeChanged`;feeder 引用全部调整;前端 ws 客户端按 `app + event_type` 分发;`MetadataCache` 通用化为 `entity_code(table, id)`。零行为变更,feeder 通过现有 smoke test |
|
||||
| **P0 骨架对齐** | `app_operation_system` 与 feeder 在依赖、AppState、bootstrap、tray、启动/退出链路对齐 | Cargo.toml 补依赖;AppState 加 `EventManager` + `SegmentRuntimeStore` + `ResourceRegistry`;启动接 `connect_all_enabled_sources`;启动 engine supervisor;退出时断开数据源 |
|
||||
| **P1 数据库迁移 & 模型** | ops 配置表 + event 归因字段 + Rust model | 新 migration `2026-05-1x_create_operation_system.sql`;新增 `station / station_signal / process_segment / segment_step / segment_interlock / segment_resource`;扩展 `event.subject_type/subject_id`;`app_operation_system::model` 模块 |
|
||||
| **P2 配置 API** | 站点 / 段 / step / interlock CRUD | `service::station / segment`;handler;router |
|
||||
| **P3 引擎 MVP** | 跑通 1 个段端到端(前端码车位进车段,说明书 8.2) | `engine`、`step_executor`、`interlock`、`runtime`;通用 `AppEvent` WebSocket 推送 |
|
||||
| **P4 动作模板补全** | 覆盖 8 章 + 10 章典型动作 | 各 `action_kind` 实现 + simulate 反馈 |
|
||||
| **P5 双窑线段模板化** | 通过段配置实现 1 号 / 2 号窑线 4 段(进口 / 内前移 / 出口) | 段配置 seed;端到端跑通 |
|
||||
| **P6 资源调度** | 公共段互斥 | `ResourceRegistry`;`segment_resource` 表;Blocked 路径完善 |
|
||||
| **P7 公共段** | 摆渡车 / 卸砖 / 回车线 | 段实例 + 段间交接 |
|
||||
| **P8 报警 & 异常恢复** | 超时报警、信号冲突、人工确认完整链路 | `AppEvent::Alarm*`;ack-fault API |
|
||||
| **P9 前端监控页** | 段卡片 + 工位状态 + 流程图 | `web/ops/html` + JS |
|
||||
| **P10 配置前端** | 段 / 工位 / 联锁可视化配置 | `web/ops/html` 表格表单 |
|
||||
|
||||
每阶段都要求:
|
||||
|
||||
- `cargo build -p app_operation_system` 通过
|
||||
- 至少 1 个单元测试或 smoke test
|
||||
- 不破坏 `app_feeder_distributor` 编译
|
||||
|
||||
## 13. 风险与约束
|
||||
|
||||
### 13.1 主要风险
|
||||
|
||||
- **P-1 迁移破坏 feeder**:从 core 把 unit 模型迁到 feeder 时容易漏改 import 或 ws 客户端调用。要求迁移单独成 commit,feeder 启动 + 单元测试 + ws 推送链路逐项验证。
|
||||
- **现场 I/O 清单缺失**:说明书描述了逻辑关系但未明确每个工位 / 设备对应的具体点位。落地前必须补 I/O 对照表。
|
||||
- **段切分粒度**:段切得太细 → 状态机膨胀;切得太粗 → 段内步骤过多。首期建议按说明书章节级切(一节 = 一段)。
|
||||
- **WebSocket 领域边界**:不得把 `SegmentRuntime` 放入 core,否则 core 会反向依赖 ops 业务模型;采用通用 `AppEvent` payload。
|
||||
- **公共资源死锁**:例如摆渡车被段 A 占用、段 A 又等卸砖位空(被段 B 占用)。首期通过段优先级与超时报警缓解,不引入死锁检测。
|
||||
- **持续命令收尾**:输送、顶车、拉引等不是纯脉冲动作,必须在超时、故障和模式切换时明确停止命令。
|
||||
|
||||
### 13.2 约束
|
||||
|
||||
- 首期不做规则引擎,所有联锁靠固定 `rule_kind` 枚举。
|
||||
- 首期段 / step 改动不做热加载——supervisor 每 10s 重读配置,与 feeder 一致。
|
||||
- 首期 `segment_runtime` 不持久化,重启全部回 `Idle`。
|
||||
- 首期不做资源抢占;资源冲突只阻塞、报警和等待人工处理。
|
||||
|
||||
## 14. 验收标准
|
||||
|
||||
完成 P0–P5 后应达到:
|
||||
|
||||
- 仓库新增 6 张 ops 业务配置表,并扩展 `event.subject_type/subject_id`,与 feeder 业务表互不干扰。
|
||||
- `app_operation_system` 可独立编译为 exe,可启动并连接 OPC UA 数据源。
|
||||
- 启动后具备 `EventManager`、`SegmentRuntimeStore`、`ResourceRegistry`、engine supervisor,退出时可断开数据源。
|
||||
- 至少 1 条段(例如 2 号干燥窑进口段,含 8 步)可通过配置驱动跑通:
|
||||
- 自动启停
|
||||
- 步骤顺序推进
|
||||
- 闭环信号确认
|
||||
- 持续动作到位后停止命令
|
||||
- 故障停步 + 人工确认
|
||||
- WebSocket 通过 `AppEvent(app="operation-system")` 推送段运行态变化、工位状态变化。
|
||||
- 前端可见段卡片与当前步骤进度。
|
||||
- `event` 表能按 `ops.*` 和 `subject_type/subject_id` 查到全链路事件。
|
||||
|
||||
完成 P6–P10 后应达到:
|
||||
|
||||
- 1 号 / 2 号窑线全部 6 段(进口 / 内前移 / 出口 × 2 窑)跑通。
|
||||
- 公共段(前端码车、摆渡车、窑尾、卸砖、回车)跑通。
|
||||
- 报警分类齐全(说明书 13.4 全部 10 类)。
|
||||
- 监控前端 + 配置前端可用。
|
||||
|
||||
## 15. 后续可演进项(非首期)
|
||||
|
||||
- 联锁 `expression` 类型:引入简单布尔表达式语言,替代 `rule_kind` 枚举。
|
||||
- 段历史持久化:将每段每次完成 / 故障写入 `segment_run_history`,支持时间线回放。
|
||||
- 现场调试视图:模拟点位值、单步推进、跳步授权(带操作员签名)。
|
||||
- 公共能力下沉:若后续出现第三套类似业务,再把 segment 引擎抽到 `plc_platform_core::control::segment`。
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
-- Operation-system business tables (design doc §4 / §12 P1).
|
||||
-- Six ops configuration tables + event attribution columns.
|
||||
|
||||
-- 1. station: 工位
|
||||
CREATE TABLE station (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
line_code TEXT,
|
||||
segment_code TEXT,
|
||||
station_type TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (code)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_station_line_code ON station(line_code);
|
||||
CREATE INDEX idx_station_segment_code ON station(segment_code);
|
||||
|
||||
-- 2. station_signal: 工位 ↔ 信号绑定
|
||||
CREATE TABLE station_signal (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
station_id UUID NOT NULL REFERENCES station(id) ON DELETE CASCADE,
|
||||
signal_role TEXT NOT NULL,
|
||||
point_id UUID REFERENCES point(id) ON DELETE SET NULL,
|
||||
derived_from_role TEXT,
|
||||
invert_value BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (station_id, signal_role)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_station_signal_point_id ON station_signal(point_id);
|
||||
|
||||
-- 3. process_segment: 流程段
|
||||
CREATE TABLE process_segment (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
segment_type TEXT NOT NULL,
|
||||
line_code TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
mode TEXT NOT NULL DEFAULT 'disabled',
|
||||
require_manual_ack_after_fault BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (code)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_process_segment_line_code ON process_segment(line_code);
|
||||
CREATE INDEX idx_process_segment_enabled ON process_segment(enabled);
|
||||
|
||||
-- 4. segment_step: 段步骤
|
||||
CREATE TABLE segment_step (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
segment_id UUID NOT NULL REFERENCES process_segment(id) ON DELETE CASCADE,
|
||||
step_no INTEGER NOT NULL,
|
||||
step_code TEXT NOT NULL,
|
||||
action_kind TEXT NOT NULL,
|
||||
target_equipment_id UUID REFERENCES equipment(id) ON DELETE SET NULL,
|
||||
target_station_id UUID REFERENCES station(id) ON DELETE SET NULL,
|
||||
confirm_signal_role TEXT,
|
||||
confirm_point_id UUID REFERENCES point(id) ON DELETE SET NULL,
|
||||
expected_value BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
timeout_ms INTEGER NOT NULL DEFAULT 30000 CHECK (timeout_ms > 0),
|
||||
command_role TEXT,
|
||||
stop_command_role TEXT,
|
||||
pulse_ms INTEGER CHECK (pulse_ms IS NULL OR pulse_ms > 0),
|
||||
hold_until_confirm BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
cancel_on_fault BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
next_step_no_on_success INTEGER,
|
||||
next_step_no_on_failure INTEGER,
|
||||
on_timeout TEXT NOT NULL DEFAULT 'fault',
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (segment_id, step_no)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_segment_step_segment_id ON segment_step(segment_id);
|
||||
|
||||
-- 5. segment_interlock: 段联锁
|
||||
CREATE TABLE segment_interlock (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
segment_id UUID NOT NULL REFERENCES process_segment(id) ON DELETE CASCADE,
|
||||
applies_to TEXT NOT NULL,
|
||||
rule_kind TEXT NOT NULL,
|
||||
point_id UUID REFERENCES point(id) ON DELETE SET NULL,
|
||||
station_id UUID REFERENCES station(id) ON DELETE SET NULL,
|
||||
equipment_id UUID REFERENCES equipment(id) ON DELETE SET NULL,
|
||||
expected_value BOOLEAN,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_segment_interlock_segment_id ON segment_interlock(segment_id);
|
||||
|
||||
-- 6. segment_resource: 段资源声明
|
||||
CREATE TABLE segment_resource (
|
||||
segment_id UUID NOT NULL REFERENCES process_segment(id) ON DELETE CASCADE,
|
||||
resource_key TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (segment_id, resource_key)
|
||||
);
|
||||
|
||||
-- 7. event attribution: subject_type / subject_id (design doc §4.2.8)
|
||||
ALTER TABLE event
|
||||
ADD COLUMN subject_type TEXT,
|
||||
ADD COLUMN subject_id UUID;
|
||||
|
||||
CREATE INDEX idx_event_subject ON event(subject_type, subject_id);
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
# 怎么运行 operation-system
|
||||
|
||||
## 1. 准备数据库
|
||||
|
||||
应用启动**不会自动执行迁移**(`db.rs:13` 注释:「如有迁移请手动执行」),第一次启动前要把 `migrations/` 目录里的 SQL 跑到 Postgres 里:
|
||||
|
||||
```bash
|
||||
# 任选一种
|
||||
sqlx migrate run --source migrations
|
||||
# 或用 psql / DataGrip 直接执行 migrations/*.sql 按文件名升序
|
||||
```
|
||||
|
||||
仓库根目录已有 `.env`,指向远程 `postgresql://postgres:zcDsj%402024@10.0.11.51:5432/gateway`。要本地跑就在 `.env` 里改 `DATABASE_URL`。
|
||||
|
||||
## 2. 设置环境变量
|
||||
|
||||
`.env` 会被 `bootstrap::init_process` 通过 `dotenv` 自动加载,所以直接改 `.env` 或在 shell 里 export 都行。
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `DATABASE_URL` | **必填** | Postgres 连接串 |
|
||||
| `OPS_SERVER_HOST` | `127.0.0.1` | 监听 host |
|
||||
| `OPS_SERVER_PORT` | `3100` | 监听端口 |
|
||||
| `RUST_LOG` | — | 建议 `info` |
|
||||
| `OPS_SEED_TEMPLATES` | 关 | 设为 `1` 启动时自动写入 12 段 + 11 工位骨架 |
|
||||
| `SIMULATE_PLC` | 关 | 设为 `1` 引擎发命令后自动回写确认信号,无需 PLC 也能跑通段 |
|
||||
|
||||
## 3. 启动
|
||||
|
||||
```bash
|
||||
# 开发态
|
||||
cargo run -p app_operation_system
|
||||
|
||||
# 或带种子 + 模拟器,方便首次端到端验证
|
||||
OPS_SEED_TEMPLATES=1 SIMULATE_PLC=1 cargo run -p app_operation_system
|
||||
|
||||
# 打 release exe
|
||||
cargo build -p app_operation_system --release
|
||||
# 产物在 target\release\app_operation_system.exe
|
||||
```
|
||||
|
||||
启动日志会打印 `Starting operation-system server at http://127.0.0.1:3100`。
|
||||
|
||||
## 4. 验证
|
||||
|
||||
- 健康检查 — `curl http://127.0.0.1:3100/api/health` → 返回 `operation-system:ok`
|
||||
- 段总览 — `curl http://127.0.0.1:3100/api/runtime/overview`
|
||||
- 前端 — 浏览器打开 `http://127.0.0.1:3100/ui/`,左上角两个 tab:
|
||||
- **运行监控** — 段卡片 + WebSocket 实时刷新 + 启停 / 故障确认 / 复位
|
||||
- **段 / 工位配置** — 工位 CRUD(含信号绑定)和段 CRUD(含步骤 / 联锁 / 资源声明)
|
||||
|
||||
## 5. 端到端冒烟(无 PLC)
|
||||
|
||||
1. 用 `OPS_SEED_TEMPLATES=1 SIMULATE_PLC=1` 启动。
|
||||
2. 浏览器进 **段 / 工位配置**,挑一个段(例如 `SEG-DRY1-INFEED`),把 `mode` 改成 `auto`。
|
||||
3. 切到 **运行监控**,点该卡片的「启动」。
|
||||
4. 看着卡片上 state 从 `idle` → `checking` → `executing` → `confirming` → 下一步循环(因为 `SIMULATE_PLC` 在每步派发后会注入确认信号)。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 与 feeder 同时跑没问题:进程互斥名是 `PLCControl.OperationSystem`,端口默认 3100(feeder 是 60309)。
|
||||
- Windows release 用 `windows_subsystem = "windows"`,不出黑窗;要看日志直接打开 `logs/` 下的 `app.log*`,或者从命令行跑 debug build。
|
||||
- OPC UA 数据源在 `source` 表里配,启动时 `connect_all_enabled_sources` 会自动连。没配数据源不影响 UI / 段配置使用。
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
// Shared real-time log stream (SSE /api/logs/stream -> #logView).
|
||||
// Depends only on the platform dom/state, so both feeder and ops can use it.
|
||||
import { dom } from "./dom.js";
|
||||
import { state } from "./state.js";
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function parseLogLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appendLog(line) {
|
||||
if (!dom.logView) return;
|
||||
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
|
||||
const div = document.createElement("div");
|
||||
const parsed = parseLogLine(line);
|
||||
if (!parsed) {
|
||||
div.className = "log-line";
|
||||
div.textContent = line;
|
||||
} else {
|
||||
const levelRaw = (parsed.level || "").toString();
|
||||
const level = levelRaw.toLowerCase();
|
||||
div.className = `log-line${level ? ` level-${level}` : ""}`;
|
||||
div.innerHTML = [
|
||||
`<span class="level">${escapeHtml(levelRaw || "LOG")}</span>`,
|
||||
parsed.timestamp ? `<span class="muted"> ${escapeHtml(parsed.timestamp)}</span>` : "",
|
||||
parsed.target ? `<span class="muted"> ${escapeHtml(parsed.target)}</span>` : "",
|
||||
`<span class="message">${escapeHtml(parsed.fields?.message || parsed.message || parsed.msg || line)}</span>`,
|
||||
].join("");
|
||||
}
|
||||
dom.logView.appendChild(div);
|
||||
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
|
||||
}
|
||||
|
||||
function appendLogDivider(text) {
|
||||
if (!dom.logView) return;
|
||||
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
|
||||
const div = document.createElement("div");
|
||||
div.className = "log-line muted";
|
||||
div.textContent = text;
|
||||
dom.logView.appendChild(div);
|
||||
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
|
||||
}
|
||||
|
||||
export function startLogs() {
|
||||
if (state.logSource) return;
|
||||
let currentLogFile = null;
|
||||
state.logSource = new EventSource("/api/logs/stream");
|
||||
state.logSource.addEventListener("log", (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.reset && data.file && data.file !== currentLogFile) {
|
||||
appendLogDivider(`[log switched to ${data.file}]`);
|
||||
}
|
||||
currentLogFile = data.file || currentLogFile;
|
||||
(data.lines || []).forEach(appendLog);
|
||||
});
|
||||
state.logSource.addEventListener("error", () => appendLog("[log stream error]"));
|
||||
}
|
||||
|
||||
export function stopLogs() {
|
||||
if (state.logSource) {
|
||||
state.logSource.close();
|
||||
state.logSource = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
// Shared platform-config wiring (data source / point / equipment management).
|
||||
// Used by both feeder and ops so the heavy module logic lives in one place.
|
||||
// Every listener is null-guarded because each app includes only a subset of
|
||||
// the platform partials (e.g. ops has no README/API doc buttons).
|
||||
import { withStatus } from "./api.js";
|
||||
import { dom } from "./dom.js";
|
||||
import { openChart, renderChart } from "./chart.js";
|
||||
import { loadEvents } from "./events.js";
|
||||
import {
|
||||
clearPointBinding,
|
||||
closeEquipmentModal,
|
||||
loadEquipments,
|
||||
openCreateEquipmentModal,
|
||||
resetEquipmentForm,
|
||||
saveEquipment,
|
||||
} from "./equipment.js";
|
||||
import {
|
||||
browseAndLoadTree,
|
||||
clearBatchBinding,
|
||||
clearSelectedPoints,
|
||||
createPoints,
|
||||
loadPoints,
|
||||
loadTree,
|
||||
openBatchBinding,
|
||||
openPointCreateModal,
|
||||
renderSelectedNodes,
|
||||
savePointBinding,
|
||||
saveBatchBinding,
|
||||
updatePointFilterSummary,
|
||||
updateSelectedPointSummary,
|
||||
} from "./points.js";
|
||||
import { state } from "./state.js";
|
||||
import { loadSources, saveSource } from "./sources.js";
|
||||
|
||||
const on = (elm, evt, fn) => {
|
||||
if (elm) elm.addEventListener(evt, fn);
|
||||
};
|
||||
|
||||
/** Bind every platform-config DOM listener. Safe to call when some elements are absent. */
|
||||
export function bindPlatformConfigEvents() {
|
||||
on(dom.sourceForm, "submit", (event) => withStatus(saveSource(event)));
|
||||
on(dom.equipmentForm, "submit", (event) => withStatus(saveEquipment(event)));
|
||||
on(dom.pointBindingForm, "submit", (event) => withStatus(savePointBinding(event)));
|
||||
on(dom.batchBindingForm, "submit", (event) => withStatus(saveBatchBinding(event)));
|
||||
|
||||
on(dom.sourceResetBtn, "click", () => dom.sourceForm && dom.sourceForm.reset());
|
||||
on(dom.equipmentResetBtn, "click", resetEquipmentForm);
|
||||
on(dom.refreshEquipmentBtn, "click", () => withStatus(loadEquipments()));
|
||||
on(dom.newEquipmentBtn, "click", openCreateEquipmentModal);
|
||||
on(dom.closeEquipmentModalBtn, "click", closeEquipmentModal);
|
||||
|
||||
on(dom.openPointModalBtn, "click", openPointCreateModal);
|
||||
on(dom.pointSourceSelect, "change", () => {
|
||||
if (dom.nodeTree) dom.nodeTree.innerHTML = '<div class="muted">点击"加载节点"获取节点树</div>';
|
||||
if (dom.pointSourceNodeCount) dom.pointSourceNodeCount.textContent = "节点: 0";
|
||||
});
|
||||
on(dom.browseNodesBtn, "click", () => withStatus(browseAndLoadTree()));
|
||||
on(dom.refreshTreeBtn, "click", () => withStatus(loadTree()));
|
||||
on(dom.createPointsBtn, "click", () => withStatus(createPoints()));
|
||||
on(dom.closeModalBtn, "click", () => dom.pointModal.classList.add("hidden"));
|
||||
|
||||
on(dom.openSourceFormBtn, "click", () => {
|
||||
dom.sourceForm.reset();
|
||||
dom.sourceId.value = "";
|
||||
dom.sourceModal.classList.remove("hidden");
|
||||
});
|
||||
on(dom.closeSourceModalBtn, "click", () => dom.sourceModal.classList.add("hidden"));
|
||||
|
||||
on(dom.clearPointBindingBtn, "click", () => withStatus(clearPointBinding()));
|
||||
on(dom.closePointBindingModalBtn, "click", () => dom.pointBindingModal.classList.add("hidden"));
|
||||
|
||||
on(dom.openBatchBindingBtn, "click", openBatchBinding);
|
||||
on(dom.clearSelectedPointsBtn, "click", clearSelectedPoints);
|
||||
on(dom.closeBatchBindingModalBtn, "click", () => dom.batchBindingModal.classList.add("hidden"));
|
||||
on(dom.clearBatchBindingBtn, "click", () => withStatus(clearBatchBinding()));
|
||||
|
||||
on(dom.toggleAllPoints, "change", () => {
|
||||
const checked = dom.toggleAllPoints.checked;
|
||||
dom.pointList.querySelectorAll('input[data-point-select="true"]').forEach((input) => {
|
||||
input.checked = checked;
|
||||
input.dispatchEvent(new Event("change"));
|
||||
});
|
||||
});
|
||||
|
||||
on(dom.refreshChartBtn, "click", () => {
|
||||
if (!state.chartPointId) return;
|
||||
withStatus(openChart(state.chartPointId, state.chartPointName));
|
||||
});
|
||||
|
||||
on(dom.prevPointsBtn, "click", () => {
|
||||
if (state.pointsPage > 1) {
|
||||
state.pointsPage -= 1;
|
||||
withStatus(loadPoints());
|
||||
}
|
||||
});
|
||||
on(dom.nextPointsBtn, "click", () => {
|
||||
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
|
||||
if (state.pointsPage < totalPages) {
|
||||
state.pointsPage += 1;
|
||||
withStatus(loadPoints());
|
||||
}
|
||||
});
|
||||
|
||||
on(dom.equipmentKeyword, "keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
withStatus(loadEquipments());
|
||||
}
|
||||
});
|
||||
|
||||
on(dom.refreshEventBtn, "click", () => withStatus(loadEvents()));
|
||||
}
|
||||
|
||||
/** Initialise the static text bits of the platform-config UI. */
|
||||
export function initPlatformConfigUi() {
|
||||
renderSelectedNodes();
|
||||
updateSelectedPointSummary();
|
||||
updatePointFilterSummary();
|
||||
renderChart();
|
||||
}
|
||||
|
||||
/** Load all platform-config data (sources, equipment, points). Events live in the
|
||||
* monitoring view, not here, so they are loaded by each app's bootstrap. */
|
||||
export async function loadPlatformConfig() {
|
||||
await Promise.all([loadSources(), loadEquipments()]);
|
||||
await loadPoints();
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<header class="topbar">
|
||||
<div class="title">投煤器布料机控制系统</div>
|
||||
<div class="title">投煤控制系统</div>
|
||||
<div class="tab-bar">
|
||||
<button type="button" class="tab-btn active" id="tabOps">运维</button>
|
||||
<button type="button" class="tab-btn active" id="tabOps">运行监控</button>
|
||||
<button type="button" class="tab-btn" id="tabAppConfig">应用配置</button>
|
||||
<button type="button" class="tab-btn" id="tabConfig">平台配置</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PLC Control</title>
|
||||
<title>投煤控制系统</title>
|
||||
<link rel="stylesheet" href="/ui/styles.css?v=20260325f" />
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -1,36 +1,25 @@
|
|||
import { withStatus } from "./api.js";
|
||||
import { openChart, renderChart } from "./chart.js";
|
||||
import { dom } from "./dom.js";
|
||||
import { closeApiDocDrawer, openApiDocDrawer, openReadmeDrawer } from "./docs.js";
|
||||
import { loadEvents } from "./events.js";
|
||||
import { withStatus } from "./platform/api.js";
|
||||
import { dom } from "./platform/dom.js";
|
||||
import { closeApiDocDrawer, openApiDocDrawer, openReadmeDrawer } from "./platform/docs.js";
|
||||
import { loadEvents } from "./platform/events.js";
|
||||
import { loadEquipments } from "./platform/equipment.js";
|
||||
import {
|
||||
clearPointBinding,
|
||||
closeEquipmentModal,
|
||||
loadEquipments,
|
||||
openCreateEquipmentModal,
|
||||
resetEquipmentForm,
|
||||
saveEquipment,
|
||||
} from "./equipment.js";
|
||||
bindPlatformConfigEvents,
|
||||
initPlatformConfigUi,
|
||||
loadPlatformConfig,
|
||||
} from "./platform/platform-config.js";
|
||||
import { startPointSocket, startLogs, stopLogs } from "./logs.js";
|
||||
import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js";
|
||||
import { state } from "./platform/state.js";
|
||||
import {
|
||||
clearBatchBinding,
|
||||
browseAndLoadTree,
|
||||
clearSelectedPoints,
|
||||
createPoints,
|
||||
loadPoints,
|
||||
loadTree,
|
||||
openBatchBinding,
|
||||
openPointCreateModal,
|
||||
renderSelectedNodes,
|
||||
saveBatchBinding,
|
||||
savePointBinding,
|
||||
updatePointFilterSummary,
|
||||
updateSelectedPointSummary,
|
||||
} from "./points.js";
|
||||
import { state } from "./state.js";
|
||||
import { loadSources, saveSource } from "./sources.js";
|
||||
import { bindUnitEquipmentModalEvents, closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js";
|
||||
bindUnitEquipmentModalEvents,
|
||||
closeUnitModal,
|
||||
loadUnits,
|
||||
openCreateUnitModal,
|
||||
resetUnitForm,
|
||||
renderUnits,
|
||||
saveUnit,
|
||||
} from "./units.js";
|
||||
|
||||
let _configLoaded = false;
|
||||
let _appConfigLoaded = false;
|
||||
|
|
@ -69,10 +58,7 @@ function switchView(view) {
|
|||
startLogs();
|
||||
if (!_configLoaded) {
|
||||
_configLoaded = true;
|
||||
withStatus((async () => {
|
||||
await Promise.all([loadSources(), loadEquipments(), loadEvents()]);
|
||||
await loadPoints();
|
||||
})());
|
||||
withStatus(loadPlatformConfig());
|
||||
}
|
||||
} else {
|
||||
stopLogs();
|
||||
|
|
@ -87,92 +73,19 @@ function switchView(view) {
|
|||
}
|
||||
|
||||
function bindEvents() {
|
||||
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event)));
|
||||
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event)));
|
||||
dom.equipmentForm.addEventListener("submit", (event) => withStatus(saveEquipment(event)));
|
||||
dom.pointBindingForm.addEventListener("submit", (event) => withStatus(savePointBinding(event)));
|
||||
dom.batchBindingForm.addEventListener("submit", (event) => withStatus(saveBatchBinding(event)));
|
||||
// Shared data-source / point / equipment listeners.
|
||||
bindPlatformConfigEvents();
|
||||
|
||||
// Feeder-specific (control unit) listeners.
|
||||
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event)));
|
||||
dom.unitResetBtn.addEventListener("click", resetUnitForm);
|
||||
if (dom.refreshUnitBtn) dom.refreshUnitBtn.addEventListener("click", () => withStatus(loadUnits().then(loadEvents)));
|
||||
if (dom.newUnitBtn) dom.newUnitBtn.addEventListener("click", openCreateUnitModal);
|
||||
dom.closeUnitModalBtn.addEventListener("click", closeUnitModal);
|
||||
|
||||
dom.sourceResetBtn.addEventListener("click", () => dom.sourceForm.reset());
|
||||
dom.equipmentResetBtn.addEventListener("click", resetEquipmentForm);
|
||||
dom.refreshEquipmentBtn.addEventListener("click", () => withStatus(loadEquipments()));
|
||||
dom.newEquipmentBtn.addEventListener("click", openCreateEquipmentModal);
|
||||
dom.closeEquipmentModalBtn.addEventListener("click", closeEquipmentModal);
|
||||
dom.openPointModalBtn.addEventListener("click", openPointCreateModal);
|
||||
dom.pointSourceSelect.addEventListener("change", () => {
|
||||
dom.nodeTree.innerHTML = '<div class="muted">点击"加载节点"获取节点树</div>';
|
||||
dom.pointSourceNodeCount.textContent = "节点: 0";
|
||||
});
|
||||
dom.browseNodesBtn.addEventListener("click", () => withStatus(browseAndLoadTree()));
|
||||
dom.refreshTreeBtn.addEventListener("click", () => withStatus(loadTree()));
|
||||
dom.createPointsBtn.addEventListener("click", () => withStatus(createPoints()));
|
||||
dom.closeModalBtn.addEventListener("click", () => dom.pointModal.classList.add("hidden"));
|
||||
|
||||
dom.openSourceFormBtn.addEventListener("click", () => {
|
||||
dom.sourceForm.reset();
|
||||
dom.sourceId.value = "";
|
||||
dom.sourceModal.classList.remove("hidden");
|
||||
});
|
||||
dom.closeSourceModalBtn.addEventListener("click", () => dom.sourceModal.classList.add("hidden"));
|
||||
|
||||
dom.clearPointBindingBtn.addEventListener("click", () => withStatus(clearPointBinding()));
|
||||
dom.closePointBindingModalBtn.addEventListener("click", () => {
|
||||
dom.pointBindingModal.classList.add("hidden");
|
||||
});
|
||||
|
||||
dom.openBatchBindingBtn.addEventListener("click", openBatchBinding);
|
||||
dom.clearSelectedPointsBtn.addEventListener("click", clearSelectedPoints);
|
||||
dom.closeBatchBindingModalBtn.addEventListener("click", () => {
|
||||
dom.batchBindingModal.classList.add("hidden");
|
||||
});
|
||||
dom.clearBatchBindingBtn.addEventListener("click", () => withStatus(clearBatchBinding()));
|
||||
|
||||
dom.toggleAllPoints.addEventListener("change", () => {
|
||||
const checked = dom.toggleAllPoints.checked;
|
||||
dom.pointList.querySelectorAll('input[data-point-select="true"]').forEach((input) => {
|
||||
input.checked = checked;
|
||||
input.dispatchEvent(new Event("change"));
|
||||
});
|
||||
});
|
||||
|
||||
dom.openReadmeDocBtn.addEventListener("click", () => withStatus(openReadmeDrawer()));
|
||||
dom.openApiDocBtn.addEventListener("click", () => withStatus(openApiDocDrawer()));
|
||||
dom.closeApiDocBtn.addEventListener("click", closeApiDocDrawer);
|
||||
dom.refreshEventBtn.addEventListener("click", () => withStatus(loadEvents()));
|
||||
|
||||
dom.refreshChartBtn.addEventListener("click", () => {
|
||||
if (!state.chartPointId) {
|
||||
return;
|
||||
}
|
||||
withStatus(openChart(state.chartPointId, state.chartPointName));
|
||||
});
|
||||
|
||||
dom.prevPointsBtn.addEventListener("click", () => {
|
||||
if (state.pointsPage > 1) {
|
||||
state.pointsPage -= 1;
|
||||
withStatus(loadPoints());
|
||||
}
|
||||
});
|
||||
|
||||
dom.nextPointsBtn.addEventListener("click", () => {
|
||||
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
|
||||
if (state.pointsPage < totalPages) {
|
||||
state.pointsPage += 1;
|
||||
withStatus(loadPoints());
|
||||
}
|
||||
});
|
||||
|
||||
dom.equipmentKeyword.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
withStatus(loadEquipments());
|
||||
}
|
||||
});
|
||||
|
||||
dom.tabOps.addEventListener("click", () => switchView("ops"));
|
||||
dom.tabAppConfig.addEventListener("click", () => switchView("app-config"));
|
||||
|
|
@ -197,10 +110,7 @@ function bindEvents() {
|
|||
async function bootstrap() {
|
||||
bindEvents();
|
||||
switchView("ops");
|
||||
renderSelectedNodes();
|
||||
updateSelectedPointSummary();
|
||||
updatePointFilterSummary();
|
||||
renderChart();
|
||||
initPlatformConfigUi();
|
||||
startPointSocket();
|
||||
|
||||
await withStatus(Promise.all([loadUnits(), loadEvents()]));
|
||||
|
|
|
|||
|
|
@ -1,76 +1,14 @@
|
|||
import { appendChartPoint } from "./chart.js";
|
||||
import { dom } from "./dom.js";
|
||||
import { prependEvent } from "./events.js";
|
||||
import { formatValue } from "./points.js";
|
||||
import { state } from "./state.js";
|
||||
import { appendChartPoint } from "./platform/chart.js";
|
||||
import { dom } from "./platform/dom.js";
|
||||
import { prependEvent } from "./platform/events.js";
|
||||
import { formatValue } from "./platform/points.js";
|
||||
import { state } from "./platform/state.js";
|
||||
import { loadUnits, renderUnits } from "./units.js";
|
||||
import { loadEquipments } from "./equipment.js";
|
||||
import { showToast } from "./api.js";
|
||||
import { loadEquipments } from "./platform/equipment.js";
|
||||
import { showToast } from "./platform/api.js";
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function parseLogLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
|
||||
try { return JSON.parse(trimmed); } catch { return null; }
|
||||
}
|
||||
|
||||
function appendLog(line) {
|
||||
if (!dom.logView) return;
|
||||
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
|
||||
const div = document.createElement("div");
|
||||
const parsed = parseLogLine(line);
|
||||
if (!parsed) {
|
||||
div.className = "log-line";
|
||||
div.textContent = line;
|
||||
} else {
|
||||
const levelRaw = (parsed.level || "").toString();
|
||||
const level = levelRaw.toLowerCase();
|
||||
div.className = `log-line${level ? ` level-${level}` : ""}`;
|
||||
div.innerHTML = [
|
||||
`<span class="level">${escapeHtml(levelRaw || "LOG")}</span>`,
|
||||
parsed.timestamp ? `<span class="muted"> ${escapeHtml(parsed.timestamp)}</span>` : "",
|
||||
parsed.target ? `<span class="muted"> ${escapeHtml(parsed.target)}</span>` : "",
|
||||
`<span class="message">${escapeHtml(parsed.fields?.message || parsed.message || parsed.msg || line)}</span>`,
|
||||
].join("");
|
||||
}
|
||||
dom.logView.appendChild(div);
|
||||
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
|
||||
}
|
||||
|
||||
function appendLogDivider(text) {
|
||||
if (!dom.logView) return;
|
||||
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
|
||||
const div = document.createElement("div");
|
||||
div.className = "log-line muted";
|
||||
div.textContent = text;
|
||||
dom.logView.appendChild(div);
|
||||
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
|
||||
}
|
||||
|
||||
export function startLogs() {
|
||||
if (state.logSource) return;
|
||||
let currentLogFile = null;
|
||||
state.logSource = new EventSource("/api/logs/stream");
|
||||
state.logSource.addEventListener("log", (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.reset && data.file && data.file !== currentLogFile) {
|
||||
appendLogDivider(`[log switched to ${data.file}]`);
|
||||
}
|
||||
currentLogFile = data.file || currentLogFile;
|
||||
(data.lines || []).forEach(appendLog);
|
||||
});
|
||||
state.logSource.addEventListener("error", () => appendLog("[log stream error]"));
|
||||
}
|
||||
|
||||
export function stopLogs() {
|
||||
if (state.logSource) {
|
||||
state.logSource.close();
|
||||
state.logSource = null;
|
||||
}
|
||||
}
|
||||
// Real-time SSE log stream lives in the shared platform module (also used by ops).
|
||||
export { startLogs, stopLogs } from "./platform/log-stream.js";
|
||||
|
||||
let _disconnectToast = null;
|
||||
|
||||
|
|
@ -150,15 +88,19 @@ export function startPointSocket() {
|
|||
prependEvent(payload.data);
|
||||
}
|
||||
|
||||
if (payload.type === "UnitRuntimeChanged") {
|
||||
const runtime = payload.data;
|
||||
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, syncEquipmentButtonsForUnit }) => {
|
||||
renderOpsUnits();
|
||||
syncEquipmentButtonsForUnit(runtime.unit_id);
|
||||
});
|
||||
if (payload.type === "AppEvent" || payload.type === "app_event") {
|
||||
const envelope = payload.data || {};
|
||||
if (envelope.app !== "feeder") return;
|
||||
if (envelope.event_type === "unit_runtime_changed") {
|
||||
const runtime = envelope.data;
|
||||
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, syncEquipmentButtonsForUnit }) => {
|
||||
renderOpsUnits();
|
||||
syncEquipmentButtonsForUnit(runtime.unit_id);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { apiFetch } from "./api.js";
|
||||
import { dom } from "./dom.js";
|
||||
import { state } from "./state.js";
|
||||
import { apiFetch } from "./platform/api.js";
|
||||
import { dom } from "./platform/dom.js";
|
||||
import { state } from "./platform/state.js";
|
||||
import { loadUnits } from "./units.js";
|
||||
|
||||
const SIGNAL_ROLES = ["rem", "run", "flt"];
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { apiFetch, withStatus } from "./api.js";
|
||||
import { dom } from "./dom.js";
|
||||
import { loadEvents } from "./events.js";
|
||||
import { loadEquipments, renderEquipments } from "./equipment.js";
|
||||
import { state } from "./state.js";
|
||||
import { apiFetch, withStatus } from "./platform/api.js";
|
||||
import { dom } from "./platform/dom.js";
|
||||
import { loadEvents } from "./platform/events.js";
|
||||
import { loadEquipments, renderEquipments } from "./platform/equipment.js";
|
||||
import { state } from "./platform/state.js";
|
||||
|
||||
function equipmentOf(item) {
|
||||
return item && item.equipment ? item.equipment : item;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
<section class="panel ops-config" data-config-section>
|
||||
<div class="config-grid">
|
||||
<div class="config-pane">
|
||||
<div class="panel-head">
|
||||
<h2>工位</h2>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="secondary" id="refreshStationsBtn">刷新</button>
|
||||
<button type="button" id="addStationBtn">+ 新增</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-list" id="stationList"></div>
|
||||
</div>
|
||||
<div class="config-pane">
|
||||
<div class="panel-head">
|
||||
<h2>段</h2>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="secondary" id="refreshSegmentConfigBtn">刷新</button>
|
||||
<button type="button" id="addSegmentBtn">+ 新增</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-list" id="segmentConfigList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<section class="panel ops-segments">
|
||||
<div class="panel-head">
|
||||
<h2>段运行态</h2>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="secondary" id="refreshSegmentsBtn">刷新</button>
|
||||
<button type="button" class="secondary" id="batchStartAutoBtn">全部启动</button>
|
||||
<button type="button" class="secondary" id="batchStopAutoBtn">全部停止</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ops-segment-list" id="segmentList"></div>
|
||||
</section>
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
<header class="topbar">
|
||||
<div class="title">运转系统</div>
|
||||
<div class="title">隧道窑运转系统</div>
|
||||
<div class="tab-bar">
|
||||
<button type="button" class="tab-btn active" id="tabMonitor">运行监控</button>
|
||||
<button type="button" class="tab-btn" id="tabConfig">应用配置</button>
|
||||
<button type="button" class="tab-btn" id="tabPlatform">平台配置</button>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<div class="status" id="statusText">
|
||||
<span class="ws-dot" id="wsDot"></span>
|
||||
|
|
|
|||
|
|
@ -3,16 +3,34 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>运转系统</title>
|
||||
<title>隧道窑运转系统</title>
|
||||
<link rel="stylesheet" href="/ui/styles.css" />
|
||||
<link rel="stylesheet" href="/ui/ops-styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div data-partial="/ui/html/topbar.html"></div>
|
||||
|
||||
<main>
|
||||
<div class="muted" style="padding:2rem;text-align:center">运转系统页面开发中</div>
|
||||
<main class="ops-main">
|
||||
<div class="ops-view" data-view="monitor">
|
||||
<div data-partial="/ui/html/segment-panel.html"></div>
|
||||
<div data-partial="/ui/html/logs-panel.html"></div>
|
||||
</div>
|
||||
<div class="ops-view hidden" data-view="config">
|
||||
<div data-partial="/ui/html/config-panel.html"></div>
|
||||
</div>
|
||||
<div class="ops-view hidden" data-view="platform">
|
||||
<div class="ops-platform-grid">
|
||||
<div data-partial="/ui/html/equipment-panel.html"></div>
|
||||
<div data-partial="/ui/html/points-panel.html"></div>
|
||||
<div data-partial="/ui/html/source-panel.html"></div>
|
||||
<div data-partial="/ui/html/log-stream-panel.html"></div>
|
||||
<div data-partial="/ui/html/chart-panel.html"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div data-partial="/ui/html/modals.html"></div>
|
||||
|
||||
<script type="module" src="/ui/js/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
async function jsonOrThrow(response, fallbackMessage) {
|
||||
if (response.ok) {
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
let detail = "";
|
||||
try {
|
||||
const body = await response.json();
|
||||
detail = body?.message || body?.err_msg || JSON.stringify(body);
|
||||
} catch {
|
||||
detail = await response.text();
|
||||
}
|
||||
throw new Error(`${fallbackMessage}: ${response.status} ${detail || response.statusText}`);
|
||||
}
|
||||
|
||||
async function get(path, label) {
|
||||
const response = await fetch(path);
|
||||
return jsonOrThrow(response, label);
|
||||
}
|
||||
|
||||
async function postJson(path, body, label) {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return jsonOrThrow(response, label);
|
||||
}
|
||||
|
||||
async function putJson(path, body, label) {
|
||||
const response = await fetch(path, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return jsonOrThrow(response, label);
|
||||
}
|
||||
|
||||
async function del(path, label) {
|
||||
const response = await fetch(path, { method: "DELETE" });
|
||||
return jsonOrThrow(response, label);
|
||||
}
|
||||
|
||||
export const runtimeApi = {
|
||||
fetchOverview: () => get("/api/runtime/overview", "加载段运行态失败"),
|
||||
};
|
||||
|
||||
export const segmentControl = {
|
||||
startAuto: (id) => postJson(`/api/control/segment/${id}/start-auto`, undefined, "启动自动控制失败"),
|
||||
stopAuto: (id) => postJson(`/api/control/segment/${id}/stop-auto`, undefined, "停止自动控制失败"),
|
||||
ackFault: (id) => postJson(`/api/control/segment/${id}/ack-fault`, undefined, "故障确认失败"),
|
||||
reset: (id) => postJson(`/api/control/segment/${id}/reset`, undefined, "复位失败"),
|
||||
batchStart: () => postJson(`/api/control/segment/batch-start-auto`, undefined, "批量启动失败"),
|
||||
batchStop: () => postJson(`/api/control/segment/batch-stop-auto`, undefined, "批量停止失败"),
|
||||
};
|
||||
|
||||
export const stationApi = {
|
||||
list: (lineCode) => {
|
||||
const q = lineCode ? `?line_code=${encodeURIComponent(lineCode)}` : "";
|
||||
return get(`/api/station${q}`, "加载工位失败");
|
||||
},
|
||||
detail: (id) => get(`/api/station/${id}`, "加载工位详情失败"),
|
||||
create: (payload) => postJson("/api/station", payload, "新增工位失败"),
|
||||
update: (id, payload) => putJson(`/api/station/${id}`, payload, "更新工位失败"),
|
||||
remove: (id) => del(`/api/station/${id}`, "删除工位失败"),
|
||||
upsertSignal: (id, payload) =>
|
||||
postJson(`/api/station/${id}/signal`, payload, "绑定工位信号失败"),
|
||||
deleteSignal: (id, role) =>
|
||||
del(`/api/station/${id}/signal/${encodeURIComponent(role)}`, "解除工位信号绑定失败"),
|
||||
};
|
||||
|
||||
export const segmentApi = {
|
||||
list: (lineCode) => {
|
||||
const q = lineCode ? `?line_code=${encodeURIComponent(lineCode)}` : "";
|
||||
return get(`/api/segment${q}`, "加载段配置失败");
|
||||
},
|
||||
detail: (id) => get(`/api/segment/${id}/detail`, "加载段详情失败"),
|
||||
create: (payload) => postJson("/api/segment", payload, "新增段失败"),
|
||||
update: (id, payload) => putJson(`/api/segment/${id}`, payload, "更新段失败"),
|
||||
remove: (id) => del(`/api/segment/${id}`, "删除段失败"),
|
||||
createStep: (id, payload) =>
|
||||
postJson(`/api/segment/${id}/step`, payload, "新增步骤失败"),
|
||||
updateStep: (id, stepNo, payload) =>
|
||||
putJson(`/api/segment/${id}/step/${stepNo}`, payload, "更新步骤失败"),
|
||||
deleteStep: (id, stepNo) =>
|
||||
del(`/api/segment/${id}/step/${stepNo}`, "删除步骤失败"),
|
||||
createInterlock: (id, payload) =>
|
||||
postJson(`/api/segment/${id}/interlock`, payload, "新增联锁失败"),
|
||||
deleteInterlock: (id, interlockId) =>
|
||||
del(`/api/segment/${id}/interlock/${interlockId}`, "删除联锁失败"),
|
||||
replaceResources: (id, keys) =>
|
||||
putJson(`/api/segment/${id}/resource`, { resource_keys: keys }, "更新资源声明失败"),
|
||||
};
|
||||
|
|
@ -1,5 +1,27 @@
|
|||
function bootstrap() {
|
||||
console.log("Operation system app initialized");
|
||||
import { bindSegmentEvents, loadSegments } from "./segments.js";
|
||||
import { bindSegmentConfigEvents } from "./segments-config.js";
|
||||
import { bindStationEvents } from "./stations.js";
|
||||
import { bindViewTabs } from "./views.js";
|
||||
import { startOpsSocket } from "./ws.js";
|
||||
import { loadEvents } from "./platform/events.js";
|
||||
|
||||
async function bootstrap() {
|
||||
bindViewTabs();
|
||||
bindSegmentEvents();
|
||||
bindStationEvents();
|
||||
bindSegmentConfigEvents();
|
||||
startOpsSocket();
|
||||
loadEvents().catch(() => {});
|
||||
try {
|
||||
await loadSegments();
|
||||
} catch (err) {
|
||||
const root = document.getElementById("segmentList");
|
||||
if (root) {
|
||||
root.innerHTML = `<div class="ops-banner banner-error">${
|
||||
err.message || String(err)
|
||||
}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/// Tiny DOM helpers shared across modules.
|
||||
|
||||
export function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
export function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return "";
|
||||
return String(text)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
export function setBanner(container, message, level = "info") {
|
||||
if (!container) return;
|
||||
const existing = container.querySelector(".ops-banner");
|
||||
if (existing) existing.remove();
|
||||
const div = document.createElement("div");
|
||||
div.className = `ops-banner banner-${level}`;
|
||||
div.textContent = message;
|
||||
container.prepend(div);
|
||||
window.setTimeout(() => div.remove(), 4000);
|
||||
}
|
||||
|
|
@ -0,0 +1,523 @@
|
|||
import { segmentApi } from "./api.js";
|
||||
import { el, escapeHtml, setBanner } from "./dom.js";
|
||||
|
||||
const SEGMENT_TYPES = [
|
||||
"front_load",
|
||||
"robot",
|
||||
"front_release",
|
||||
"front_transfer",
|
||||
"kiln_infeed",
|
||||
"kiln_step",
|
||||
"kiln_outfeed",
|
||||
"tail_transfer",
|
||||
"tail_step",
|
||||
"unload",
|
||||
"return",
|
||||
];
|
||||
|
||||
const SEGMENT_MODES = ["auto", "remote_manual", "local_manual", "disabled"];
|
||||
|
||||
const ACTION_KINDS = [
|
||||
"open_door",
|
||||
"close_door",
|
||||
"push_forward",
|
||||
"push_retract",
|
||||
"pull_run",
|
||||
"pull_retract",
|
||||
"transfer_move_to",
|
||||
"step_once",
|
||||
"robot_permit",
|
||||
"robot_release",
|
||||
"wait_signal",
|
||||
"pulse_cmd",
|
||||
];
|
||||
|
||||
const ON_TIMEOUT = ["fault", "retry", "block"];
|
||||
|
||||
const APPLIES_TO = ["start_allow", "start_deny", "run_halt"];
|
||||
|
||||
const RULE_KINDS = [
|
||||
"point_eq",
|
||||
"station_vacant",
|
||||
"station_occupied",
|
||||
"equipment_origin",
|
||||
"equipment_no_fault",
|
||||
"equipment_remote",
|
||||
"safety_chain_ok",
|
||||
];
|
||||
|
||||
const segments = new Map();
|
||||
const segmentDetails = new Map(); // id -> { segment, steps, interlocks, resources }
|
||||
const expanded = new Set();
|
||||
let editing = null;
|
||||
let creating = false;
|
||||
|
||||
function renderSegmentForm(initial) {
|
||||
const data = initial || {};
|
||||
return `
|
||||
<form class="config-form" data-form="segment">
|
||||
<div class="form-row">
|
||||
<label>Code<input name="code" value="${escapeHtml(data.code)}" required maxlength="100" /></label>
|
||||
<label>名称<input name="name" value="${escapeHtml(data.name)}" required maxlength="100" /></label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>段类型
|
||||
<select name="segment_type" required>
|
||||
${SEGMENT_TYPES.map(
|
||||
(t) => `<option value="${t}"${data.segment_type === t ? " selected" : ""}>${t}</option>`,
|
||||
).join("")}
|
||||
</select>
|
||||
</label>
|
||||
<label>线路<input name="line_code" value="${escapeHtml(data.line_code)}" maxlength="50" /></label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>模式
|
||||
<select name="mode" required>
|
||||
${SEGMENT_MODES.map(
|
||||
(m) => `<option value="${m}"${(data.mode || "disabled") === m ? " selected" : ""}>${m}</option>`,
|
||||
).join("")}
|
||||
</select>
|
||||
</label>
|
||||
<label>优先级<input type="number" name="priority" value="${data.priority ?? 0}" /></label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-check">
|
||||
<input type="checkbox" name="enabled" ${data.enabled === false ? "" : "checked"} />
|
||||
启用
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input type="checkbox" name="require_manual_ack_after_fault" ${data.require_manual_ack_after_fault === false ? "" : "checked"} />
|
||||
故障需手工确认
|
||||
</label>
|
||||
</div>
|
||||
<label>说明<textarea name="description" maxlength="500">${escapeHtml(data.description)}</textarea></label>
|
||||
<div class="form-actions">
|
||||
<button type="button" data-action="cancel-form" class="secondary">取消</button>
|
||||
<button type="submit">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStepRow(step) {
|
||||
return `
|
||||
<tr>
|
||||
<td>${step.step_no}</td>
|
||||
<td>${escapeHtml(step.step_code)}</td>
|
||||
<td>${escapeHtml(step.action_kind)}</td>
|
||||
<td class="mono">${escapeHtml(step.target_equipment_id || "")}</td>
|
||||
<td class="mono">${escapeHtml(step.target_station_id || "")}</td>
|
||||
<td>${escapeHtml(step.confirm_signal_role || "")}</td>
|
||||
<td>${step.timeout_ms}</td>
|
||||
<td>${step.hold_until_confirm ? "保持" : "脉冲"}</td>
|
||||
<td>${escapeHtml(step.on_timeout)}</td>
|
||||
<td><button data-action="delete-step" data-step-no="${step.step_no}" class="secondary">删除</button></td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStepForm() {
|
||||
return `
|
||||
<form class="config-form step-form" data-form="step">
|
||||
<div class="form-row">
|
||||
<label>步序<input name="step_no" type="number" min="1" required /></label>
|
||||
<label>Code<input name="step_code" required maxlength="64" /></label>
|
||||
<label>Action
|
||||
<select name="action_kind" required>
|
||||
${ACTION_KINDS.map((a) => `<option value="${a}">${a}</option>`).join("")}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>目标设备 ID<input name="target_equipment_id" placeholder="UUID" /></label>
|
||||
<label>目标工位 ID<input name="target_station_id" placeholder="UUID" /></label>
|
||||
<label>确认信号<input name="confirm_signal_role" placeholder="arrived / done / ..." /></label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>命令角色<input name="command_role" placeholder="留空走默认" /></label>
|
||||
<label>停止角色<input name="stop_command_role" /></label>
|
||||
<label>脉冲毫秒<input name="pulse_ms" type="number" min="1" /></label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>超时 ms<input name="timeout_ms" type="number" min="1" value="30000" /></label>
|
||||
<label>on_timeout
|
||||
<select name="on_timeout">${ON_TIMEOUT.map((v) => `<option value="${v}">${v}</option>`).join("")}</select>
|
||||
</label>
|
||||
<label class="form-check"><input type="checkbox" name="hold_until_confirm" />持续命令</label>
|
||||
<label class="form-check"><input type="checkbox" name="cancel_on_fault" checked />故障自动停止</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit">新增步骤</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderInterlockRow(rule) {
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(rule.applies_to)}</td>
|
||||
<td>${escapeHtml(rule.rule_kind)}</td>
|
||||
<td class="mono">${escapeHtml(rule.point_id || rule.station_id || rule.equipment_id || "")}</td>
|
||||
<td>${rule.expected_value === null || rule.expected_value === undefined ? "" : rule.expected_value ? "true" : "false"}</td>
|
||||
<td>${escapeHtml(rule.description || "")}</td>
|
||||
<td><button data-action="delete-interlock" data-id="${rule.id}" class="secondary">删除</button></td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderInterlockForm() {
|
||||
return `
|
||||
<form class="config-form interlock-form" data-form="interlock">
|
||||
<div class="form-row">
|
||||
<label>applies_to
|
||||
<select name="applies_to" required>${APPLIES_TO.map((v) => `<option value="${v}">${v}</option>`).join("")}</select>
|
||||
</label>
|
||||
<label>rule_kind
|
||||
<select name="rule_kind" required>${RULE_KINDS.map((v) => `<option value="${v}">${v}</option>`).join("")}</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Point ID<input name="point_id" /></label>
|
||||
<label>Station ID<input name="station_id" /></label>
|
||||
<label>Equipment ID<input name="equipment_id" /></label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>期望值
|
||||
<select name="expected_value">
|
||||
<option value="">(none)</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>说明<input name="description" maxlength="200" /></label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit">新增联锁</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderResourcesEditor(detail) {
|
||||
const keys = (detail?.resources || []).map((r) => r.resource_key);
|
||||
return `
|
||||
<form class="config-form resource-form" data-form="resources">
|
||||
<label>资源键(逗号或换行分隔)
|
||||
<textarea name="resource_keys" rows="2">${escapeHtml(keys.join(", "))}</textarea>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="submit">保存资源列表</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDetail(detail) {
|
||||
const steps = detail?.steps || [];
|
||||
const interlocks = detail?.interlocks || [];
|
||||
return `
|
||||
<div class="row-body">
|
||||
<h3 class="row-section-title">步骤</h3>
|
||||
${
|
||||
steps.length === 0
|
||||
? `<div class="muted card-empty">暂无步骤</div>`
|
||||
: `<table class="config-table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Code</th><th>Action</th><th>设备</th><th>工位</th><th>确认</th><th>超时</th><th>方式</th><th>超时策略</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>${steps.map(renderStepRow).join("")}</tbody>
|
||||
</table>`
|
||||
}
|
||||
${renderStepForm()}
|
||||
|
||||
<h3 class="row-section-title">联锁</h3>
|
||||
${
|
||||
interlocks.length === 0
|
||||
? `<div class="muted card-empty">暂无联锁</div>`
|
||||
: `<table class="config-table">
|
||||
<thead>
|
||||
<tr><th>applies_to</th><th>rule_kind</th><th>对象 ID</th><th>期望</th><th>说明</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>${interlocks.map(renderInterlockRow).join("")}</tbody>
|
||||
</table>`
|
||||
}
|
||||
${renderInterlockForm()}
|
||||
|
||||
<h3 class="row-section-title">资源声明</h3>
|
||||
${renderResourcesEditor(detail)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRow(segment) {
|
||||
const isExpanded = expanded.has(segment.id);
|
||||
const isEditing = editing === segment.id;
|
||||
const detail = segmentDetails.get(segment.id);
|
||||
return `
|
||||
<article class="config-row" data-segment-id="${segment.id}">
|
||||
<header class="row-head">
|
||||
<div class="row-title">
|
||||
<strong>${escapeHtml(segment.code)}</strong>
|
||||
<span class="muted">${escapeHtml(segment.name)}</span>
|
||||
${segment.line_code ? `<span class="badge">${escapeHtml(segment.line_code)}</span>` : ""}
|
||||
<span class="badge">${escapeHtml(segment.segment_type)}</span>
|
||||
<span class="badge">${escapeHtml(segment.mode)}</span>
|
||||
${segment.enabled ? "" : `<span class="badge badge-warn">已禁用</span>`}
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button data-action="toggle">${isExpanded ? "收起" : "详情"}</button>
|
||||
<button data-action="edit">${isEditing ? "取消" : "编辑"}</button>
|
||||
<button data-action="delete" class="danger">删除</button>
|
||||
</div>
|
||||
</header>
|
||||
${isEditing ? `<div class="row-edit">${renderSegmentForm(segment)}</div>` : ""}
|
||||
${isExpanded ? renderDetail(detail) : ""}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
const root = el("segmentConfigList");
|
||||
if (!root) return;
|
||||
const list = Array.from(segments.values()).sort((a, b) => a.code.localeCompare(b.code));
|
||||
root.innerHTML = `
|
||||
${creating ? `<div class="config-row creating">${renderSegmentForm({})}</div>` : ""}
|
||||
${list.length === 0 ? `<div class="muted card-empty">尚无段</div>` : list.map(renderRow).join("")}
|
||||
`;
|
||||
}
|
||||
|
||||
function segmentFormToPayload(form) {
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const payload = {
|
||||
code: data.code?.trim(),
|
||||
name: data.name?.trim(),
|
||||
segment_type: data.segment_type,
|
||||
mode: data.mode,
|
||||
enabled: form.elements.enabled.checked,
|
||||
require_manual_ack_after_fault: form.elements.require_manual_ack_after_fault.checked,
|
||||
priority: Number(data.priority || 0),
|
||||
};
|
||||
if (data.line_code) payload.line_code = data.line_code.trim();
|
||||
if (data.description) payload.description = data.description;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function stepFormToPayload(form) {
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const payload = {
|
||||
step_no: Number(data.step_no),
|
||||
step_code: data.step_code,
|
||||
action_kind: data.action_kind,
|
||||
on_timeout: data.on_timeout || "fault",
|
||||
hold_until_confirm: form.elements.hold_until_confirm.checked,
|
||||
cancel_on_fault: form.elements.cancel_on_fault.checked,
|
||||
};
|
||||
for (const key of [
|
||||
"target_equipment_id",
|
||||
"target_station_id",
|
||||
"confirm_signal_role",
|
||||
"command_role",
|
||||
"stop_command_role",
|
||||
]) {
|
||||
if (data[key]) payload[key] = data[key].trim();
|
||||
}
|
||||
if (data.pulse_ms) payload.pulse_ms = Number(data.pulse_ms);
|
||||
if (data.timeout_ms) payload.timeout_ms = Number(data.timeout_ms);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function interlockFormToPayload(form) {
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const payload = {
|
||||
applies_to: data.applies_to,
|
||||
rule_kind: data.rule_kind,
|
||||
};
|
||||
for (const key of ["point_id", "station_id", "equipment_id"]) {
|
||||
if (data[key]) payload[key] = data[key].trim();
|
||||
}
|
||||
if (data.expected_value === "true") payload.expected_value = true;
|
||||
else if (data.expected_value === "false") payload.expected_value = false;
|
||||
if (data.description) payload.description = data.description;
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function refreshDetail(segmentId) {
|
||||
try {
|
||||
const detail = await segmentApi.detail(segmentId);
|
||||
segmentDetails.set(segmentId, detail);
|
||||
} catch (err) {
|
||||
setBanner(el("segmentConfigList"), err.message || String(err), "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClick(event) {
|
||||
const button = event.target.closest("button[data-action]");
|
||||
if (!button) return;
|
||||
const action = button.dataset.action;
|
||||
const row = event.target.closest(".config-row");
|
||||
const segmentId = row?.dataset?.segmentId;
|
||||
|
||||
switch (action) {
|
||||
case "cancel-form":
|
||||
creating = false;
|
||||
editing = null;
|
||||
return renderAll();
|
||||
case "toggle":
|
||||
if (!segmentId) return;
|
||||
if (expanded.has(segmentId)) {
|
||||
expanded.delete(segmentId);
|
||||
} else {
|
||||
expanded.add(segmentId);
|
||||
await refreshDetail(segmentId);
|
||||
}
|
||||
return renderAll();
|
||||
case "edit":
|
||||
editing = editing === segmentId ? null : segmentId;
|
||||
return renderAll();
|
||||
case "delete":
|
||||
if (!segmentId) return;
|
||||
if (!window.confirm("确认删除该段及其步骤 / 联锁 / 资源声明?")) return;
|
||||
try {
|
||||
await segmentApi.remove(segmentId);
|
||||
segments.delete(segmentId);
|
||||
segmentDetails.delete(segmentId);
|
||||
expanded.delete(segmentId);
|
||||
if (editing === segmentId) editing = null;
|
||||
renderAll();
|
||||
setBanner(el("segmentConfigList"), "段已删除", "info");
|
||||
} catch (err) {
|
||||
setBanner(el("segmentConfigList"), err.message || String(err), "error");
|
||||
}
|
||||
return;
|
||||
case "delete-step": {
|
||||
if (!segmentId) return;
|
||||
const stepNo = button.dataset.stepNo;
|
||||
try {
|
||||
await segmentApi.deleteStep(segmentId, stepNo);
|
||||
await refreshDetail(segmentId);
|
||||
renderAll();
|
||||
setBanner(el("segmentConfigList"), `已删除步骤 ${stepNo}`, "info");
|
||||
} catch (err) {
|
||||
setBanner(el("segmentConfigList"), err.message || String(err), "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "delete-interlock": {
|
||||
if (!segmentId) return;
|
||||
const interlockId = button.dataset.id;
|
||||
try {
|
||||
await segmentApi.deleteInterlock(segmentId, interlockId);
|
||||
await refreshDetail(segmentId);
|
||||
renderAll();
|
||||
setBanner(el("segmentConfigList"), "已删除联锁", "info");
|
||||
} catch (err) {
|
||||
setBanner(el("segmentConfigList"), err.message || String(err), "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event) {
|
||||
const form = event.target.closest("form[data-form]");
|
||||
if (!form) return;
|
||||
event.preventDefault();
|
||||
const row = form.closest(".config-row");
|
||||
const segmentId = row?.dataset?.segmentId;
|
||||
const kind = form.dataset.form;
|
||||
|
||||
if (kind === "segment") {
|
||||
const payload = segmentFormToPayload(form);
|
||||
try {
|
||||
if (segmentId && editing === segmentId) {
|
||||
await segmentApi.update(segmentId, payload);
|
||||
setBanner(el("segmentConfigList"), "段已更新", "info");
|
||||
} else {
|
||||
await segmentApi.create(payload);
|
||||
setBanner(el("segmentConfigList"), "段已创建", "info");
|
||||
}
|
||||
creating = false;
|
||||
editing = null;
|
||||
await loadSegmentsConfig();
|
||||
} catch (err) {
|
||||
setBanner(el("segmentConfigList"), err.message || String(err), "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!segmentId) return;
|
||||
|
||||
if (kind === "step") {
|
||||
const payload = stepFormToPayload(form);
|
||||
try {
|
||||
await segmentApi.createStep(segmentId, payload);
|
||||
await refreshDetail(segmentId);
|
||||
renderAll();
|
||||
setBanner(el("segmentConfigList"), "步骤已新增", "info");
|
||||
} catch (err) {
|
||||
setBanner(el("segmentConfigList"), err.message || String(err), "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === "interlock") {
|
||||
const payload = interlockFormToPayload(form);
|
||||
try {
|
||||
await segmentApi.createInterlock(segmentId, payload);
|
||||
await refreshDetail(segmentId);
|
||||
renderAll();
|
||||
setBanner(el("segmentConfigList"), "联锁已新增", "info");
|
||||
} catch (err) {
|
||||
setBanner(el("segmentConfigList"), err.message || String(err), "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === "resources") {
|
||||
const raw = form.elements.resource_keys.value || "";
|
||||
const keys = raw
|
||||
.split(/[,\n]/)
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.length > 0);
|
||||
try {
|
||||
await segmentApi.replaceResources(segmentId, keys);
|
||||
await refreshDetail(segmentId);
|
||||
renderAll();
|
||||
setBanner(el("segmentConfigList"), "资源声明已保存", "info");
|
||||
} catch (err) {
|
||||
setBanner(el("segmentConfigList"), err.message || String(err), "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSegmentsConfig() {
|
||||
try {
|
||||
const rows = await segmentApi.list();
|
||||
segments.clear();
|
||||
rows.forEach((s) => segments.set(s.id, s));
|
||||
renderAll();
|
||||
} catch (err) {
|
||||
setBanner(el("segmentConfigList"), err.message || String(err), "error");
|
||||
}
|
||||
}
|
||||
|
||||
export function bindSegmentConfigEvents() {
|
||||
const root = el("segmentConfigList");
|
||||
if (root) {
|
||||
root.addEventListener("click", handleClick);
|
||||
root.addEventListener("submit", handleSubmit);
|
||||
}
|
||||
const addBtn = el("addSegmentBtn");
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener("click", () => {
|
||||
creating = !creating;
|
||||
editing = null;
|
||||
renderAll();
|
||||
});
|
||||
}
|
||||
const refreshBtn = el("refreshSegmentConfigBtn");
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", () => loadSegmentsConfig());
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
import { runtimeApi, segmentControl } from "./api.js";
|
||||
|
||||
const STATE_LABEL = {
|
||||
idle: "空闲",
|
||||
checking: "校验",
|
||||
executing: "执行",
|
||||
confirming: "等待确认",
|
||||
resetting: "复位",
|
||||
completed: "完成",
|
||||
blocked: "阻塞",
|
||||
faulted: "故障",
|
||||
manual_ack_required: "待人工确认",
|
||||
};
|
||||
|
||||
const STATE_CLASS = {
|
||||
idle: "state-idle",
|
||||
checking: "state-active",
|
||||
executing: "state-active",
|
||||
confirming: "state-active",
|
||||
resetting: "state-active",
|
||||
completed: "state-active",
|
||||
blocked: "state-warn",
|
||||
faulted: "state-error",
|
||||
manual_ack_required: "state-warn",
|
||||
};
|
||||
|
||||
const segments = new Map();
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return "";
|
||||
return String(text)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function renderState(runtime) {
|
||||
const state = runtime?.state || "idle";
|
||||
const label = STATE_LABEL[state] || state;
|
||||
const cls = STATE_CLASS[state] || "state-idle";
|
||||
return `<span class="state-badge ${cls}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
function renderActions(seg) {
|
||||
const runtime = seg.runtime || {};
|
||||
const autoOn = runtime.auto_enabled === true;
|
||||
const state = runtime.state || "idle";
|
||||
const canAck = state === "faulted" || state === "manual_ack_required";
|
||||
const canReset = canAck || state === "blocked";
|
||||
return `
|
||||
<div class="card-actions">
|
||||
<button data-action="start-auto" data-id="${seg.segment.id}" ${autoOn ? "disabled" : ""}>启动</button>
|
||||
<button data-action="stop-auto" data-id="${seg.segment.id}" ${autoOn ? "" : "disabled"}>停止</button>
|
||||
<button data-action="ack-fault" data-id="${seg.segment.id}" ${canAck ? "" : "disabled"}>故障确认</button>
|
||||
<button data-action="reset" data-id="${seg.segment.id}" ${canReset ? "" : "disabled"}>复位</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCard(seg) {
|
||||
const segment = seg.segment;
|
||||
const runtime = seg.runtime || {};
|
||||
const note = runtime.fault_message || runtime.blocked_reason || "";
|
||||
const lineTag = segment.line_code ? `<span class="badge">${escapeHtml(segment.line_code)}</span>` : "";
|
||||
const modeTag = `<span class="badge">${escapeHtml(segment.mode)}</span>`;
|
||||
const autoTag = runtime.auto_enabled
|
||||
? `<span class="badge badge-accent">AUTO</span>`
|
||||
: "";
|
||||
const stepText = runtime.current_step_no === null || runtime.current_step_no === undefined
|
||||
? "—"
|
||||
: `Step ${runtime.current_step_no}`;
|
||||
return `
|
||||
<article class="ops-card" data-segment-id="${segment.id}">
|
||||
<header class="card-head">
|
||||
<div class="card-title">
|
||||
<strong>${escapeHtml(segment.code)}</strong>
|
||||
<span class="muted">${escapeHtml(segment.name)}</span>
|
||||
</div>
|
||||
<div class="card-tags">${lineTag}${modeTag}${autoTag}${renderState(runtime)}</div>
|
||||
</header>
|
||||
<div class="card-body">
|
||||
<div class="card-row"><span class="muted">当前步骤</span><span>${escapeHtml(stepText)}</span></div>
|
||||
${note ? `<div class="card-note">${escapeHtml(note)}</div>` : ""}
|
||||
</div>
|
||||
${renderActions(seg)}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
const root = document.getElementById("segmentList");
|
||||
if (!root) return;
|
||||
const items = Array.from(segments.values());
|
||||
items.sort((a, b) => a.segment.code.localeCompare(b.segment.code));
|
||||
if (items.length === 0) {
|
||||
root.innerHTML = `<div class="muted card-empty">尚无段配置;执行种子或通过配置页新增段。</div>`;
|
||||
return;
|
||||
}
|
||||
root.innerHTML = items.map(renderCard).join("");
|
||||
}
|
||||
|
||||
function setBanner(message, level = "info") {
|
||||
const root = document.getElementById("segmentList");
|
||||
if (!root) return;
|
||||
const existing = root.querySelector(".ops-banner");
|
||||
if (existing) existing.remove();
|
||||
const div = document.createElement("div");
|
||||
div.className = `ops-banner banner-${level}`;
|
||||
div.textContent = message;
|
||||
root.prepend(div);
|
||||
window.setTimeout(() => div.remove(), 4000);
|
||||
}
|
||||
|
||||
async function callAndRefresh(label, fn) {
|
||||
try {
|
||||
await fn();
|
||||
setBanner(`${label} 成功`, "info");
|
||||
} catch (err) {
|
||||
setBanner(err.message || String(err), "error");
|
||||
}
|
||||
}
|
||||
|
||||
function handleAction(event) {
|
||||
const button = event.target.closest("button[data-action]");
|
||||
if (!button) return;
|
||||
const action = button.dataset.action;
|
||||
const id = button.dataset.id;
|
||||
switch (action) {
|
||||
case "start-auto":
|
||||
return callAndRefresh("启动自动控制", () => segmentControl.startAuto(id));
|
||||
case "stop-auto":
|
||||
return callAndRefresh("停止自动控制", () => segmentControl.stopAuto(id));
|
||||
case "ack-fault":
|
||||
return callAndRefresh("故障确认", () => segmentControl.ackFault(id));
|
||||
case "reset":
|
||||
return callAndRefresh("复位", () => segmentControl.reset(id));
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSegments() {
|
||||
const data = await runtimeApi.fetchOverview();
|
||||
segments.clear();
|
||||
(data?.segments || []).forEach((entry) => {
|
||||
segments.set(entry.segment.id, entry);
|
||||
});
|
||||
renderAll();
|
||||
}
|
||||
|
||||
/// Apply a SegmentRuntime payload pushed via WebSocket app_event.
|
||||
export function applyRuntimeUpdate(runtime) {
|
||||
if (!runtime?.segment_id) return;
|
||||
const entry = segments.get(runtime.segment_id);
|
||||
if (!entry) {
|
||||
// Unknown segment — refresh from overview so we pick it up.
|
||||
void loadSegments();
|
||||
return;
|
||||
}
|
||||
entry.runtime = runtime;
|
||||
renderAll();
|
||||
}
|
||||
|
||||
export function bindSegmentEvents() {
|
||||
const root = document.getElementById("segmentList");
|
||||
if (root) root.addEventListener("click", handleAction);
|
||||
const refreshBtn = document.getElementById("refreshSegmentsBtn");
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", () => callAndRefresh("刷新", loadSegments));
|
||||
}
|
||||
const batchStart = document.getElementById("batchStartAutoBtn");
|
||||
if (batchStart) {
|
||||
batchStart.addEventListener("click", async () => {
|
||||
await callAndRefresh("批量启动", () => segmentControl.batchStart());
|
||||
await loadSegments();
|
||||
});
|
||||
}
|
||||
const batchStop = document.getElementById("batchStopAutoBtn");
|
||||
if (batchStop) {
|
||||
batchStop.addEventListener("click", async () => {
|
||||
await callAndRefresh("批量停止", () => segmentControl.batchStop());
|
||||
await loadSegments();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
import { stationApi } from "./api.js";
|
||||
import { el, escapeHtml, setBanner } from "./dom.js";
|
||||
|
||||
const STATION_TYPES = [
|
||||
"load",
|
||||
"dry_in",
|
||||
"dry_step",
|
||||
"dry_out",
|
||||
"fire_in",
|
||||
"fire_step",
|
||||
"fire_out",
|
||||
"transfer",
|
||||
"unload",
|
||||
"return",
|
||||
];
|
||||
|
||||
const SIGNAL_ROLES = ["presence", "vacancy", "arrived", "allow_in", "done", "fault"];
|
||||
|
||||
const stations = new Map();
|
||||
const expanded = new Set();
|
||||
let stationDetails = new Map(); // station_id -> { signals: [...] }
|
||||
let editing = null; // station_id being edited inline
|
||||
let creating = false;
|
||||
|
||||
function renderForm(initial) {
|
||||
const data = initial || {};
|
||||
return `
|
||||
<form class="config-form" data-form="station">
|
||||
<div class="form-row">
|
||||
<label>Code<input name="code" value="${escapeHtml(data.code)}" required maxlength="100" /></label>
|
||||
<label>名称<input name="name" value="${escapeHtml(data.name)}" required maxlength="100" /></label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>线路<input name="line_code" value="${escapeHtml(data.line_code)}" maxlength="50" /></label>
|
||||
<label>段分组<input name="segment_code" value="${escapeHtml(data.segment_code)}" maxlength="50" /></label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>工位类型
|
||||
<select name="station_type" required>
|
||||
${STATION_TYPES.map(
|
||||
(t) => `<option value="${t}"${data.station_type === t ? " selected" : ""}>${t}</option>`,
|
||||
).join("")}
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input type="checkbox" name="enabled" ${data.enabled === false ? "" : "checked"} />
|
||||
启用
|
||||
</label>
|
||||
</div>
|
||||
<label>说明<textarea name="description" maxlength="500">${escapeHtml(data.description)}</textarea></label>
|
||||
<div class="form-actions">
|
||||
<button type="button" data-action="cancel-form" class="secondary">取消</button>
|
||||
<button type="submit">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSignalForm() {
|
||||
return `
|
||||
<form class="config-form signal-form" data-form="signal">
|
||||
<div class="form-row">
|
||||
<label>角色
|
||||
<select name="signal_role" required>
|
||||
${SIGNAL_ROLES.map((r) => `<option value="${r}">${r}</option>`).join("")}
|
||||
</select>
|
||||
</label>
|
||||
<label>Point ID<input name="point_id" placeholder="UUID 或留空走推导" /></label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>推导来源角色
|
||||
<select name="derived_from_role">
|
||||
<option value="">(none)</option>
|
||||
${SIGNAL_ROLES.map((r) => `<option value="${r}">${r}</option>`).join("")}
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input type="checkbox" name="invert_value" />取反
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit">绑定 / 更新</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSignals(signals) {
|
||||
if (!signals?.length) {
|
||||
return `<div class="muted card-empty">未绑定信号</div>`;
|
||||
}
|
||||
return `
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr><th>角色</th><th>Point</th><th>推导</th><th>取反</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${signals
|
||||
.map(
|
||||
(sig) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(sig.signal_role)}</td>
|
||||
<td class="mono">${escapeHtml(sig.point_id || "")}</td>
|
||||
<td>${escapeHtml(sig.derived_from_role || "")}</td>
|
||||
<td>${sig.invert_value ? "是" : "否"}</td>
|
||||
<td><button data-action="delete-signal" data-role="${escapeHtml(sig.signal_role)}" class="secondary">解绑</button></td>
|
||||
</tr>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRow(station) {
|
||||
const isExpanded = expanded.has(station.id);
|
||||
const isEditing = editing === station.id;
|
||||
const detail = stationDetails.get(station.id);
|
||||
return `
|
||||
<article class="config-row" data-station-id="${station.id}">
|
||||
<header class="row-head">
|
||||
<div class="row-title">
|
||||
<strong>${escapeHtml(station.code)}</strong>
|
||||
<span class="muted">${escapeHtml(station.name)}</span>
|
||||
${station.line_code ? `<span class="badge">${escapeHtml(station.line_code)}</span>` : ""}
|
||||
<span class="badge">${escapeHtml(station.station_type)}</span>
|
||||
${station.enabled ? "" : `<span class="badge badge-warn">已禁用</span>`}
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button data-action="toggle">${isExpanded ? "收起" : "信号"}</button>
|
||||
<button data-action="edit">${isEditing ? "取消" : "编辑"}</button>
|
||||
<button data-action="delete" class="danger">删除</button>
|
||||
</div>
|
||||
</header>
|
||||
${isEditing ? `<div class="row-edit">${renderForm(station)}</div>` : ""}
|
||||
${
|
||||
isExpanded
|
||||
? `<div class="row-body">
|
||||
${renderSignals(detail?.signals)}
|
||||
${renderSignalForm()}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
const root = el("stationList");
|
||||
if (!root) return;
|
||||
const list = Array.from(stations.values()).sort((a, b) => a.code.localeCompare(b.code));
|
||||
root.innerHTML = `
|
||||
${creating ? `<div class="config-row creating">${renderForm({})}</div>` : ""}
|
||||
${list.length === 0 ? `<div class="muted card-empty">尚无工位</div>` : list.map(renderRow).join("")}
|
||||
`;
|
||||
}
|
||||
|
||||
function formToPayload(form) {
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const payload = {
|
||||
code: data.code?.trim(),
|
||||
name: data.name?.trim(),
|
||||
station_type: data.station_type,
|
||||
enabled: form.elements.enabled.checked,
|
||||
};
|
||||
if (data.line_code) payload.line_code = data.line_code.trim();
|
||||
if (data.segment_code) payload.segment_code = data.segment_code.trim();
|
||||
if (data.description) payload.description = data.description;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function signalFormToPayload(form) {
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const payload = { signal_role: data.signal_role };
|
||||
if (data.point_id) payload.point_id = data.point_id.trim();
|
||||
if (data.derived_from_role) payload.derived_from_role = data.derived_from_role;
|
||||
payload.invert_value = form.elements.invert_value.checked;
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function refreshDetail(stationId) {
|
||||
try {
|
||||
const detail = await stationApi.detail(stationId);
|
||||
stationDetails.set(stationId, { signals: detail.signals || [] });
|
||||
} catch (err) {
|
||||
setBanner(el("stationList"), err.message || String(err), "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClick(event) {
|
||||
const button = event.target.closest("button[data-action]");
|
||||
if (!button) return;
|
||||
const action = button.dataset.action;
|
||||
const row = event.target.closest(".config-row");
|
||||
const stationId = row?.dataset?.stationId;
|
||||
|
||||
switch (action) {
|
||||
case "cancel-form":
|
||||
creating = false;
|
||||
editing = null;
|
||||
return renderAll();
|
||||
case "toggle":
|
||||
if (!stationId) return;
|
||||
if (expanded.has(stationId)) {
|
||||
expanded.delete(stationId);
|
||||
} else {
|
||||
expanded.add(stationId);
|
||||
await refreshDetail(stationId);
|
||||
}
|
||||
return renderAll();
|
||||
case "edit":
|
||||
editing = editing === stationId ? null : stationId;
|
||||
return renderAll();
|
||||
case "delete":
|
||||
if (!stationId) return;
|
||||
if (!window.confirm("确认删除该工位?此操作不可恢复。")) return;
|
||||
try {
|
||||
await stationApi.remove(stationId);
|
||||
stations.delete(stationId);
|
||||
expanded.delete(stationId);
|
||||
if (editing === stationId) editing = null;
|
||||
renderAll();
|
||||
setBanner(el("stationList"), "工位已删除", "info");
|
||||
} catch (err) {
|
||||
setBanner(el("stationList"), err.message || String(err), "error");
|
||||
}
|
||||
return;
|
||||
case "delete-signal": {
|
||||
if (!stationId) return;
|
||||
const role = button.dataset.role;
|
||||
try {
|
||||
await stationApi.deleteSignal(stationId, role);
|
||||
await refreshDetail(stationId);
|
||||
renderAll();
|
||||
setBanner(el("stationList"), `已解除 ${role} 绑定`, "info");
|
||||
} catch (err) {
|
||||
setBanner(el("stationList"), err.message || String(err), "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event) {
|
||||
const form = event.target.closest("form[data-form]");
|
||||
if (!form) return;
|
||||
event.preventDefault();
|
||||
const row = form.closest(".config-row");
|
||||
const stationId = row?.dataset?.stationId;
|
||||
|
||||
if (form.dataset.form === "station") {
|
||||
const payload = formToPayload(form);
|
||||
try {
|
||||
if (stationId && editing === stationId) {
|
||||
await stationApi.update(stationId, payload);
|
||||
setBanner(el("stationList"), "工位已更新", "info");
|
||||
} else {
|
||||
await stationApi.create(payload);
|
||||
setBanner(el("stationList"), "工位已创建", "info");
|
||||
}
|
||||
creating = false;
|
||||
editing = null;
|
||||
await loadStations();
|
||||
} catch (err) {
|
||||
setBanner(el("stationList"), err.message || String(err), "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.dataset.form === "signal") {
|
||||
if (!stationId) return;
|
||||
const payload = signalFormToPayload(form);
|
||||
try {
|
||||
await stationApi.upsertSignal(stationId, payload);
|
||||
await refreshDetail(stationId);
|
||||
renderAll();
|
||||
setBanner(el("stationList"), "信号绑定已保存", "info");
|
||||
} catch (err) {
|
||||
setBanner(el("stationList"), err.message || String(err), "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadStations() {
|
||||
try {
|
||||
const rows = await stationApi.list();
|
||||
stations.clear();
|
||||
rows.forEach((s) => stations.set(s.id, s));
|
||||
renderAll();
|
||||
} catch (err) {
|
||||
setBanner(el("stationList"), err.message || String(err), "error");
|
||||
}
|
||||
}
|
||||
|
||||
export function bindStationEvents() {
|
||||
const root = el("stationList");
|
||||
if (root) {
|
||||
root.addEventListener("click", handleClick);
|
||||
root.addEventListener("submit", handleSubmit);
|
||||
}
|
||||
const addBtn = el("addStationBtn");
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener("click", () => {
|
||||
creating = !creating;
|
||||
editing = null;
|
||||
renderAll();
|
||||
});
|
||||
}
|
||||
const refreshBtn = el("refreshStationsBtn");
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", () => loadStations());
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { el } from "./dom.js";
|
||||
import { loadSegmentsConfig } from "./segments-config.js";
|
||||
import { loadStations } from "./stations.js";
|
||||
import {
|
||||
bindPlatformConfigEvents,
|
||||
initPlatformConfigUi,
|
||||
loadPlatformConfig,
|
||||
} from "./platform/platform-config.js";
|
||||
import { startLogs, stopLogs } from "./platform/log-stream.js";
|
||||
|
||||
const VIEWS = ["monitor", "config", "platform"];
|
||||
const TAB_IDS = { monitor: "tabMonitor", config: "tabConfig", platform: "tabPlatform" };
|
||||
|
||||
let configLoaded = false;
|
||||
let platformLoaded = false;
|
||||
|
||||
function show(viewName) {
|
||||
VIEWS.forEach((name) => {
|
||||
const view = document.querySelector(`[data-view='${name}']`);
|
||||
if (view) view.classList.toggle("hidden", name !== viewName);
|
||||
const tab = el(TAB_IDS[name]);
|
||||
if (tab) tab.classList.toggle("active", name === viewName);
|
||||
});
|
||||
|
||||
if (viewName === "config" && !configLoaded) {
|
||||
configLoaded = true;
|
||||
Promise.allSettled([loadStations(), loadSegmentsConfig()]);
|
||||
}
|
||||
|
||||
// Real-time log stream only runs while the platform-config view is visible.
|
||||
if (viewName === "platform") {
|
||||
startLogs();
|
||||
if (!platformLoaded) {
|
||||
platformLoaded = true;
|
||||
loadPlatformConfig().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
stopLogs();
|
||||
}
|
||||
}
|
||||
|
||||
export function bindViewTabs() {
|
||||
VIEWS.forEach((name) => {
|
||||
const tab = el(TAB_IDS[name]);
|
||||
if (tab) tab.addEventListener("click", () => show(name));
|
||||
});
|
||||
// Platform-config listeners/UI bind once; data loads lazily on first view.
|
||||
bindPlatformConfigEvents();
|
||||
initPlatformConfigUi();
|
||||
show("monitor");
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { applyRuntimeUpdate } from "./segments.js";
|
||||
import { prependEvent } from "./platform/events.js";
|
||||
|
||||
const RECONNECT_INITIAL_MS = 1_000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
let socket = null;
|
||||
let reconnectDelay = RECONNECT_INITIAL_MS;
|
||||
|
||||
function setWsStatus(connected) {
|
||||
const dot = document.getElementById("wsDot");
|
||||
const label = document.getElementById("wsLabel");
|
||||
if (dot) {
|
||||
dot.classList.toggle("connected", connected);
|
||||
dot.classList.toggle("disconnected", !connected);
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = connected ? "已连接" : "连接断开,重连中…";
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(payload) {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
|
||||
// System events -> 运行监控 event panel.
|
||||
if (payload.type === "EventCreated" || payload.type === "event_created") {
|
||||
prependEvent(payload.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type !== "app_event") return;
|
||||
const event = payload.data;
|
||||
if (!event || event.app !== "operation-system") return;
|
||||
if (event.event_type === "segment_runtime_changed") {
|
||||
applyRuntimeUpdate(event.data);
|
||||
}
|
||||
}
|
||||
|
||||
export function startOpsSocket() {
|
||||
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
|
||||
socket = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setWsStatus(true);
|
||||
reconnectDelay = RECONNECT_INITIAL_MS;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
handleMessage(payload);
|
||||
} catch (err) {
|
||||
// Tolerate non-JSON pings.
|
||||
console.debug("ops ws non-json message", err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setWsStatus(false);
|
||||
socket = null;
|
||||
window.setTimeout(startOpsSocket, reconnectDelay);
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
||||
};
|
||||
|
||||
ws.onerror = () => setWsStatus(false);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue