Compare commits

...

33 Commits

Author SHA1 Message Date
caoqianming 07239ebff1 Unify two apps' UI: shared platform config + matching tabs
- Move shared platform JS (source/point/equipment/chart/events/...) from
  web/feeder/js to web/core/js/platform; both apps load it via the ServeDir
  core fallback (subdir avoids shadowing ops' own api.js/dom.js).
- Add platform-config.js and log-stream.js as shared modules; feeder app.js
  and logs.js now delegate instead of inlining (no duplication).
- ops: add 平台配置 tab reusing the shared panels; rename 段/工位配置 ->
  应用配置; move tabs into the topbar like feeder.
- Align view layout across both apps: 系统事件 in 运行监控 (live via WS),
  实时日志 in 平台配置.
- Titles: 投煤控制系统 / 隧道窑运转系统; feeder 运维 tab -> 运行监控.
- Update README; add CLAUDE.md documenting the shared-frontend architecture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:39:19 +08:00
caoqianming 5613c9f0d5 Harden operation segment control 2026-05-22 14:09:12 +08:00
caoqianming ed638eadb2 Stop respawning disabled-mode segment tasks
Supervisor filtered only on enabled, so disabled-mode segments (e.g.
the OPS_SEED_TEMPLATES skeleton, which seeds mode='disabled' until an
operator finishes wiring) got a task spawned every 10 s only to exit
immediately on the task's own enable-check, spamming the log with
"segment X disabled or removed, task exiting" every supervisor tick.

Aligning the supervisor's filter with the task's exit condition lets
disabled-mode segments stay quiescent. Flipping mode away from
'disabled' via the config UI now reaches the engine within one
supervisor cycle (≤10 s), unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:26:25 +08:00
caoqianming c5983ab5c3 Fix ops button colors hidden until hover
Core styles.css ships a global button { background: var(--accent);
color: #fff } primary rule. The ops overrides reset background to
var(--surface) (white) but inherited color: #fff, leaving white text
on a white background until the hover rule swapped color to accent.

Explicitly sets color on .card-actions / .row-actions / .form-actions
buttons and reshapes .toolbar so the "+ 新增" stays primary (filled
accent) and .secondary toolbar buttons get the white-with-accent-hover
treatment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:27:21 +08:00
caoqianming d616829988 Add run.md with operation-system bring-up instructions
Covers manual migration (db.rs intentionally does not auto-migrate),
env vars (DATABASE_URL / OPS_SERVER_HOST / OPS_SERVER_PORT /
RUST_LOG / OPS_SEED_TEMPLATES / SIMULATE_PLC), dev + release run
commands, verification endpoints, and a no-PLC end-to-end smoke flow
using the seed + simulator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:23:36 +08:00
caoqianming b84ce744d3 Ignore cl.ps1 / col.ps1 personal launcher scripts
Mirrors the existing cl.bat / col.bat entries — these are user-local
PowerShell wrappers that set HTTP_PROXY and shell out to claude / codex.
Not project artifacts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:07:19 +08:00
caoqianming 3667d64243 Add P10 ops config UI: stations + segments CRUD
Adds Monitor/Config tab switcher. Config view splits into stations
panel (inline create/edit/delete + per-station signal binding expand)
and segments panel (inline create/edit/delete + expandable detail with
step add/delete, interlock add/delete, and resource-keys replace).

UI talks to the existing ops CRUD endpoints exclusively; no engine
changes. node --check passes for all eight ops JS modules; backend
tests still green. Browser verification still required end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:56:26 +08:00
caoqianming e2248fa04f Add P9 segment monitor page for operation-system
Replaces the ops UI placeholder with a single-panel monitor: fetches
/api/runtime/overview to render one card per segment with state badge,
current step, fault / block note, and per-card Start / Stop / Ack-Fault
/ Reset buttons plus batch start/stop. WebSocket subscriber routes
app_event(app=operation-system, event_type=segment_runtime_changed)
into in-place card updates with exponential reconnect.

Note: UI not verified in-browser; the engine + WebSocket plumbing has
unit + smoke test coverage but the page itself needs runtime
validation by running app_operation_system and visiting /ui/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:19:20 +08:00
caoqianming 972938a8e6 Seed public segments and emit signal-conflict alarms
Extends ensure_default_templates to also seed the 5 公共段 (前端码车 /
前端放车 / 前端摆渡 / 窑尾摆渡 / 卸砖 / 回车) and their shared resource
declarations (transfer_front / transfer_tail / unload_position /
return_line / robot_arm) so operators have the full skeleton to bind
equipment + signals against.

Engine now runs a station signal-conflict check during the run-halt
phase: any station whose presence and vacancy are both true with Good
quality emits ops.alarm.signal_conflict + segment.fault_locked and
transitions the segment to Faulted. Closes the final P8 alarm type
from design doc §8.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:16:04 +08:00
caoqianming a7f5c85032 Persist event subject_type/subject_id and add ops event timeline API
EventInsert + EventRecord + record_event now carry the subject_type /
subject_id columns added by the P1 migration. Ops events populate
"segment" / "station" subjects so the timeline can be filtered without
parsing event_type strings. Platform SourceCreated / Updated / Deleted
attribute themselves to subject_type="source". Adds get_events_*_filtered
in core and exposes GET /api/event on ops with event_type /
event_type_prefix / subject_type / subject_id query params, closing
design doc §14 "event 表能按 ops.* 和 subject_type/subject_id 查到全链路事件".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:57:40 +08:00
caoqianming ed1067f6e5 Reclaim stale resource leases and refresh heartbeats
Adds ResourceRegistry::sweep_stale and runs it on each supervisor tick
so a panicked or stuck segment task can't keep a shared resource
locked indefinitely. The per-segment task refreshes heartbeat on every
iteration for each key in runtime.held_resources, distinguishing live
owners from dead ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:49:27 +08:00
caoqianming e3e7917078 Seed dual-kiln segment templates behind OPS_SEED_TEMPLATES
ensure_kiln_templates idempotently inserts 6 dry-kiln stations and 6
segments (infeed/step/outfeed × 2 kilns) with their canonical step
sequences from §10.1. Equipment and station-signal bindings stay
operator-owned through the CRUD APIs. Startup runs the seed only when
OPS_SEED_TEMPLATES=true|1, so production deployments don't accidentally
mutate config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:47:18 +08:00
caoqianming aaf48a336d Add hold/value dispatch modes, cancel_on_fault, and SIMULATE_PLC injection
step_executor gains three dispatch modes: pulse (default), hold
(hold_until_confirm), and value (transfer_move_to writes the target
station's code). The engine now sends step.stop_command_role whenever
cancel_on_fault is true on Faulted entry, and threads a target-station
lookup ahead of dispatch. A new simulate module patches the resolved
confirm signal after a short delay when SIMULATE_PLC is set, so
segments can be driven end-to-end without a real PLC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:44:10 +08:00
caoqianming 63683a24c8 Implement operation-system engine MVP (P3)
Adds the segment supervisor + per-segment state machine driving
Idle → Checking → Executing → Confirming → Completed (plus Blocked /
Faulted / ManualAckRequired), interlock evaluator, action-kind step
executor, control + runtime HTTP handlers, and WebSocket runtime push
via AppEvent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:39:14 +08:00
caoqianming a33c013da5 Add operation-system config schema and CRUD API
Land design doc §12 P1 + P2: ops business tables plus station/segment
configuration endpoints. The engine (P3) consumes these as its inputs.

- Migration: six ops tables (station, station_signal, process_segment,
  segment_step, segment_interlock, segment_resource) plus event attribution
  columns (subject_type, subject_id).
- model.rs: FromRow structs and string-backed enum helpers
  (StationType, StationSignalRole, SegmentMode, ActionKind, OnTimeout,
  InterlockAppliesTo, RuleKind).
- service: station CRUD with signal-binding upsert; segment CRUD with
  nested step/interlock CRUD and transactional resource replacement.
- handler: 13 endpoints covering design doc §9.1 config routes with
  validator-based input checks and enum allowlists.
- router: wires the new routes; smoke tests cover station and segment
  collection routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:17:34 +08:00
caoqianming fd028b1320 Bootstrap operation-system app skeleton
Wires AppState with EventManager, SegmentRuntimeStore, ResourceRegistry
and an engine supervisor that idles until P1 lands the segment schema.
The run() bootstrap connects enabled sources, installs a Ctrl+C handler,
and disconnects on shutdown, matching the feeder app lifecycle. The
router exposes /ws/public, /ws/client/{id}, simple_logger middleware
and a permissive CORS layer.

AppEvent covers the full ops.* taxonomy from the spec; resource lease
tracking includes heartbeat timestamps for the §7 recovery strategy and
has two unit tests for acquire/release semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:39:09 +08:00
caoqianming 19ace9c2be Move feeder unit model out of platform core
Lifts ControlUnit, UnitRuntime/State, ControlRuntimeStore and unit CRUD
into app_feeder_distributor; their feeder-specific semantics
(DistributorRunning state, run_time/stop_time/acc_time/bl_time pacing)
violated the shared-core invariant.

WsMessage drops the UnitRuntimeChanged variant in favor of a generic
AppEvent(AppWsEvent) carrying {app, event_type, data}. Feeder emits
"feeder"/"unit_runtime_changed"; the web client dispatches by app
namespace, leaving core free of business types. The equipment handler
keeps its friendly unit-exists check by issuing an inline EXISTS query
instead of pulling the unit service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:38:52 +08:00
caoqianming 3467f203ca Add operation-system engine design spec
Spec covers station/segment/step/interlock domain model, segment state
machine (Idle..ManualAckRequired), action templates including persistent
commands, resource lease registry, ops.* event taxonomy, and the AppEvent
WebSocket envelope. Stage plan includes P-1 core cleanup before ops work
begins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:38:33 +08:00
caoqianming 2a6dde9e0e Restore SIMULATE_PLC chaos task and run feedback
Bring back crates/app_feeder_distributor/src/control/simulate.rs which
was dropped when feeder's local connection/telemetry/websocket modules
moved into core; the restored module reuses core's equivalents via
plc_platform_core::{connection, service, telemetry, websocket}.

Also restore the call sites that were lost alongside it:
- app.rs starts the chaos task when SIMULATE_PLC=true
- engine.rs fires simulate_run_feedback after each pulse command so
  the auto-control state machine sees the RUN bit transition it
  would get from a real PLC
- handler/control.rs does the same after manual start/stop commands

The SIMULATE_PLC flag is now read via simulate::enabled() from the
environment rather than state.config.simulate_plc (the old config
struct was removed with the module migration). To expose equipment
ids by kind (used for run feedback), build_equipment_maps now also
returns HashMap<kind, Uuid>.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:33:51 +08:00
caoqianming 3b92c0028a Merge handle_control_event and persist_event_if_needed
The split existed only so handle_control_event could log
UnitStateChanged and then delegate; that's no longer worth its own
function. Co-locating the UnitStateChanged special case with the other
match arms makes its 'log-only, no persist' treatment self-evident,
and the call chain drops from handle → persist → record to handle →
record.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 08:39:17 +08:00
caoqianming 52cd3e630e Rename persist_and_broadcast to record_platform_event
After record_event became the primitive for INSERT + broadcast + tracing,
persist_and_broadcast no longer persists or broadcasts directly — it
translates a PlatformEvent into an EventInsert and delegates. The new
name makes the layering explicit (record_event is the primitive,
record_platform_event is the PlatformEvent translator).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 08:33:28 +08:00
caoqianming 6c8e5561dc Cache unit/equipment codes for event messages
Add MetadataCache to PlatformContext — a lazy-loaded, cross-app cache
of code fields used when formatting event messages. Each persisted
AppEvent previously did 1-2 extra SELECTs to look up the code for its
human-readable message; after this change the same id hits the cache
on all subsequent events.

Invalidation: the platform-owned equipment handler invalidates its
entry on update/delete; feeder's unit handler does the same for
units. Deletes are invalidated for hygiene only — no further events
should target a deleted id.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 08:21:46 +08:00
caoqianming 3e0d4c242b Emit tracing inside record_event and drop duplicate feeder match
record_event now logs at the matching tracing level (error/warn/info)
using the persisted message — giving every app uniform event logs for
free. Feeder's handle_control_event collapses from a 60-line match
(which just duplicated the persisted message with less-readable UUIDs)
to a single if-let for UnitStateChanged, which is the only AppEvent
that is intentionally not persisted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 20:11:59 +08:00
caoqianming 1c646dfaa7 Extract event persistence primitive to platform core
Move the INSERT + WebSocket broadcast mechanism out of the feeder app
and into plc_platform_core as pub record_event(pool, ws, EventInsert).
The event table schema is owned by core, so writing to it is a platform
capability — apps (feeder, future ops) should only decide what to emit,
not how to persist it. Also replaces the 7-tuple in core's
persist_and_broadcast with the named EventInsert struct for readability.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 20:04:25 +08:00
caoqianming 58fdb9f58e Clean up clippy warnings and improve code organization
- engine.rs: use is_none_or instead of map_or, remove redundant closures
- service/control.rs: move get_equipment_role_points before test module
- event.rs: replace complex 7-element tuple with PersistableEvent struct

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 19:49:27 +08:00
caoqianming 706fb4f72a Simplify imports in feeder control handler
Replace verbose fully-qualified paths with top-level use imports
for plc_platform_core types and services throughout control.rs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 19:45:46 +08:00
caoqianming 087f016f01 Remove dead handler re-exports from feeder
The feeder router uses platform_routes() directly from core,
making these re-export modules unused. The only actual dependency
(SignalRolePoint) now references plc_platform_core directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 19:32:20 +08:00
caoqianming dabcde1fca Remove unit change platform event plumbing 2026-04-21 19:12:09 +08:00
caoqianming a49f6adf9b Format feeder event module 2026-04-21 16:24:26 +08:00
caoqianming f8ba864a65 Clean feeder core dependency boundaries 2026-04-21 16:22:11 +08:00
caoqianming 24b1d3546b Move shared feeder plumbing into core 2026-04-21 16:04:03 +08:00
caoqianming 1317271e16 refactor(core): centralize telemetry, connection management, and event sink in platform
Move TelemetryProcessor (PointNewValue batching/dedup/broadcast) from feeder to
plc_platform_core. PlatformBuilder.build() now auto-wires telemetry processing and
reconnect task. PlatformContext.emit_event() handles connection management side effects
(connect/reconnect/disconnect/subscribe/unsubscribe) directly. Simplify PlatformEventSink
trait from 6 methods to single on_event(). Feeder's AppEvent now only contains business
events; FeederPlatformEventSink only handles UnitsChanged for control runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:30:45 +08:00
caoqianming 6814e9eae9 refactor: migrate platform handlers to core, centralize routes and event persistence
- Move source/point/equipment/tag/page handlers from feeder to plc_platform_core
  using State<PlatformContext>; feeder re-exports via handler modules
- Keep batch_set_point_value in feeder (requires app-specific write key auth)
- Add PlatformEvent enum and persist_and_broadcast() in core for platform event
  persistence to DB + WebSocket broadcast
- Add PlatformContext::emit_event() that handles both sink notification and
  async persistence in one call
- Add platform_routes<S>() in core for centralized route registration;
  both feeder and ops merge it instead of duplicating route definitions
- Implement FromRef<AppState> for PlatformContext in both apps
- Add FeederPlatformEventSink adapter bridging core events to feeder's
  EventManager + ControlRuntimeStore
- Add event namespace prefixes: platform.source.created, feeder.unit.fault_locked, etc.
- Register full platform CRUD routes in ops app

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 13:45:02 +08:00
102 changed files with 10901 additions and 2071 deletions

2
.gitignore vendored
View File

@ -31,4 +31,6 @@ Thumbs.db
.VSCodeCounter/
cl.bat
col.bat
cl.ps1
col.ps1
.worktrees/

42
CLAUDE.md Normal file
View File

@ -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 监听 → 凡引入平台 JSevents.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())`,平台 CRUDsource/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`

6
Cargo.lock generated
View File

@ -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]]

View File

@ -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`
## 文档索引

View File

@ -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,

View File

@ -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))
}

View File

@ -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, &notify, &mut fault_tick).await {
if !wait_phase(
&state,
&store,
&unit_for_wait,
&all_roles,
&notify,
&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, &notify, &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;

View File

@ -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,
}

View File

@ -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);

View File

@ -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 {
})),
)
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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))?;

View File

@ -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;

View File

@ -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
}

View File

@ -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>,
}

View File

@ -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)
}

View File

@ -0,0 +1,3 @@
pub mod unit;
pub use unit::*;

View File

@ -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");
}
}

View File

@ -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;
}

View File

@ -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]

View File

@ -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"] }

View File

@ -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()),
}
}

View File

@ -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")
);
}
}

View File

@ -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());
}
}

View File

@ -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
}

View File

@ -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"));
}
}

View File

@ -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();
}
}
}

View File

@ -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"),
}
}
}

View File

@ -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,
}

View File

@ -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(_)));
});
}
}

View File

@ -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;
}
}

View File

@ -1 +1,6 @@
pub mod control;
pub mod doc;
pub mod event;
pub mod runtime;
pub mod segment;
pub mod station;

View File

@ -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 })))
}

View File

@ -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,
)))
}

View File

@ -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,
})))
}

View File

@ -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());
}
}

View File

@ -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()));
}
}
}

View File

@ -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;

View File

@ -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>,
}

View File

@ -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)
}

View File

@ -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"),
}
}
}

View File

@ -0,0 +1,2 @@
pub mod segment;
pub mod station;

View File

@ -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
}

View File

@ -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(&params.signal_role)
.bind(params.point_id)
.bind(&params.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)
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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)
}

View File

@ -1,2 +1 @@
pub mod command;
pub mod runtime;

View File

@ -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())
}

View File

@ -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;

View File

@ -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)
}

View File

@ -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 {

View File

@ -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,
}
}

View File

@ -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))
}

View File

@ -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"))?;

View File

@ -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)
}

View File

@ -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))
}

View File

@ -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;

View File

@ -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")]

View File

@ -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);
}
}
}
});
}
}

View File

@ -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");
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -160,10 +160,6 @@
批量写点。
请求头:
- `X-Write-Key: <key>`
请求示例:
```json

View File

@ -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 完成。

View File

@ -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 // AppEventops.*
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)` 查 codefeeder 用 unit/equipmentops 加 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** | 下放到 feederevent 查询留 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`handlerrouter |
| **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 客户端调用。要求迁移单独成 commitfeeder 启动 + 单元测试 + 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. 验收标准
完成 P0P5 后应达到:
- 仓库新增 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` 查到全链路事件。
完成 P6P10 后应达到:
- 1 号 / 2 号窑线全部 6 段(进口 / 内前移 / 出口 × 2 窑)跑通。
- 公共段(前端码车、摆渡车、窑尾、卸砖、回车)跑通。
- 报警分类齐全(说明书 13.4 全部 10 类)。
- 监控前端 + 配置前端可用。
## 15. 后续可演进项(非首期)
- 联锁 `expression` 类型:引入简单布尔表达式语言,替代 `rule_kind` 枚举。
- 段历史持久化:将每段每次完成 / 故障写入 `segment_run_history`,支持时间线回放。
- 现场调试视图:模拟点位值、单步推进、跳步授权(带操作员签名)。
- 公共能力下沉:若后续出现第三套类似业务,再把 segment 引擎抽到 `plc_platform_core::control::segment`

View File

@ -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);

63
run.md Normal file
View File

@ -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`,端口默认 3100feeder 是 60309
- Windows release 用 `windows_subsystem = "windows"`,不出黑窗;要看日志直接打开 `logs/` 下的 `app.log*`,或者从命令行跑 debug build。
- OPC UA 数据源在 `source` 表里配,启动时 `connect_all_enabled_sources` 会自动连。没配数据源不影响 UI / 段配置使用。

View File

@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
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;
}
}

View File

@ -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();
}

View File

@ -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>

View File

@ -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>

View File

@ -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()]));

View File

@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
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 {

View File

@ -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"];

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

93
web/ops/js/api.js Normal file
View File

@ -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 }, "更新资源声明失败"),
};

View File

@ -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();

24
web/ops/js/dom.js Normal file
View File

@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
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);
}

View File

@ -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());
}

185
web/ops/js/segments.js Normal file
View File

@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
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();
});
}
}

314
web/ops/js/stations.js Normal file
View File

@ -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());
}

51
web/ops/js/views.js Normal file
View File

@ -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");
}

66
web/ops/js/ws.js Normal file
View File

@ -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