Compare commits

...

44 Commits

Author SHA1 Message Date
caoqianming 13c4b515d7 fix(docs): widen API drawer and fix TOC scroll hijacking window
- Add .api-drawer (1100px) so content area is ~880px instead of ~540px
- Replace scrollIntoView with apiDocContent.scrollBy to avoid scrolling
  the window and collapsing the drawer layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 14:41:17 +08:00
caoqianming 22ae4fa4b4 docs(api): add missing Tag, Page, Log sections and batch-auto/set-tags endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 14:27:49 +08:00
caoqianming bc6e6e889f feat(events): infinite scroll with page_size=10, replace full re-render
Replace bulk load+re-render with scroll-based pagination: load 10 items
on init, append next page when scrolling near the bottom. prependEvent
now inserts directly into DOM instead of rebuilding from state.events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:23:18 +08:00
caoqianming 2a247dd70b fix(toast): remove shake class before hiding to ensure animationend fires
When dismiss() was called on a persistent+shaking toast, the .shake CSS
rule (declared after .hiding) overrode toast-out animation. If shake had
already finished, no animationend fired and the element was never removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:16:50 +08:00
caoqianming b7d55fed81 refactor(web): unify toast — remove duplicate, reuse api.js showToast
- Deleted redundant toast.js (was duplicating api.js's toast with conflicting CSS)
- Extended api.js showToast: returns {dismiss}, supports shake option, guards
  against double-dismiss
- Removed duplicate #toastContainer CSS block from styles.css; added shake
  animation to existing toast CSS
- logs.js: import showToast from api.js; WS disconnect shows persistent error
  toast with shake, reconnect shows success toast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:11:23 +08:00
caoqianming 69ae0b05b7 feat(web): add toast notifications + WS disconnect alert
- New toast.js module: showToast(message, type, duration)
  - types: error / warning / success / info
  - duration=0 → persistent until dismissed or manually closed
  - click to dismiss early
  - shake animation on appear
- WS disconnect: persistent red error toast "后端连接断开,正在重连…"
- WS reconnect: dismisses disconnect toast, shows green "连接已恢复" for 3s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:08:14 +08:00
caoqianming 757d6f9a3a feat(control): batch start/stop auto control for all enabled units
Backend:
- POST /api/control/unit/batch-start-auto — starts auto on all enabled
  units that are not fault/comm locked and not already running auto
- POST /api/control/unit/batch-stop-auto — stops auto on all units

Frontend (ops view):
- Add "全部启动" / "全部停止" buttons in the unit sidebar header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:05:23 +08:00
caoqianming 0077a4ad90 fix(engine): stop coal_feeder before starting distributor on acc_time trigger
When accumulated_run_sec reaches acc_time_sec, the coal feeder must be stopped
before entering DistributorRunning state. Previously the feeder was left running
while the distributor also ran, which is incorrect per the control spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:00:24 +08:00
caoqianming 3a8a2c1389 fix(sim): try OPC UA write first in simulate_run_feedback, fallback to cache patch
Instead of always patching the local cache, first attempt to write the RUN
point value through the normal OPC UA path. If the proxy accepts the write,
write_point_values_batch already emits PointNewValue locally so no extra work
is needed. Only fall back to direct cache patching when the write is rejected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 12:50:26 +08:00
caoqianming b28ac23520 feat(web): show WS connection status indicator in topbar
Green dot + "已连接" when socket is open; red dot + "连接断开,重连中…"
on close/error. Reconnect timer (2s) already in place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 12:41:50 +08:00
caoqianming b832d98196 fix(control): block manual commands during auto, fix engine stop_time=0 bug, add sim feedback
- validator: reject equipment start/stop when unit auto_enabled
- engine: fix stop_time_sec==0 causing infinite Stopped state (never starts)
- engine: call simulate_run_feedback after auto commands when SIMULATE_PLC=true
- command: extract simulate_run_feedback to shared module (was private in handler)
- web: disable Start/Stop buttons when unit auto is active; sync on WS runtime update

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 12:37:43 +08:00
caoqianming 989a0286e9 feat(sim): simulate RUN signal feedback when SIMULATE_PLC=true
After a successful start/stop command, write run=true/false directly
into the point monitor cache and broadcast PointNewValue via WebSocket.
Gated by SIMULATE_PLC=true env var; real OPC-UA values override it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 11:08:38 +08:00
caoqianming 36cfe9ecfc feat(web): add runtime badge and auto/ack buttons to ops unit list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:47:09 +08:00
caoqianming 4076f6575e feat(web): dual-view UI — 运维/配置 tab, ops equipment cards with live signal values
- Add 运维/配置 tab switch; grid-ops / grid-config layout classes
- New ops-panel: unit sidebar + equipment card grid (REM/RUN/FLT signals)
- All equipment cards shown by default; unit click acts as filter
- Signal cells seed from point_monitor cache on render, then update via WS PointNewValue
- New log-stream-panel: SSE realtime log stream, active only in config view
- Backend: get_unit_detail now includes point_monitor (current value) in each point

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:25:20 +08:00
caoqianming c2ed1e70fb docs(api): rewrite API.md to cover all current endpoints
Add Equipment, Unit, Event, Control sections. Update Point (equipment_id
filter, signal_role in PUT). Add EventCreated and UnitRuntimeChanged to
WebSocket. Remove stale SSE log stream section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:50:47 +08:00
caoqianming 2732238be7 feat(api): add GET /api/unit/{id}/detail with nested equipment and points
Returns unit with its equipments, each embedding their bound points.
Uses 2 queries (equipment list + points via ANY) to avoid N+1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:37:09 +08:00
caoqianming 884f6ba5f3 docs: update feature spec with current implementation status
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:27:09 +08:00
caoqianming dae5bdcb9e fix(web): fix node count showing 2x by counting only details elements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:27:06 +08:00
caoqianming 622d010cb1 fix(server): add Cache-Control: no-store to static file responses
Prevents browser from caching JS/CSS modules, so frontend changes take
effect immediately on page refresh without needing hard refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:57:39 +08:00
caoqianming 3e277cdb52 refactor(web): event card layout to 2-line (meta row + message row)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:46:32 +08:00
caoqianming a405623ec1 refactor(web): remove realtime log stream, compact event list to single line
- Remove SSE log stream (EventSource /api/logs/stream) and logView panel
- System events panel now occupies the full bottom-middle panel
- Each event renders as a single flex row: level badge, type, message, timestamp
- Remove logSource from state, logView from dom, startLogs from app bootstrap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:39:01 +08:00
caoqianming d88d8375fd fix(frontend): pre-select active unit when opening new equipment modal
When a unit is selected in the sidebar, the create-equipment modal now
pre-fills the unit dropdown with that unit. Previously it always reset
to empty, so newly created equipment got unit_id=null and was hidden by
the unit filter after save.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 16:35:33 +08:00
caoqianming d1131c4e2d fix(frontend): show fault/comm locked state in runtime badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:04:17 +08:00
caoqianming b5a8d6a71d fix(engine): correct fault equipment ID lookup and all_roles data structure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:03:26 +08:00
caoqianming 0c2ce48d23 feat(frontend): handle UnitRuntimeChanged WebSocket message 2026-03-24 14:58:29 +08:00
caoqianming 21f6008cba feat(frontend): add start/stop control buttons to equipment cards 2026-03-24 14:58:16 +08:00
caoqianming 89023e867b feat(frontend): show runtime state and auto/ack buttons on unit cards 2026-03-24 14:58:07 +08:00
caoqianming 31ccf49b75 feat(frontend): add runtimes map to state 2026-03-24 14:57:41 +08:00
caoqianming d2bd567799 feat(main): register control engine routes and start engine 2026-03-24 14:56:19 +08:00
caoqianming 856c888667 feat(control): add start-auto, stop-auto, ack-fault, runtime endpoints 2026-03-24 14:56:16 +08:00
caoqianming 459bb49c65 feat(control): implement state machine engine with fault/comm monitoring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 14:53:59 +08:00
caoqianming 5c0b99c0d4 feat(service): add get_all_enabled_units and get_equipment_by_unit_id 2026-03-24 14:48:30 +08:00
caoqianming 6a4c3b1d39 feat(websocket): add UnitRuntimeChanged message type 2026-03-24 14:47:35 +08:00
caoqianming 68e724898c feat(event): add business control events (fault, comm, auto, state change) 2026-03-24 14:46:25 +08:00
caoqianming 684ca9da85 feat(control): reject manual commands when unit is fault/comm locked 2026-03-24 14:44:48 +08:00
caoqianming 628553f2b8 refactor(control): extract pulse command helper to control/command.rs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 14:31:37 +08:00
caoqianming 9194bd1dca feat(control): add auto_enabled and flt_active to UnitRuntime 2026-03-24 14:23:53 +08:00
caoqianming 49a4afa4a4 feat(web): auto-toast on API errors with dismissible notifications
Add showToast() utility in api.js and a matching toast stylesheet.
apiFetch now automatically shows a toast for any 400+ response before
re-throwing, so callers can still .catch() for additional handling.

Toasts stack at the bottom-right, auto-dismiss after 4s, and support
error/warning/success/info levels via a left-border colour accent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 13:43:57 +08:00
caoqianming f7dc39a70a fix: point bind bug 2026-03-24 13:39:45 +08:00
caoqianming a38204511a refactor(control): align point roles and equipment kind 2026-03-24 13:17:53 +08:00
caoqianming 2d80266422 fix(opcua): trigger reconnect on BadTimeout and tighten subscription params
Remove the special-case that silently ignored BadTimeout in the
subscription status callback. BadTimeout means the server has already
dropped the subscription, so reconnect must be triggered immediately
rather than waiting for the heartbeat check.

Also reduce lifetime_count (120→15) and max_keep_alive_count (10→5)
so failures are detected within 15s instead of 120s, while still
satisfying the OPC UA spec requirement of lifetime >= 3×keepalive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:50:55 +08:00
caoqianming 0b9b7aef7d fix(opcua): relax subscription timeout handling 2026-03-24 12:28:23 +08:00
caoqianming c50127b9d0 feat(event): stream created events over websocket 2026-03-24 12:28:12 +08:00
caoqianming 97d2f6ebf8 feat(control): add manual equipment pulse commands 2026-03-24 11:16:50 +08:00
36 changed files with 3249 additions and 794 deletions

688
API.md
View File

@ -1,6 +1,6 @@
# PLC Control 接口说明
本文档基于当前服务端路由与处理器代码整理,覆盖 HTTP API、SSE 日志流和 WebSocket 实时消息。
本文档基于当前服务端路由与处理器代码整理,覆盖 HTTP API 和 WebSocket 实时消息。
## 基本信息
@ -24,7 +24,7 @@
常见状态码:
- `400 Bad Request`:参数错误
- `403 Forbidden`:写入权限不足
- `403 Forbidden`:写入权限不足或控制条件不满足
- `404 Not Found`:资源不存在
- `500 Internal Server Error`:服务端内部错误
@ -74,16 +74,12 @@
响应:
```json
{
"id": "uuid"
}
{ "id": "uuid" }
```
### PUT `/api/source/{source_id}`
更新数据源。
请求体字段均可选:
更新数据源。请求体字段均可选:
```json
{
@ -97,63 +93,29 @@
}
```
响应:
```json
{
"ok_msg": "Source updated successfully"
}
```
### DELETE `/api/source/{source_id}`
删除数据源。
成功响应:`204 No Content`
删除数据源。成功响应:`204 No Content`
### POST `/api/source/{source_id}/reconnect`
手动重连指定数据源。
响应:
```json
{
"ok_msg": "Source reconnected successfully"
}
{ "ok_msg": "Source reconnected successfully" }
```
### POST `/api/source/{source_id}/browse`
从 OPC UA 源浏览节点并写入本地 `node` 表。
响应:
```json
{
"ok_msg": "Browse completed",
"total_nodes": 123
}
{ "ok_msg": "Browse completed", "total_nodes": 123 }
```
### GET `/api/source/{source_id}/node-tree`
获取指定数据源的节点树。
响应字段:
- `id`
- `source_id`
- `external_id`
- `namespace_uri`
- `namespace_index`
- `identifier_type`
- `identifier`
- `browse_name`
- `display_name`
- `node_class`
- `parent_id`
- `children`
获取指定数据源的节点树(含 `children` 递归嵌套)。
---
@ -161,13 +123,14 @@
### GET `/api/point`
分页获取点位列表。
分页获取点位列表,同时返回实时监测值
查询参数:
- `source_id`:可选,按数据源过滤
- `equipment_id`:可选,按设备过滤
- `page`:页码
- `page_size`:每页条数
- `page_size`:每页条数`-1` 表示全量)
响应示例:
@ -175,28 +138,22 @@
{
"data": [
{
"point": {
"id": "uuid",
"node_id": "uuid",
"name": "Temperature",
"description": null,
"unit": null,
"tag_id": null,
"equipment_id": "uuid",
"signal_role": "run",
"created_at": "2026-03-20 10:00:00.000",
"updated_at": "2026-03-20 10:00:00.000",
"updated_at": "2026-03-20 10:00:00.000"
},
"point_monitor": {
"protocol": "opcua",
"source_id": "uuid",
"point_id": "uuid",
"client_handle": 1001,
"scan_mode": "subscribe",
"timestamp": "2026-03-20 10:05:00.000",
"quality": "good",
"value": 12.3,
"value_type": "float",
"value_text": "12.3",
"old_value": 12.1,
"old_timestamp": "2026-03-20 10:04:59.000",
"value_changed": true
"value_text": "12.3"
}
}
],
@ -212,13 +169,9 @@
### GET `/api/point/{point_id}/history`
获取点位最近历史样本。数据来自进程内存中的环形缓冲,不是持久化历史库
获取点位最近历史样本(进程内存环形缓冲,重启后清空)
查询参数:
- `limit`:可选,默认 `120`,最大 `1000`
响应示例:
查询参数:`limit`(可选,默认 `120`,最大 `1000`
```json
[
@ -232,23 +185,17 @@
]
```
说明:
- `value_number` 便于前端直接绘图
- 非数值型点位时,`value_number` 可能为 `null`
### PUT `/api/point/{point_id}`
更新点位元数据。
请求体:
更新点位元数据,字段均可选:
```json
{
"name": "Temperature",
"description": "Room temperature",
"unit": "°C",
"tag_id": "uuid"
"equipment_id": "uuid",
"signal_role": "run"
}
```
@ -256,24 +203,12 @@
删除单个点位。
响应:
```json
{
"ok_msg": "Point deleted successfully"
}
```
### POST `/api/point/batch`
根据节点批量创建点位。
请求体:
```json
{
"node_ids": ["uuid1", "uuid2"]
}
{ "node_ids": ["uuid1", "uuid2"] }
```
响应:
@ -291,27 +226,25 @@
批量删除点位。
请求体:
```json
{
"point_ids": ["uuid1", "uuid2"]
}
{ "point_ids": ["uuid1", "uuid2"] }
```
响应:
### PUT `/api/point/batch/set-equipment`
批量设置点位的设备绑定和信号角色。
```json
{
"deleted_count": 2
"point_ids": ["uuid1", "uuid2"],
"equipment_id": "uuid",
"signal_role": "run"
}
```
### PUT `/api/point/batch/set-tags`
批量设置点位标签。
请求体:
批量设置点位的标签(`tag_id`,传 `null` 可清除绑定)。
```json
{
@ -320,40 +253,213 @@
}
```
响应:
```json
{ "ok_msg": "Point tags updated successfully", "updated_count": 2 }
```
### POST `/api/point/value/batch`
批量写点。
请求头:
- `X-Write-Key: <key>`
请求体:
请求头:`X-Write-Key: <key>`
```json
{
"items": [
{
"point_id": "uuid",
"value": 12.3
}
{ "point_id": "uuid", "value": 12.3 }
]
}
```
响应:
---
## Equipment
### GET `/api/equipment`
分页获取设备列表,包含每台设备绑定的点位数量。
查询参数:`page`、`page_size``-1` 全量)、`keyword`(可选,按 code/name 模糊搜索)
响应示例:
```json
{
"success": true,
"err_msg": null,
"success_count": 1,
"failed_count": 0,
"results": [
"data": [
{
"point_id": "uuid",
"success": true,
"err_msg": null
"id": "uuid",
"unit_id": "uuid",
"code": "E01",
"name": "投煤器1",
"kind": "coal_feeder",
"description": null,
"created_at": "2026-03-20 10:00:00.000",
"updated_at": "2026-03-20 10:00:00.000",
"point_count": 5
}
],
"total": 1,
"page": 1,
"page_size": 20
}
```
### POST `/api/equipment`
创建设备。
```json
{
"unit_id": "uuid",
"code": "E01",
"name": "投煤器1",
"kind": "coal_feeder",
"description": null
}
```
响应:`201 Created`
```json
{ "id": "uuid", "ok_msg": "Equipment created successfully" }
```
### PUT `/api/equipment/{equipment_id}`
更新设备,字段均可选:
```json
{
"unit_id": "uuid",
"code": "E01",
"name": "投煤器1",
"kind": "coal_feeder",
"description": null
}
```
### DELETE `/api/equipment/{equipment_id}`
删除设备。成功响应:`204 No Content`
### GET `/api/equipment/{equipment_id}/points`
获取指定设备下所有绑定点位。
### PUT `/api/equipment/batch/set-unit`
批量将设备绑定到控制单元。
```json
{
"equipment_ids": ["uuid1", "uuid2"],
"unit_id": "uuid"
}
```
---
## Unit控制单元
### GET `/api/unit`
分页获取控制单元列表。
查询参数:`page`、`page_size`、`keyword`(可选)
响应字段包含:`id`、`code`、`name`、`enabled`、`run_time_sec`、`stop_time_sec`、`acc_time_sec`、`bl_time_sec`、`require_manual_ack_after_fault`
### POST `/api/unit`
创建控制单元。
```json
{
"code": "U01",
"name": "1号机组",
"description": null,
"enabled": true,
"run_time_sec": 60,
"stop_time_sec": 30,
"acc_time_sec": 3600,
"bl_time_sec": 10,
"require_manual_ack_after_fault": true
}
```
响应:`201 Created`
```json
{ "id": "uuid", "ok_msg": "Unit created successfully" }
```
### GET `/api/unit/{unit_id}`
获取单个控制单元。
### PUT `/api/unit/{unit_id}`
更新控制单元,字段均可选。
### DELETE `/api/unit/{unit_id}`
删除控制单元。成功响应:`204 No Content`
### GET `/api/unit/{unit_id}/runtime`
获取控制单元的当前运行时状态(内存中,不持久化)。
响应示例:
```json
{
"unit_id": "uuid",
"state": "running",
"auto_enabled": true,
"accumulated_run_sec": 3600000,
"current_run_elapsed_sec": 60000,
"current_stop_elapsed_sec": 0,
"distributor_run_elapsed_sec": 0,
"fault_locked": false,
"flt_active": false,
"comm_locked": false,
"manual_ack_required": false,
"last_tick_at": "2026-03-25 10:00:00.000"
}
```
`state` 枚举值:`stopped` / `running` / `distributor_running` / `fault_locked` / `comm_locked`
注意时间字段单位为毫秒ms
### GET `/api/unit/{unit_id}/detail`
获取控制单元及其下所有设备和点位的完整嵌套结构。
响应示例:
```json
{
"id": "uuid",
"code": "U01",
"name": "1号机组",
"enabled": true,
"equipments": [
{
"id": "uuid",
"code": "E01",
"name": "投煤器1",
"kind": "coal_feeder",
"points": [
{
"id": "uuid",
"name": "启动命令",
"signal_role": "start_cmd",
"equipment_id": "uuid"
}
]
}
]
}
@ -361,183 +467,359 @@
---
## Tag
## Event系统事件
### GET `/api/event`
分页获取系统控制事件记录。
查询参数:
- `unit_id`:可选,按控制单元过滤
- `event_type`:可选,按事件类型过滤
- `page`、`page_size`
响应示例:
```json
{
"data": [
{
"id": "uuid",
"event_type": "equipment.start_command_sent",
"level": "info",
"unit_id": "uuid",
"equipment_id": "uuid",
"message": "Start command sent to equipment ...",
"payload": {},
"created_at": "2026-03-25 10:00:00.000"
}
],
"total": 1,
"page": 1,
"page_size": 20
}
```
---
## Control控制命令
所有控制命令在执行前会校验信号质量、REM 状态、FLT 状态、单元通讯锁、单元故障锁。
### POST `/api/control/equipment/{equipment_id}/start`
向设备发送启动脉冲命令(写 HIGH → 延迟 300ms → 写 LOW
响应示例:
```json
{
"ok_msg": "Equipment start command sent",
"equipment_id": "uuid",
"unit_id": "uuid",
"command_role": "start_cmd",
"command_point_id": "uuid",
"pulse_ms": 300
}
```
失败(设备未处于可启动状态)返回 `403 Forbidden`
### POST `/api/control/equipment/{equipment_id}/stop`
向设备发送停止脉冲命令。响应结构同上。
### POST `/api/control/unit/{unit_id}/start-auto`
启动指定控制单元的自动控制循环。单元须已启用(`enabled = true`)。
```json
{ "ok_msg": "Auto control started", "unit_id": "uuid" }
```
### POST `/api/control/unit/{unit_id}/stop-auto`
停止自动控制循环。设备当前状态保持不变,不会自动停机。
```json
{ "ok_msg": "Auto control stopped", "unit_id": "uuid" }
```
### POST `/api/control/unit/batch-start-auto`
批量启动所有已启用(`enabled = true`)控制单元的自动控制。已在运行、故障锁或通讯锁的单元将跳过。
```json
{
"started": ["uuid1", "uuid2"],
"skipped": ["uuid3"]
}
```
### POST `/api/control/unit/batch-stop-auto`
批量停止所有自动控制中的控制单元。
```json
{ "stopped": ["uuid1", "uuid2"] }
```
### POST `/api/control/unit/{unit_id}/ack-fault`
人工确认故障,解除故障锁定。要求:`fault_locked = true` 且 `flt_active = false`(故障信号已消失)。
```json
{ "ok_msg": "Fault acknowledged", "unit_id": "uuid" }
```
---
## Tag标签
### GET `/api/tag`
分页获取标签列表。
查询参数:
查询参数:`page`、`page_size`
- `page`
- `page_size`
### GET `/api/tag/{tag_id}`
当前实现返回该标签下的点位列表。
### POST `/api/tag`
创建标签。
请求体:
响应示例:
```json
{
"name": "Area-A",
"description": "Area A points",
"data": [
{
"id": "uuid",
"name": "主蒸汽",
"description": null,
"created_at": "2026-03-20 10:00:00.000",
"updated_at": "2026-03-20 10:00:00.000"
}
],
"total": 1,
"page": 1,
"page_size": 20
}
```
### POST `/api/tag`
创建标签,可同时绑定点位。
```json
{
"name": "主蒸汽",
"description": null,
"point_ids": ["uuid1", "uuid2"]
}
```
响应:`201 Created`
```json
{ "id": "uuid", "ok_msg": "Tag created successfully" }
```
### GET `/api/tag/{tag_id}`
获取标签下所有绑定点位。
响应:点位对象数组。
### PUT `/api/tag/{tag_id}`
更新标签。
请求体:
更新标签,字段均可选(`point_ids` 传入时全量替换绑定关系):
```json
{
"name": "Area-A",
"description": "Updated",
"point_ids": ["uuid1", "uuid2"]
"name": "主蒸汽",
"description": "描述",
"point_ids": ["uuid1"]
}
```
### DELETE `/api/tag/{tag_id}`
删除标签。
成功响应:`204 No Content`
删除标签。成功响应:`204 No Content`
---
## Page
`page` 用于保存页面布局或组件映射数据。
## Page自定义页面
### GET `/api/page`
查询页面列表。
获取所有页面,按 `created_at` 排序
查询参数:
查询参数:`name`(可选,模糊搜索)
- `name`:可选,按名称模糊搜索
响应Page 对象数组。
### GET `/api/page/{page_id}`
```json
[
{
"id": "uuid",
"name": "总览",
"data": { "slot_key": "point-uuid" },
"created_at": "2026-03-20 10:00:00.000",
"updated_at": "2026-03-20 10:00:00.000"
}
]
```
获取单个页面。
`data``{ slot_key: point_id }` 映射,用于页面布局与点位绑定
### POST `/api/page`
创建页面。
请求体:
```json
{
"name": "Dashboard",
"data": {
"widgetA": "uuid1",
"widgetB": "uuid2"
}
"name": "总览",
"data": { "slot_a": "uuid1", "slot_b": "uuid2" }
}
```
响应:`201 Created`
```json
{ "id": "uuid", "ok_msg": "Page created successfully" }
```
### GET `/api/page/{page_id}`
获取单个页面。
### PUT `/api/page/{page_id}`
更新页面。
请求体字段均可选:
更新页面,字段均可选:
```json
{
"name": "Dashboard",
"data": {
"widgetA": "uuid1"
}
"name": "总览",
"data": { "slot_a": "uuid1" }
}
```
### DELETE `/api/page/{page_id}`
删除页面。
成功响应:`204 No Content`
删除页面。成功响应:`204 No Content`
---
## Log
## Log(运行日志)
### GET `/api/logs`
读取日志文件内容。
读取服务端日志文件内容(默认取最新 `app.log*` 文件尾部 200 行)
查询参数:
- `file`:可选,指定日志文件名,仅允许 `app.log*`
- `cursor`:可选,从指定游标后读取
- `tail_lines`:可选,默认 `200`
- `max_bytes`:可选
- `file`:可选,指定文件名(仅允许 `app.log` 前缀)
- `cursor`:可选,上次返回的字节偏移量;传入时增量读取 cursor 之后的内容
- `tail_lines`:可选,不传 cursor 时返回的尾部行数(默认 200最大 2000
- `max_bytes`:可选,单次最多返回字节数(默认 64 KB最大 512 KB
响应:
响应示例
```json
{
"file": "app.log",
"cursor": 1024,
"lines": ["..."],
"cursor": 204800,
"lines": ["2026-03-25 10:00:00 INFO ..."],
"truncated": false,
"reset": false
}
```
- `truncated``true` 表示本次未读完,可用新 cursor 继续请求
- `reset``true` 表示文件已轮转cursor > 文件大小),已从头读取
### GET `/api/logs/stream`
SSE 实时日志流。
**SSE**Server-Sent Events流式推送日志增量每 800 ms 检查一次文件变化
事件类型:
查询参数:`file`、`cursor`(可选,默认从文件末尾开始)、`max_bytes`(默认 32 KB
- `log`
- `error`
事件格式:
客户端可使用 `EventSource` 订阅。
```
event: log
data: { "file": "app.log", "cursor": 204800, "lines": [...], "truncated": false, "reset": false }
event: error
data: log stream read failed
```
---
## WebSocket
## 连接地址
### 连接地址
- 公共广播:`/ws/public`
- 客户端专属:`/ws/client/{client_id}`
## 服务端主动消息
### 服务端主动推送消息
### `PointNewValue`
#### `PointNewValue`
点位实时值更新:
```json
{
"type": "PointNewValue",
"data": {
"protocol": "opcua",
"source_id": "uuid",
"point_id": "uuid",
"client_handle": 1001,
"scan_mode": "subscribe",
"timestamp": "2026-03-20 10:05:00.000",
"quality": "good",
"value": 12.3,
"value_type": "float",
"value_text": "12.3",
"old_value": 12.1,
"old_timestamp": "2026-03-20 10:04:59.000",
"value_changed": true
"value_text": "12.3"
}
}
```
### `PointSetValueBatchResult`
#### `EventCreated`
系统事件创建(控制操作、故障、状态变更等):
```json
{
"type": "EventCreated",
"data": {
"id": "uuid",
"event_type": "equipment.start_command_sent",
"level": "info",
"unit_id": "uuid",
"equipment_id": "uuid",
"message": "...",
"created_at": "2026-03-25 10:00:00.000"
}
}
```
#### `UnitRuntimeChanged`
控制单元运行时状态变更(每 500ms tick 后广播):
```json
{
"type": "UnitRuntimeChanged",
"data": {
"unit_id": "uuid",
"state": "running",
"auto_enabled": true,
"fault_locked": false,
"comm_locked": false,
"manual_ack_required": false,
"accumulated_run_sec": 3600000
}
}
```
#### `PointSetValueBatchResult`
批量写点结果回调:
```json
{
@ -552,30 +834,25 @@ SSE 实时日志流。
}
```
## 客户端发送消息
### 客户端发送消息
### 写权限认证
#### 写权限认证
```json
{
"type": "auth_write",
"data": {
"key": "your-write-key"
}
"data": { "key": "your-write-key" }
}
```
### 批量写点
#### 批量写点
```json
{
"type": "point_set_value_batch",
"data": {
"items": [
{
"point_id": "uuid",
"value": 12.3
}
{ "point_id": "uuid", "value": 12.3 }
]
}
}
@ -585,6 +862,7 @@ SSE 实时日志流。
## 备注
- 历史曲线接口当前使用内存缓存,服务重启后历史会清空。
- 实时遥测与 WebSocket 推送是“最新值优先”的设计,在高压场景下允许丢弃部分中间消息。
- `/api/tag/{tag_id}` 当前返回的是标签下点位,而不是标签自身详情。
- 运行时状态(`/runtime`)存储在内存中,服务重启后重置。
- 历史曲线数据(`/history`)同样是内存环形缓冲,重启后清空。
- 控制单元时间配置字段(`run_time_sec` 等)单位为秒,运行时 elapsed 字段单位为毫秒。
- 自动控制启动后,状态机以 500ms 为周期运行,实时状态通过 WebSocket `UnitRuntimeChanged` 推送。

View File

@ -1,4 +1,6 @@
# 投煤器布料机远程监控与控制功能实现方案
# 投煤器布料机远程监控与控制功能实现方案
> 最后更新2026-03-24基于当前代码重新审阅
## 1. 目标
@ -11,551 +13,424 @@
- 支持故障锁定、人工复位、通讯异常冻结
- 支持通过配置适配不同现场,不改代码完成项目复用
## 2. 现有系统能力盘点
---
当前项目已经具备较好的通用工业采集平台基础:
## 2. 当前系统能力盘点
- OPC UA 数据源接入与自动重连
- 节点浏览、批量建点、点位实时订阅
- 点位批量写入能力
- 设备 `equipment` 模型
- 点位到设备绑定 `equipment_id`
- 点位信号角色字段 `signal_role`
- WebSocket 实时推送
### 2.1 通用平台基础(已有)
- OPC UA 数据源接入与自动重连(`connection.rs`
- 节点浏览、批量建点、点位实时订阅(`handler/point.rs`, `handler/source.rs`
- 点位批量写入能力(`connection.rs: write_point_values_batch`
- WebSocket 实时推送(`websocket.rs`
- 前端设备、点位、日志、趋势图基础界面
- 页面配置 `page` 能力
- 进程内事件总线 `event.rs`
- 进程内事件总线 `event.rs`control + telemetry 双通道)
- HTTP 中间件、鉴权、分页等工具链
现状更接近“通用点位监控平台”,还不是“投煤器/布料机业务控制系统”。
### 2.2 业务模型(已有)
## 3. 与需求的差距
- `unit` 表(`migrations/20260324090000_add_unit_and_event.sql`
- 包含 `run_time_sec`, `stop_time_sec`, `acc_time_sec`, `bl_time_sec`, `require_manual_ack_after_fault`
- `event` 表(同上迁移),含 `unit_id`, `equipment_id`, `source_id`, `level`, `payload`
- `equipment.unit_id` FK
- `equipment.kind``point.signal_role`
- `ControlUnit`, `EventRecord` 模型(`model.rs`
需求文档要求的软件能力,当前尚未落地的核心部分如下:
### 2.3 业务逻辑(已有)
- 缺少“控制单元”概念,无法表达一组投煤器对应一组布料机
- 缺少业务配置模型,无法配置 `RunTime`、`StopTime`、`AccTime`、`BLTime`
- 缺少设备业务类型约束,尚未明确区分投煤器、布料机
- 缺少设备归属单元字段,尚未形成 `unit -> equipment -> point` 的业务链路
- 缺少业务信号角色规范,尚未标准化 `REM/RUN/FLT/STA/STP`
- 缺少自动控制状态机
- 缺少故障锁定与人工确认恢复流程
- 缺少通讯异常冻结与恢复后重同步机制
- 缺少脉冲写入封装,当前只有通用批量写点
- 缺少单元总览、设备详情、控制面板、报警面板等业务界面
- 缺少统一事件持久化与后续报警模型
- Unit CRUD 接口:`GET/POST/PUT/DELETE /api/unit`, `GET /api/unit/{id}`
- 设备手动控制接口:`POST /api/control/equipment/{id}/start|stop`
- 内置脉冲写入(写 1 → 等 300ms → 写 0
- 前置校验:`rem == 1`, `flt == 0`, 通讯质量 good`control/validator.rs`
- `AppEvent::EquipmentStartCommandSent/EquipmentStopCommandSent`,自动持久化 `event` 表并推送 WebSocket
- 内存运行态结构体(`control/runtime.rs`
- `UnitRuntimeState`: Stopped / Running / DistributorRunning / FaultLocked / CommLocked
- `UnitRuntime`:含 `accumulated_run_sec`, `fault_locked`, `comm_locked`, `manual_ack_required` 等全部字段
- `ControlRuntimeStore`:全局 `HashMap<Uuid, UnitRuntime>`,已注册到 `AppState`
- `control/validator.rs`:独立的控制前置校验模块
- `control/engine.rs`:模块骨架已建立,`start()` 函数为空桩
- 前端Unit 列表(`units.js`),事件列表(`events.js`),设备 Unit 绑定(`equipment.js`
- 事件列表接口:`GET /api/event`(支持 `unit_id`, `event_type` 过滤)
---
## 3. 与需求的差距(当前真实状态)
| 需求项 | 状态 | 说明 |
|--------|------|------|
| Unit / 设备 / 点位数据模型 | ✅ 已完成 | 迁移、模型、CRUD 全部到位 |
| 脉冲写入封装 | ✅ 已完成 | 内置在 `handler/control.rs` |
| 手动控制前置校验 | ✅ 已完成 | `validator.rs` 实现 REM/FLT/quality 检查 |
| 手动启停接口 | ✅ 已完成 | start/stop 接口含脉冲写入和事件持久化 |
| 运行态内存模型 | ✅ 已完成 | `runtime.rs` 数据结构完整,已注入 AppState |
| 事件持久化与查询 | ✅ 已完成 | event 表 + 事件处理 + GET /api/event |
| **自动控制状态机** | ❌ 未实现 | `engine.rs` 是空桩,无任何调度逻辑 |
| **自动控制接口** | ❌ 未实现 | 缺 `start-auto`, `stop-auto` 接口 |
| **故障锁定联动** | ❌ 未实现 | `runtime.fault_locked` 有字段但无联动逻辑 |
| **通讯冻结联动** | ❌ 未实现 | `runtime.comm_locked` 有字段但无联动逻辑 |
| **人工确认解锁接口** | ❌ 未实现 | 缺 `ack-fault` 接口 |
| **手动控制检查运行态** | ❌ 未实现 | `validator.rs` 未读取 `ControlRuntimeStore` |
| **运行态 WebSocket 推送** | ❌ 未实现 | 单元状态变更未推送前端 |
| **运行态查询接口** | ❌ 未实现 | 缺 `GET /api/unit/{id}/runtime` |
| 前端设备控制面板 | ❌ 未实现 | 缺 REM/RUN/FLT 展示和手动启停按钮 |
| 前端单元详情/总览 | ❌ 未实现 | 缺状态机状态展示、自动/手动切换 |
| 前端参数在线编辑 | 🔶 部分 | Unit 编辑 Modal 有表单,但无运行态反馈 |
---
## 4. 推荐实现思路
推荐在现有平台上“增量扩展”,而不是重写:
继续在现有平台上"增量扩展"
- 保留现有 `source/node/point/equipment` 通用底座
- 新增面向业务控制的配置表和运行态管理
- 将控制逻辑放在 Rust 服务端,复用当前 OPC UA 连接和写点能力
- 前端增加业务页面,不破坏现有通用点位管理页面
- 基于现有 `event.rs` 扩展事件体系,而不是再造一套事件机制
- 现有 `unit` / `equipment` / `point` / `event` 底座不动
- 填充 `control/engine.rs` 实现状态机调度
- 新增自动控制和 ack-fault 接口
- `validator.rs` 增加对运行态的检查
- 扩展 `AppEvent` 业务事件,通过 WebSocket 推送运行态变更
- 前端增加业务控制页面
这样做的好处是:
---
- 现有 OPC UA、点位、设备、WebSocket 基础都能继续复用
- 后续不同现场只需要换设备映射和参数
- 手动调试仍然可以通过现有点位/设备页面完成
- 未来做报警、审计、统计时可以直接复用统一事件体系
## 5. 命名与模型设计(已落地,确认)
## 5. 命名与模型设计
### 5.1 命名原则
建议区分“代码命名”和“表命名”:
- 代码模型名保留语义完整性,如 `ControlUnit`
- 数据库表名尽量简洁,如 `unit`
不建议使用 `group` 作为表名,原因是语义过泛,后续容易与权限分组、界面分组、标签分组等概念冲突。
### 5.2 设备分类
继续复用 `equipment` 表中的 `kind` 字段,约定:
### 5.1 设备分类(`equipment.kind`
- `coal_feeder`:投煤器
- `distributor`:布料机
### 5.3 点位角色规范
继续复用 `point.signal_role`,建议统一枚举值:
### 5.2 点位角色规范(`point.signal_role`
- 状态点:`rem` `run` `flt` `ii` `q`
- 控制点:`start_cmd` `stop_cmd`
- 可选扩展:`estop` `mode_auto` `mode_manual` `reset_cmd`
这样每台设备都可以通过“设备 + 点位角色”完成映射,而不是在代码里写死点名。
### 5.3 Unit 运行态放内存(已确认)
### 5.4 新增 `unit`
运行态字段已在 `control/runtime.rs:UnitRuntime` 中定义完整,不落库。
服务重启后重新从 `REM/RUN/FLT/Q` 重建运行态,不自动补发命令。
建议新增 `unit` 表,对应代码模型 `ControlUnit`,表示一个业务控制单元。
### 5.4 统一 `event` 表,预留 `alarm` 表(已落地)
建议字段:
- `event`:记录"发生了什么",已上线
- `alarm`:记录"需要被告警管理的异常",第二阶段实现
- `id`
- `code`
- `name`
- `description`
- `enabled`
- `run_time_sec`
- `stop_time_sec`
- `acc_time_sec`
- `bl_time_sec`
- `require_manual_ack_after_fault`
- `created_at`
- `updated_at`
### 5.5 设备直接归属 Unit
当前业务前提下,一台设备只属于一个控制单元,不会跨单元复用,因此不建议单独建立关系表。
更合适的方式是直接在 `equipment` 表新增:
- `unit_id`
这样模型会更简单:
- 一个 `unit` 对多台 `equipment`
- 一台 `equipment` 只属于一个 `unit`
- `equipment.kind` 用于区分 `coal_feeder``distributor`
如果后续现场出现“一台设备可挂多个单元”或“单元内设备编排顺序复杂”的需求,再演进成关系表会更合适。第一阶段不建议设计过重。
### 5.6 Unit 运行态放内存
`unit` 运行态不建议优先落数据库,建议由控制引擎保存在内存中。
建议维护的内存运行态字段:
- `state`
- `accumulated_run_sec`
- `current_run_elapsed_sec`
- `current_stop_elapsed_sec`
- `distributor_run_elapsed_sec`
- `fault_locked`
- `comm_locked`
- `manual_ack_required`
- `last_tick_at`
状态值建议:
- `stopped`
- `running`
- `distributor_running`
- `fault_locked`
- `comm_locked`
原因:
- 这些字段变化频率高,不适合高频写库
- 服务重启后直接恢复旧控制态并不安全
- 更合理的方式是重启后重新读取 `REM/RUN/FLT/Q` 并重建运行态
- 通讯恢复或服务重启后不自动补发控制命令,更符合工业控制安全原则
如后续确实需要“断电恢复上下文”或运行分析,再补充轻量级快照能力即可,但不是第一阶段必须项。
### 5.7 统一 `event` 表,预留 `alarm`
建议不要命名为 `control_event`,而是使用统一的 `event` 表。
原因:
- 当前不仅有控制事件,后续还会有配置事件、通讯事件、数据源事件
- `event` 更适合作为统一审计与业务时间线
- 现有 `event.rs` 已经是进程内事件总线,命名保持一致更自然
建议 `event` 表记录:
- 手动启动/停止
- 自动启动/停止
- 故障锁定
- 人工解除故障锁定
- 通讯异常/恢复
- 数据源创建、更新、删除
- 控制参数变更
- 关键状态切换
关键字段建议:
- `id`
- `event_type`
- `level`
- `unit_id`
- `equipment_id`
- `source_id`
- `message`
- `payload`
- `created_at`
同时建议未来单独设计 `alarm` 表,而不是把报警状态硬塞进 `event` 表。
原因:
- 报警通常有独立生命周期:触发、确认、恢复、清除
- 报警需要独立字段,如 `severity`、`active`、`acked`、`acked_by`、`acked_at`、`cleared_at`
- 把报警硬塞到 `event` 中会让通用事件表越来越臃肿
因此推荐边界是:
- `event`:记录“发生了什么”
- `alarm`:记录“需要被告警管理的异常”
第一阶段可以先只落 `event` 表,`alarm` 表先在方案中预留,不急着实现。
---
## 6. 控制逻辑设计
### 6.1 手动控制
### 6.1 手动控制(已实现,待补充运行态检查)
手动控制前置校验:
当前已实现:
- `rem == 1` 检查
- `flt == 0` 检查
- 通讯质量检查
- 脉冲写入300ms
- `REM == 1`
- `FLT == 0`
- 通讯正常
- 未处于故障锁定
- 如有急停点,`ESTOP == 0`
**待补充**:在 `validator.rs` 中增加对 `ControlRuntimeStore` 的检查:
- `fault_locked == true` → 拒绝
- `comm_locked == true` → 拒绝
- `manual_ack_required == true` → 拒绝(等待人工确认)
控制命令统一走脉冲写入:
### 6.2 自动控制状态机(待实现)
1. 写入 `1`
2. 延时 `200-500ms`
3. 写回 `0`
服务端需要封装 `pulse_write(point_id, high_ms)`,前端不能直接拼两次写点。
### 6.2 自动控制状态机
每个 `unit` 独立维护状态机。
每个 `unit` 独立维护状态机,在 `control/engine.rs` 中以后台任务驱动。
#### `STOPPED`
- 累计停止时间
- 若 `stop_elapsed >= StopTime`,尝试启动投煤器
- 成功后切换到 `RUNNING`
- 累计停止时间 `current_stop_elapsed_sec`
- 若 `stop_elapsed >= StopTime` → 校验投煤器 REM/FLT/质量 → 发启动命令 → 切换到 `RUNNING`
#### `RUNNING`
- 累计运行时间
- 累计运行时间 `current_run_elapsed_sec`
- `accumulated_run_sec += delta`
- 若 `run_elapsed >= RunTime`,尝试停止投煤器,切换回 `STOPPED`
- 若 `accumulated_run_sec >= AccTime`,进入布料机触发流程
- 若 `run_elapsed >= RunTime` → 停止投煤器 → 切换回 `STOPPED`
- 若 `accumulated_run_sec >= AccTime` → 进入 `DISTRIBUTOR_RUNNING`
#### `DISTRIBUTOR_RUNNING`
- 校验布料机 `REM/FLT/通讯`
- 启动布料机
- 等待 `RUN == 1` 反馈
- 校验布料机 REM/FLT/质量
- 启动布料机,等待 `RUN == 1`
- 计时 `BLTime`
- 停止布料机
- 累计时间清零
- 回 `STOPPED`回到自动节拍起点
- 清零 `accumulated_run_sec`
- 切回 `STOPPED` 或自动节拍起点
### 6.3 故障机制
### 6.3 故障机制(待实现)
任意设备检测到 `FLT == 1`
- 停止该单元自动控制
- 标记 `fault_locked = true`
- 禁止再次自动发命令
- 发送并持久化关键事件
`FLT``1 -> 0` 恢复:
- `state = FaultLocked`, `fault_locked = true`
- 发送并持久化 `FaultLocked` 事件
`FLT``1 → 0` 恢复:
- 不自动解锁
- 标记 `manual_ack_required = true`
- 等待人工在界面点击“解除故障锁定”
- `manual_ack_required = true`
- 等待人工调用 `POST /api/control/unit/{id}/ack-fault`
### 6.4 通讯异常机制
### 6.4 通讯异常机制(待实现)
当质量位异常或 OPC 连接中断:
- 标记 `comm_locked = true`
当 OPC UA 质量位异常或连接中断:
- `state = CommLocked`, `comm_locked = true`
- 冻结全部控制动作
- 前端按钮灰化
- 不允许任何自动/手动写入
通讯恢复后:
- 重新读取 `REM/RUN/FLT`
- 重同步运行态
- 不自动补发控制命令
- 发送并持久化恢复事件
- 持久化恢复事件
- 等待人工操作或下一次自动触发
---
## 7. 事件体系设计
### 7.1 继续复用 `src/event.rs`
建议不要另起一套业务事件中心,而是在现有 [src/event.rs](D:/projects/plc_control/src/event.rs) 上扩展。
当前 `AppEvent` 已有:
- `SourceCreate/Update/Delete`
- `PointCreateBatch/PointDeleteBatch`
- `EquipmentStartCommandSent/EquipmentStopCommandSent`
- `PointNewValue`(遥测)
当前它已经承担两类职责:
**待扩展**
- 控制类内部事件分发
- 遥测类高频事件分发
推荐继续保留这个结构:
- `AppEvent` 作为统一进程内事件枚举
- 高频遥测事件继续走内存和 WebSocket
- 低频且有审计价值的事件选择性落库到 `event`
```rust
AutoControlStarted { unit_id }
AutoControlStopped { unit_id }
FaultLocked { unit_id, equipment_id }
FaultAcked { unit_id }
CommLocked { unit_id }
CommRecovered { unit_id }
UnitStateChanged { unit_id, from_state, to_state }
```
### 7.2 哪些事件适合落库
适合落库的:
- 适合:所有手动/自动启停、故障、通讯、参数变更、状态切换
- 不适合:`PointNewValue`、高频遥测、内部轮询过程
- `SourceCreate`
- `SourceUpdate`
- `SourceDelete`
- 自动控制启动/停止
- 手动启动/停止命令发送
- 故障锁定
- 人工确认恢复
- 通讯异常/恢复
- 参数配置变更
- 单元状态切换
不适合直接落库的:
- `PointNewValue`
- 高频实时遥测
- 细碎的内部轮询过程
### 7.3 推荐扩展方向
建议在 `AppEvent` 中逐步增加业务事件,例如:
- `AutoControlStarted`
- `AutoControlStopped`
- `EquipmentStartCommandSent`
- `EquipmentStopCommandSent`
- `FaultLocked`
- `FaultAcked`
- `CommLocked`
- `CommRecovered`
- `UnitStateChanged`
这样后续无论是写日志、落库、推送 WebSocket、做报警触发都可以基于同一个事件入口。
---
## 8. 后端改造方案
### 8.1 新增模块
### 8.1 已有模块(确认现状)
建议新增:
| 模块 | 文件 | 状态 |
|------|------|------|
| HTTP 控制接口 | `src/handler/control.rs` | ✅ 有 Unit CRUD + start/stop + event list |
| 控制前置校验 | `src/control/validator.rs` | ✅ REM/FLT/质量检查,**待加运行态检查** |
| 内存运行态 | `src/control/runtime.rs` | ✅ 数据结构完整 |
| 自动控制引擎 | `src/control/engine.rs` | ❌ 空桩,待实现 |
| 服务层 Unit | `src/service/` | ✅ CRUD 完整 |
- `src/handler/control.rs`
- `src/service/control.rs`
- `src/control/engine.rs`
- `src/control/runtime.rs`
- `src/control/validator.rs`
### 8.2 待新增接口
职责划分:
- `handler`HTTP 接口
- `service`:数据库读写
- `control/engine`:状态机与调度
- `control/runtime`:内存运行态缓存与同步
- `control/validator`:控制前置校验
### 8.2 新增接口
建议新增接口:
- `GET /api/control/unit`
- `POST /api/control/unit`
- `PUT /api/control/unit/{id}`
- `GET /api/control/unit/{id}`
- `POST /api/control/unit/{id}/start-auto`
- `POST /api/control/unit/{id}/stop-auto`
- `POST /api/control/unit/{id}/ack-fault`
- `POST /api/control/equipment/{id}/start`
- `POST /api/control/equipment/{id}/stop`
- `GET /api/events`
```
POST /api/control/unit/{id}/start-auto 启动自动控制
POST /api/control/unit/{id}/stop-auto 停止自动控制
POST /api/control/unit/{id}/ack-fault 人工确认故障解锁
GET /api/unit/{id}/runtime 查询运行态state, elapsed, fault_locked 等)
```
说明:
- `start/stop-auto` 修改运行态 `auto_enabled`,引擎轮询时读取
- `ack-fault` 仅在 `manual_ack_required == true` 时允许操作,否则返回 400
- 设备手动控制必须走业务接口,不建议继续直接暴露给页面做原始点位写入
- 原 `/api/point/value/batch` 保留给调试或底层工具能力
- 事件查询接口可以直接面向统一 `event`
### 8.3 控制引擎运行方式(待实现)
### 8.3 控制引擎运行方式
`control/engine.rs: start()` 中实现后台任务:
建议服务启动后增加一个后台任务:
```
每 500ms 扫描所有 enabled unit
从 ControlRuntimeStore 读取运行态
从 connection_manager.get_point_monitor_data_read_guard() 取实时点值
检查质量位 → 更新 comm_locked
检查 FLT → 更新 fault_locked
驱动状态机 tick
有状态变化 → 更新 ControlRuntimeStore → 发 AppEvent → 推 WebSocket
```
- 每 `500ms``1s` 扫描所有启用的 `unit`
- 从内存读取运行态缓存
- 从当前点位监控数据中取 `REM/RUN/FLT/Q`
- 驱动状态机执行
### 8.4 手动控制补充运行态检查
控制引擎不要直接查 OPC应复用当前 `connection_manager` 已维护的实时点值。
`control/validator.rs: validate_manual_control()` 中增加:
### 8.4 关键复用点
```rust
let runtime = state.control_runtime.get(unit_id).await;
if let Some(runtime) = runtime {
if runtime.fault_locked {
return Err(ApiErr::Forbidden("Unit is fault locked", ...));
}
if runtime.comm_locked {
return Err(ApiErr::Forbidden("Unit is comm locked", ...));
}
if runtime.manual_ack_required {
return Err(ApiErr::Forbidden("Fault ack required before control", ...));
}
}
```
### 8.5 关键复用点
可直接复用当前已有能力:
- `connection_manager.get_point_monitor_data_read_guard()` → 读取实时点值
- `connection_manager.write_point_values_batch()` → 写点(自动控制也走此接口)
- `event_manager.send()` → 统一事件入口
- `ws_manager.send_to_public()` → 推送运行态变更
- `unit_id + equipment.kind + point.signal_role` 三元组 → 业务映射
- `connection_manager` 的点位实时缓存
- `get_point_monitor_data_read_guard`
- 批量写点能力
- WebSocket 实时推送
- `event.rs` 的统一事件入口
- `unit_id + equipment.kind + point.signal_role` 的业务映射关系
---
## 9. 前端改造方案
建议在现有通用页面之外新增业务页面,避免混杂。
### 9.1 已有页面(确认现状)
### 9.1 新增页面
| 页面/功能 | 状态 |
|-----------|------|
| Unit 列表(含 CRUD Modal | ✅ 基础实现 |
| 事件列表 | ✅ 基础实现 |
| 设备列表(含 Unit 归属绑定) | ✅ 实现 |
| 点位绑定equipment/signal_role | ✅ 实现 |
| 趋势图 | ✅ 实现 |
- 单元总览页
- 单元详情页
- 设备控制面板
- 事件记录页
- 报警页
- 参数配置页
### 9.2 单元总览页内容
每个 `unit` 展示:
- 单元名称
- 自动/手动状态
- 当前状态机状态
- 投煤器运行状态
- 布料机运行状态
- 当前累计运行时间
- 故障锁定状态
- 通讯状态
并提供按钮:
- 启动自动
- 停止自动
- 故障确认/解除锁定
### 9.3 设备控制页内容
针对单台投煤器/布料机提供:
### 9.2 待新增页面/功能
**设备控制面板**(单台投煤器/布料机):
- REM / RUN / FLT / Q / II 实时显示
- 启动按钮
- 停止按钮
- 启动 / 停止按钮灰化逻辑comm_locked / fault_locked / manual_ack_required
- 通讯异常、故障锁定提示
- 最近事件
- 最近控制事件
### 9.4 趋势、事件与报警
**单元总览**Unit 卡片增强):
- 当前状态机状态STOPPED / RUNNING / DISTRIBUTOR_RUNNING / FAULT_LOCKED / COMM_LOCKED
- 自动/手动切换按钮
- 故障确认按钮(`manual_ack_required == true` 时显示)
- 累计运行时间进度
- 投煤器 + 布料机运行状态摘要
复用已有趋势图能力:
**参数在线编辑**
- 现有 Unit Modal 已有表单
- 需补充:保存后通知引擎重新加载(或引擎每次 tick 从 DB 读取配置)
- 电流 `II` 趋势
- 运行状态变化曲线
- 事件时间线
**WebSocket 运行态更新**
- 新增 `WsMessage::UnitRuntimeChanged { unit_id, runtime }` 消息类型
- 前端收到后实时更新 Unit 卡片状态,无需轮询
后续报警页面基于独立 `alarm` 表实现:
- 当前活动报警
- 已确认报警
- 已恢复报警
- 报警确认操作
---
## 10. 分阶段实施建议
### 第一阶段:最小可用版
### 第一阶段:补全控制闭环(当前阶段)
目标:先让系统具备业务闭环,但不追求复杂页面
**目标**:让自动控制可以跑起来,故障/通讯保护机制生效
内容
待完成工作
- 新增 `unit`
- 为 `equipment` 增加 `unit_id`
- 约定设备 `kind` 和点位 `signal_role`
- 新增手动控制接口
- 实现脉冲写入
- 实现故障锁定与通讯冻结
- 实现自动控制状态机
- 基于 `event.rs` 落统一 `event`
- 前端增加一个“控制单元”面板和事件列表
1. **补充运行态检查到 `validator.rs`**
- 手动控制时检查 `fault_locked`, `comm_locked`, `manual_ack_required`
交付后即可验证:
2. **实现 `control/engine.rs`**
- 后台 500ms 轮询任务
- 质量检查 → `comm_locked` 更新
- FLT 检测 → `fault_locked` 更新
- 状态机 tickSTOPPED / RUNNING / DISTRIBUTOR_RUNNING
- 单台投煤器启停
- 单元级自动启停
3. **新增接口**
- `POST /api/control/unit/{id}/start-auto`
- `POST /api/control/unit/{id}/stop-auto`
- `POST /api/control/unit/{id}/ack-fault`
- `GET /api/unit/{id}/runtime`
4. **扩展 `AppEvent`**
- `FaultLocked`, `FaultAcked`, `CommLocked`, `CommRecovered`, `UnitStateChanged`, `AutoControlStarted`, `AutoControlStopped`
5. **WebSocket 运行态推送**
- `WsMessage::UnitRuntimeChanged`
6. **前端设备控制面板**
- REM/RUN/FLT 展示 + 启停按钮 + 灰化逻辑
7. **前端 Unit 卡片增强**
- 状态机状态展示、自动/手动切换、故障确认
交付后可验证:
- 单台设备手动启停(含故障/通讯拦截)
- 单元自动定时启停
- 累计触发布料机运行
- 故障恢复后人工确认
- 关键操作和状态切换可追溯
- 故障恢复后人工确认才能操作
- 通讯异常冻结后恢复自动同步
### 第二阶段:增强版
内容:
- 单元总览页
- 单元详情页
- 参数在线编辑
- 更丰富的趋势图
- WebSocket 业务事件推送
- 单元详情页(运行趋势、事件时间线、参数在线编辑)
- 更丰富的趋势图(电流 II、运行状态变化曲线
- 报警规则与 `alarm`
- 报警确认与恢复流程
### 第三阶段:现场适配版
内容:
- 导入导出配置
- 项目模板
- 配置校验工具
- 启停联锁自检
- 操作权限控制
## 11. 建议优先落地顺序
---
从当前代码基础出发,建议按下面顺序开发:
## 11. 当前优先落地顺序
1. 补齐业务数据模型和数据库迁移
2. 新增 `unit` 表并为 `equipment` 增加 `unit_id`
3. 规范 `equipment.kind``point.signal_role`
4. 实现服务端脉冲写入能力
5. 实现手动控制接口
6. 实现 `unit` 自动控制状态机
7. 扩展 `event.rs` 并实现统一 `event` 表持久化
8. 实现故障锁定、通讯冻结、人工确认
9. 增加前端业务页面
10. 第二阶段再引入独立 `alarm`
从当前代码基础出发,第一阶段建议按下面顺序开发:
1. `validator.rs` 补充运行态检查
2. `engine.rs` 实现质量检查与 `comm_locked` 更新
3. `engine.rs` 实现 FLT 检测与 `fault_locked` 更新
4. `engine.rs` 实现状态机主循环
5. `handler/control.rs` 新增 `start-auto`, `stop-auto`, `ack-fault`
6. `handler/control.rs` 新增 `GET /api/unit/{id}/runtime`
7. 扩展 `AppEvent` 业务事件类型并落库
8. `websocket.rs` 新增 `UnitRuntimeChanged` 消息
9. 前端设备控制面板
10. 前端 Unit 卡片增强
11. 第二阶段再引入独立 `alarm`
---
## 12. 对当前代码的具体落点
基于现有代码,建议主要改动点如下:
| 文件 | 改动内容 |
|------|---------|
| `src/control/engine.rs` | 填充后台轮询任务,实现状态机 tick |
| `src/control/validator.rs` | 增加运行态检查fault/comm/ack |
| `src/handler/control.rs` | 新增 start-auto, stop-auto, ack-fault, runtime 接口 |
| `src/event.rs` | 扩展 `AppEvent` 业务事件枚举 |
| `src/websocket.rs` | 新增 `WsMessage::UnitRuntimeChanged` |
| `web/js/units.js` | Unit 卡片增加状态机状态、auto 切换、fault ack 按钮 |
| `web/js/equipment.js` | 增加设备控制面板REM/RUN/FLT + 启停) |
| `web/js/app.js` | 绑定新按钮事件,处理 WebSocket 运行态消息 |
- [src/model.rs](D:/projects/plc_control/src/model.rs)
- 增加 `ControlUnit` 模型
- 为 `Equipment` 增加 `unit_id`
- 后续增加 `EventRecord`、`AlarmRecord` 模型
- [src/event.rs](D:/projects/plc_control/src/event.rs)
- 扩展 `AppEvent` 业务事件类型
- 增加低频关键事件的持久化逻辑
- [src/main.rs](D:/projects/plc_control/src/main.rs)
- 注册控制相关路由
- 启动控制引擎后台任务
- [src/handler/point.rs](D:/projects/plc_control/src/handler/point.rs)
- 保留底层写点接口,不直接承载业务控制
- [src/handler/equipment.rs](D:/projects/plc_control/src/handler/equipment.rs)
- 继续作为设备基础资料管理
- [web/index.html](D:/projects/plc_control/web/index.html)
- 增加业务控制页面入口或独立面板
- [web/js/app.js](D:/projects/plc_control/web/js/app.js)
- 增加控制单元页面事件绑定
不需要改动的:
- `src/model.rs`(数据模型完整)
- `src/control/runtime.rs`(运行态结构体完整)
- 所有迁移文件schema 已完整)
- `src/handler/point.rs`(保留底层写点,不承载业务控制)
---
## 13. 本次结论
当前项目不需要推倒重来,可以直接演进成投煤器与布料机远程监控控制系统
当前项目已经完成了业务底座的建设(数据模型、脉冲写入、手动控制、事件持久化、运行态数据结构),具备了较好的基础。
最合理的路径是
下一步核心工作集中在:
- 以现有 OPC UA 与点位平台为底座
- 数据库使用简洁表名 `unit`、`event`
- 代码层保留语义化命名,如 `ControlUnit`、`AppEvent`
- 在 `equipment` 上直接增加 `unit_id`
- 在服务端以内存运行态实现状态机和脉冲控制
- 在现有 [src/event.rs](D:/projects/plc_control/src/event.rs) 上扩展统一事件体系
- 第一阶段先做统一 `event`,第二阶段再拆分独立 `alarm`
1. **填充 `control/engine.rs`**——这是最核心的缺口,所有自动控制、故障保护、通讯冻结都需要它来驱动
2. **前端业务控制面板**——让操作员看到并操作设备状态
如果进入下一步开发,建议先做“第一阶段最小可用版”
其余部分接口、事件、WebSocket属于连接层工作量相对可控。

View File

@ -6,6 +6,9 @@ pub struct AppConfig {
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,
}
@ -22,11 +25,16 @@ impl AppConfig {
.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,
})
}

View File

@ -328,7 +328,7 @@ impl ConnectionManager {
let manager = self.clone();
let handle = tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(4)); // 每4秒检测一次心跳
let mut ticker = tokio::time::interval(Duration::from_secs(8)); // 每8秒检测一次心跳
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
@ -1153,8 +1153,8 @@ impl ConnectionManager {
match session
.create_subscription(
Duration::from_secs(1),
10,
30,
15,
5,
0,
0,
true,

188
src/control/command.rs Normal file
View File

@ -0,0 +1,188 @@
use crate::{
connection::{BatchSetPointValueReq, ConnectionManager, SetPointValueReqItem},
telemetry::ValueType,
AppState,
};
use serde_json::json;
use std::sync::Arc;
use uuid::Uuid;
/// Write a pulse (high → delay → low) to a command point.
/// Returns Ok(()) on success, Err(msg) on any failure.
pub async fn send_pulse_command(
connection_manager: &Arc<ConnectionManager>,
point_id: Uuid,
value_type: Option<&ValueType>,
pulse_ms: u64,
) -> Result<(), String> {
let high = pulse_value(true, value_type);
let low = pulse_value(false, value_type);
let high_result = connection_manager
.write_point_values_batch(BatchSetPointValueReq {
items: vec![SetPointValueReqItem { point_id, value: high }],
})
.await?;
if !high_result.success {
return Err(format!("Pulse high write failed: {:?}", high_result.err_msg));
}
tokio::time::sleep(std::time::Duration::from_millis(pulse_ms)).await;
let low_result = connection_manager
.write_point_values_batch(BatchSetPointValueReq {
items: vec![SetPointValueReqItem { point_id, value: low }],
})
.await?;
if !low_result.success {
return Err(format!("Pulse low write failed: {:?}", low_result.err_msg));
}
Ok(())
}
/// Simulate RUN signal feedback after a command when SIMULATE_PLC=true.
///
/// Strategy:
/// 1. Try writing the desired value to the RUN point via the normal OPC UA write path.
/// If the proxy accepts the write, `write_point_values_batch` will emit a local
/// `PointNewValue` event that updates the cache and WebSocket automatically.
/// 2. If the write is rejected (proxy has no write target or returns an error),
/// fall back to directly patching the local monitor cache and broadcasting over WS.
pub async fn simulate_run_feedback(state: &AppState, equipment_id: Uuid, run_on: bool) {
let role_points =
match crate::service::get_equipment_role_points(&state.pool, equipment_id).await {
Ok(v) => v,
Err(e) => {
tracing::warn!("simulate_run_feedback: db error: {}", e);
return;
}
};
let run_point = match role_points.iter().find(|p| p.signal_role == "run") {
Some(p) => p.clone(),
None => return,
};
// Determine the write value based on the current known value_type for the point.
let write_json = {
let guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
match guard
.get(&run_point.point_id)
.and_then(|m| m.value_type.as_ref())
{
Some(crate::telemetry::ValueType::Int) | Some(crate::telemetry::ValueType::UInt) => {
serde_json::json!(if run_on { 1 } else { 0 })
}
_ => serde_json::json!(run_on),
}
};
// Try writing to the proxy server first.
let write_ok = match state
.connection_manager
.write_point_values_batch(crate::connection::BatchSetPointValueReq {
items: vec![crate::connection::SetPointValueReqItem {
point_id: run_point.point_id,
value: write_json,
}],
})
.await
{
Ok(res) => res.success,
Err(e) => {
tracing::debug!("simulate_run_feedback: write attempt failed: {}", e);
false
}
};
if write_ok {
// write_point_values_batch already emitted PointNewValue; nothing more to do.
tracing::info!(
"simulate_run_feedback: wrote run={} for equipment={} via OPC UA",
run_on,
equipment_id
);
return;
}
// Fallback: patch the local cache and push over WebSocket.
tracing::debug!(
"simulate_run_feedback: OPC UA write rejected, falling back to cache patch for equipment={}",
equipment_id
);
let (value, value_type, value_text) = {
let guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
match guard
.get(&run_point.point_id)
.and_then(|m| m.value_type.as_ref())
{
Some(crate::telemetry::ValueType::Int) => (
crate::telemetry::DataValue::Int(if run_on { 1 } else { 0 }),
Some(crate::telemetry::ValueType::Int),
Some(if run_on { "1" } else { "0" }.to_string()),
),
Some(crate::telemetry::ValueType::UInt) => (
crate::telemetry::DataValue::UInt(if run_on { 1 } else { 0 }),
Some(crate::telemetry::ValueType::UInt),
Some(if run_on { "1" } else { "0" }.to_string()),
),
_ => (
crate::telemetry::DataValue::Bool(run_on),
Some(crate::telemetry::ValueType::Bool),
Some(run_on.to_string()),
),
}
};
let monitor = crate::telemetry::PointMonitorInfo {
protocol: "simulation".to_string(),
source_id: uuid::Uuid::nil(),
point_id: run_point.point_id,
client_handle: 0,
scan_mode: crate::model::ScanMode::Poll,
timestamp: Some(chrono::Utc::now()),
quality: crate::telemetry::PointQuality::Good,
value: Some(value),
value_type,
value_text,
old_value: None,
old_timestamp: None,
value_changed: true,
};
if let Err(e) = state
.connection_manager
.update_point_monitor_data(monitor.clone())
.await
{
tracing::warn!("simulate_run_feedback: cache update failed: {}", e);
return;
}
let _ = state
.ws_manager
.send_to_public(crate::websocket::WsMessage::PointNewValue(monitor))
.await;
tracing::info!(
"simulate_run_feedback: cache-patched run={} for equipment={}",
run_on,
equipment_id
);
}
fn pulse_value(high: bool, value_type: Option<&ValueType>) -> serde_json::Value {
match value_type {
Some(ValueType::Bool) => serde_json::Value::Bool(high),
_ => if high { json!(1) } else { json!(0) },
}
}

431
src/control/engine.rs Normal file
View File

@ -0,0 +1,431 @@
use std::collections::HashMap;
use std::sync::Arc;
use chrono::Utc;
use uuid::Uuid;
use crate::{
control::{
command::send_pulse_command,
runtime::{ControlRuntimeStore, UnitRuntime, UnitRuntimeState},
},
event::AppEvent,
service::EquipmentRolePoint,
telemetry::{DataValue, PointMonitorInfo, PointQuality},
websocket::WsMessage,
AppState,
};
pub fn start(state: AppState, runtime_store: Arc<ControlRuntimeStore>) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(500));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
ticker.tick().await;
tick_all_units(&state, &runtime_store).await;
}
});
}
async fn tick_all_units(state: &AppState, store: &ControlRuntimeStore) {
let units = match crate::service::get_all_enabled_units(&state.pool).await {
Ok(u) => u,
Err(e) => {
tracing::error!("Engine: failed to load units: {}", e);
return;
}
};
for unit in units {
tick_unit(state, store, &unit).await;
}
}
async fn tick_unit(
state: &AppState,
store: &ControlRuntimeStore,
unit: &crate::model::ControlUnit,
) {
let mut runtime = store.get_or_init(unit.id).await;
// ── Load equipment role-point maps by kind ───────────────
let equipment_list = match crate::service::get_equipment_by_unit_id(&state.pool, unit.id).await {
Ok(e) => e,
Err(e) => {
tracing::error!(
"Engine: equipment load failed for unit {}: {}",
unit.id,
e
);
return;
}
};
// kind -> role -> EquipmentRolePoint (first equipment per kind wins)
let mut kind_roles: HashMap<String, HashMap<String, EquipmentRolePoint>> = HashMap::new();
// kind -> equipment id (first equipment per kind)
let mut kind_eq_ids: HashMap<String, Uuid> = HashMap::new();
// all role maps for fault/comm scanning across all equipment
let mut all_roles: Vec<(Uuid, HashMap<String, EquipmentRolePoint>)> = Vec::new();
for equip in &equipment_list {
match crate::service::get_equipment_role_points(&state.pool, equip.id).await {
Ok(role_points) => {
let role_map: HashMap<String, EquipmentRolePoint> = role_points
.into_iter()
.map(|rp| (rp.signal_role.clone(), rp))
.collect();
if let Some(kind) = &equip.kind {
if kind_roles.contains_key(kind.as_str()) {
tracing::warn!(
"Engine: unit {} has multiple {} equipment; using first",
unit.id,
kind
);
} else {
kind_roles.insert(kind.clone(), role_map.clone());
kind_eq_ids.insert(kind.clone(), equip.id);
}
}
all_roles.push((equip.id, role_map));
}
Err(e) => {
tracing::warn!(
"Engine: role points load failed for equipment {}: {}",
equip.id,
e
);
}
}
}
let monitor_guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
// ── Communication check ──────────────────────────────────
let any_bad_quality = all_roles.iter().flat_map(|(_, r)| r.values()).any(|rp| {
monitor_guard
.get(&rp.point_id)
.map(|m| m.quality != PointQuality::Good)
.unwrap_or(false)
});
let prev_comm = runtime.comm_locked;
runtime.comm_locked = any_bad_quality;
if !prev_comm && runtime.comm_locked {
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 });
}
// ── Fault check ──────────────────────────────────────────
let any_flt = all_roles.iter().any(|(_, roles)| {
roles
.get("flt")
.and_then(|rp| monitor_guard.get(&rp.point_id))
.map(|m| monitor_value_as_bool(m))
.unwrap_or(false)
});
let prev_flt = runtime.flt_active;
runtime.flt_active = any_flt;
if any_flt && !runtime.fault_locked {
// Find which equipment triggered the fault
let flt_eq_id = all_roles
.iter()
.find(|(_, roles)| {
roles
.get("flt")
.and_then(|rp| monitor_guard.get(&rp.point_id))
.map(|m| monitor_value_as_bool(m))
.unwrap_or(false)
})
.map(|(eq_id, _)| *eq_id)
.unwrap_or(Uuid::nil());
runtime.fault_locked = true;
let _ = state.event_manager.send(AppEvent::FaultLocked {
unit_id: unit.id,
equipment_id: flt_eq_id,
});
if runtime.auto_enabled {
runtime.auto_enabled = false;
let _ = state
.event_manager
.send(AppEvent::AutoControlStopped { unit_id: unit.id });
}
}
// FLT just cleared → require manual ack if unit is configured that way
if prev_flt && !any_flt && runtime.fault_locked {
if unit.require_manual_ack_after_fault {
runtime.manual_ack_required = true;
} else {
// Auto-clear fault lock
runtime.fault_locked = false;
}
}
drop(monitor_guard);
// ── State machine tick ───────────────────────────────────
if runtime.auto_enabled && !runtime.fault_locked && !runtime.comm_locked {
let now = Utc::now();
// Accumulate in milliseconds to avoid sub-second truncation
let delta_ms = runtime
.last_tick_at
.map(|t| (now - t).num_milliseconds().max(0))
.unwrap_or(0);
let prev_state = runtime.state.clone();
tick_state_machine(state, &mut runtime, unit, &kind_roles, &kind_eq_ids, delta_ms).await;
if runtime.state != prev_state {
let _ = state.event_manager.send(AppEvent::UnitStateChanged {
unit_id: unit.id,
from_state: format!("{:?}", prev_state),
to_state: format!("{:?}", runtime.state),
});
}
}
runtime.last_tick_at = Some(Utc::now());
store.upsert(runtime.clone()).await;
if let Err(e) = state
.ws_manager
.send_to_public(WsMessage::UnitRuntimeChanged(runtime))
.await
{
tracing::debug!("Engine: WS push skipped (no subscribers): {}", e);
}
}
/// Drive one state-machine tick for a unit.
/// All elapsed counters accumulate in **milliseconds**; comparisons use `*_time_sec * 1000`.
async fn tick_state_machine(
state: &AppState,
runtime: &mut UnitRuntime,
unit: &crate::model::ControlUnit,
kind_roles: &HashMap<String, HashMap<String, EquipmentRolePoint>>,
kind_eq_ids: &HashMap<String, Uuid>,
delta_ms: i64,
) {
let feeder_roles = kind_roles.get("coal_feeder");
let dist_roles = kind_roles.get("distributor");
let feeder_eq_id = kind_eq_ids.get("coal_feeder").copied();
let dist_eq_id = kind_eq_ids.get("distributor").copied();
match runtime.state {
UnitRuntimeState::Stopped => {
// stop_time_sec == 0 means start immediately (no wait)
if unit.stop_time_sec > 0 {
runtime.current_stop_elapsed_sec += delta_ms; // field holds ms
if runtime.current_stop_elapsed_sec < unit.stop_time_sec as i64 * 1000 {
return;
}
}
let monitor = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
if let Some((pid, vt)) =
feeder_roles.and_then(|r| find_cmd(r, "start_cmd", &monitor))
{
drop(monitor);
if let Err(e) =
send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await
{
tracing::warn!("Engine: auto start coal_feeder failed: {}", e);
return;
}
if state.config.simulate_plc {
if let Some(eq_id) = feeder_eq_id {
crate::control::command::simulate_run_feedback(state, eq_id, true).await;
}
}
runtime.state = UnitRuntimeState::Running;
runtime.current_stop_elapsed_sec = 0;
runtime.current_run_elapsed_sec = 0;
}
}
UnitRuntimeState::Running => {
runtime.current_run_elapsed_sec += delta_ms;
runtime.accumulated_run_sec += delta_ms;
// Check RunTime first — stop feeder before considering distributor trigger
if unit.run_time_sec > 0
&& runtime.current_run_elapsed_sec >= unit.run_time_sec as i64 * 1000
{
let monitor = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
if let Some((pid, vt)) =
feeder_roles.and_then(|r| find_cmd(r, "stop_cmd", &monitor))
{
drop(monitor);
if let Err(e) =
send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await
{
tracing::warn!("Engine: auto stop coal_feeder failed: {}", e);
return;
}
if state.config.simulate_plc {
if let Some(eq_id) = feeder_eq_id {
crate::control::command::simulate_run_feedback(state, eq_id, false)
.await;
}
}
runtime.state = UnitRuntimeState::Stopped;
runtime.current_run_elapsed_sec = 0;
runtime.current_stop_elapsed_sec = 0;
}
return;
}
// Check AccTime — stop feeder then trigger distributor
if unit.acc_time_sec > 0
&& runtime.accumulated_run_sec >= unit.acc_time_sec as i64 * 1000
{
let monitor = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
if let Some((pid, vt)) =
feeder_roles.and_then(|r| find_cmd(r, "stop_cmd", &monitor))
{
drop(monitor);
if let Err(e) =
send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await
{
tracing::warn!("Engine: stop coal_feeder before distributor failed: {}", e);
return;
}
if state.config.simulate_plc {
if let Some(eq_id) = feeder_eq_id {
crate::control::command::simulate_run_feedback(state, eq_id, false)
.await;
}
}
}
runtime.state = UnitRuntimeState::DistributorRunning;
runtime.distributor_run_elapsed_sec = 0;
}
}
UnitRuntimeState::DistributorRunning => {
// First tick in this state (distributor_run_elapsed_sec == 0): send start pulse then return.
// Time advance happens on subsequent ticks.
if runtime.distributor_run_elapsed_sec == 0 {
let monitor = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
if let Some((pid, vt)) =
dist_roles.and_then(|r| find_cmd(r, "start_cmd", &monitor))
{
drop(monitor);
if let Err(e) =
send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await
{
tracing::warn!("Engine: auto start distributor failed: {}", e);
} else if state.config.simulate_plc {
if let Some(eq_id) = dist_eq_id {
crate::control::command::simulate_run_feedback(state, eq_id, true)
.await;
}
}
}
// Mark as "started" by advancing to 1ms so this branch won't re-fire
runtime.distributor_run_elapsed_sec = 1;
return;
}
runtime.distributor_run_elapsed_sec += delta_ms;
if unit.bl_time_sec > 0
&& runtime.distributor_run_elapsed_sec >= unit.bl_time_sec as i64 * 1000
{
let monitor = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
if let Some((pid, vt)) =
dist_roles.and_then(|r| find_cmd(r, "stop_cmd", &monitor))
{
drop(monitor);
if let Err(e) =
send_pulse_command(&state.connection_manager, pid, vt.as_ref(), 300).await
{
tracing::warn!("Engine: auto stop distributor failed: {}", e);
return;
}
if state.config.simulate_plc {
if let Some(eq_id) = dist_eq_id {
crate::control::command::simulate_run_feedback(state, eq_id, false)
.await;
}
}
}
runtime.accumulated_run_sec = 0;
runtime.distributor_run_elapsed_sec = 0;
runtime.state = UnitRuntimeState::Stopped;
runtime.current_stop_elapsed_sec = 0;
}
}
UnitRuntimeState::FaultLocked | UnitRuntimeState::CommLocked => {}
}
}
/// Find a command point by role in a single equipment's role map.
/// Returns `None` if REM==0 or FLT==1 or quality is bad.
fn find_cmd(
roles: &HashMap<String, EquipmentRolePoint>,
role: &str,
monitor: &HashMap<Uuid, PointMonitorInfo>,
) -> Option<(Uuid, Option<crate::telemetry::ValueType>)> {
let cmd_rp = roles.get(role)?;
let rem_ok = roles
.get("rem")
.and_then(|rp| monitor.get(&rp.point_id))
.map(|m| monitor_value_as_bool(m) && m.quality == PointQuality::Good)
.unwrap_or(true);
let flt_ok = roles
.get("flt")
.and_then(|rp| monitor.get(&rp.point_id))
.map(|m| !monitor_value_as_bool(m) && m.quality == PointQuality::Good)
.unwrap_or(true);
if rem_ok && flt_ok {
let vtype = monitor
.get(&cmd_rp.point_id)
.and_then(|m| m.value_type.clone());
Some((cmd_rp.point_id, vtype))
} else {
None
}
}
fn monitor_value_as_bool(monitor: &PointMonitorInfo) -> bool {
match monitor.value.as_ref() {
Some(DataValue::Bool(v)) => *v,
Some(DataValue::Int(v)) => *v != 0,
Some(DataValue::UInt(v)) => *v != 0,
Some(DataValue::Float(v)) => *v != 0.0,
Some(DataValue::Text(v)) => {
matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "on")
}
_ => false,
}
}

4
src/control/mod.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod command;
pub mod engine;
pub mod runtime;
pub mod validator;

79
src/control/runtime.rs Normal file
View File

@ -0,0 +1,79 @@
use std::{collections::HashMap, sync::Arc};
use chrono::{DateTime, Utc};
use tokio::sync::RwLock;
use uuid::Uuid;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum UnitRuntimeState {
Stopped,
Running,
DistributorRunning,
FaultLocked,
CommLocked,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UnitRuntime {
pub unit_id: Uuid,
pub state: UnitRuntimeState,
pub auto_enabled: bool,
pub accumulated_run_sec: i64,
pub current_run_elapsed_sec: i64,
pub current_stop_elapsed_sec: i64,
pub distributor_run_elapsed_sec: i64,
pub fault_locked: bool,
pub flt_active: bool,
pub comm_locked: bool,
pub manual_ack_required: bool,
pub last_tick_at: Option<DateTime<Utc>>,
}
impl UnitRuntime {
pub fn new(unit_id: Uuid) -> Self {
Self {
unit_id,
state: UnitRuntimeState::Stopped,
auto_enabled: false,
accumulated_run_sec: 0,
current_run_elapsed_sec: 0,
current_stop_elapsed_sec: 0,
distributor_run_elapsed_sec: 0,
fault_locked: false,
flt_active: false,
comm_locked: false,
manual_ack_required: false,
last_tick_at: None,
}
}
}
#[derive(Clone, Default)]
pub struct ControlRuntimeStore {
inner: Arc<RwLock<HashMap<Uuid, UnitRuntime>>>,
}
impl ControlRuntimeStore {
pub fn new() -> Self {
Self::default()
}
pub async fn get(&self, unit_id: Uuid) -> Option<UnitRuntime> {
self.inner.read().await.get(&unit_id).cloned()
}
pub async fn get_or_init(&self, unit_id: Uuid) -> UnitRuntime {
if let Some(runtime) = self.get(unit_id).await {
return runtime;
}
let runtime = UnitRuntime::new(unit_id);
self.inner.write().await.insert(unit_id, runtime.clone());
runtime
}
pub async fn upsert(&self, runtime: UnitRuntime) {
self.inner.write().await.insert(runtime.unit_id, runtime);
}
}

214
src/control/validator.rs Normal file
View File

@ -0,0 +1,214 @@
use std::collections::HashMap;
use serde_json::json;
use uuid::Uuid;
use crate::{
service::EquipmentRolePoint,
telemetry::{DataValue, PointMonitorInfo, PointQuality, ValueType},
util::response::ApiErr,
AppState,
};
#[derive(Debug, Clone, Copy)]
pub enum ControlAction {
Start,
Stop,
}
impl ControlAction {
pub fn as_str(self) -> &'static str {
match self {
Self::Start => "start",
Self::Stop => "stop",
}
}
pub fn command_role(self) -> &'static str {
match self {
Self::Start => "start_cmd",
Self::Stop => "stop_cmd",
}
}
}
pub struct ManualControlContext {
pub unit_id: Option<Uuid>,
pub command_point: EquipmentRolePoint,
pub command_value_type: Option<ValueType>,
}
pub async fn validate_manual_control(
state: &AppState,
equipment_id: Uuid,
action: ControlAction,
) -> Result<ManualControlContext, ApiErr> {
let equipment = crate::service::get_equipment_by_id(&state.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.pool, equipment_id).await?;
if role_points.is_empty() {
return Err(ApiErr::BadRequest(
"Equipment has no bound role points".to_string(),
Some(json!({ "equipment_id": equipment_id })),
));
}
let role_map: HashMap<&str, &EquipmentRolePoint> = role_points
.iter()
.map(|point| (point.signal_role.as_str(), point))
.collect();
let command_point = role_map
.get(action.command_role())
.copied()
.ok_or_else(|| {
ApiErr::BadRequest(
format!("Equipment missing role point {}", action.command_role()),
Some(json!({
"equipment_id": equipment_id,
"required_role": action.command_role()
})),
)
})?
.clone();
let monitor_guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
validate_quality(
role_map.get("rem").copied(),
&monitor_guard,
"REM",
equipment_id,
)?;
validate_quality(
role_map.get("flt").copied(),
&monitor_guard,
"FLT",
equipment_id,
)?;
if let Some(rem_point) = role_map.get("rem").copied() {
let rem_monitor = monitor_guard
.get(&rem_point.point_id)
.ok_or_else(|| missing_monitor_err("REM", equipment_id))?;
if !monitor_value_as_bool(rem_monitor) {
return Err(ApiErr::Forbidden(
"Remote control not allowed, REM is not enabled".to_string(),
Some(json!({ "equipment_id": equipment_id })),
));
}
}
if let Some(flt_point) = role_map.get("flt").copied() {
let flt_monitor = monitor_guard
.get(&flt_point.point_id)
.ok_or_else(|| missing_monitor_err("FLT", equipment_id))?;
if monitor_value_as_bool(flt_monitor) {
return Err(ApiErr::Forbidden(
"Equipment fault is active, command denied".to_string(),
Some(json!({ "equipment_id": equipment_id })),
));
}
}
drop(monitor_guard);
// Runtime state checks — block commands if unit is locked
if let Some(unit_id) = equipment.unit_id {
if let Some(runtime) = state.control_runtime.get(unit_id).await {
if runtime.auto_enabled {
return Err(ApiErr::Forbidden(
"Auto control is active; disable auto first".to_string(),
Some(json!({ "unit_id": unit_id })),
));
}
if runtime.comm_locked {
return Err(ApiErr::Forbidden(
"Unit communication is locked".to_string(),
Some(json!({ "unit_id": unit_id })),
));
}
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 })),
));
}
if runtime.manual_ack_required {
return Err(ApiErr::Forbidden(
"Fault acknowledgement required before issuing commands".to_string(),
Some(json!({ "unit_id": unit_id })),
));
}
}
}
let command_value_type = state
.connection_manager
.get_point_monitor_data_read_guard()
.await
.get(&command_point.point_id)
.and_then(|item| item.value_type.clone());
Ok(ManualControlContext {
unit_id: equipment.unit_id,
command_point,
command_value_type,
})
}
fn validate_quality(
role_point: Option<&EquipmentRolePoint>,
monitor_map: &HashMap<Uuid, PointMonitorInfo>,
role: &str,
equipment_id: Uuid,
) -> Result<(), ApiErr> {
let Some(role_point) = role_point else {
return Ok(());
};
let monitor = monitor_map
.get(&role_point.point_id)
.ok_or_else(|| missing_monitor_err(role, equipment_id))?;
if monitor.quality != PointQuality::Good {
return Err(ApiErr::Forbidden(
format!("Communication abnormal for role {}", role),
Some(json!({
"equipment_id": equipment_id,
"role": role,
"quality": monitor.quality
})),
));
}
Ok(())
}
fn missing_monitor_err(role: &str, equipment_id: Uuid) -> ApiErr {
ApiErr::Forbidden(
format!("No realtime value for role {}", role),
Some(json!({
"equipment_id": equipment_id,
"role": role
})),
)
}
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,
}
}

View File

@ -24,6 +24,23 @@ pub enum AppEvent {
source_id: Uuid,
point_ids: Vec<Uuid>,
},
EquipmentStartCommandSent {
equipment_id: Uuid,
unit_id: Option<Uuid>,
point_id: Uuid,
},
EquipmentStopCommandSent {
equipment_id: Uuid,
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 },
UnitStateChanged { unit_id: Uuid, from_state: String, to_state: String },
PointNewValue(crate::telemetry::PointNewValue),
}
@ -45,9 +62,11 @@ impl EventManager {
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).await;
handle_control_event(event, &control_pool, &control_cm, control_ws_manager.as_ref())
.await;
}
});
@ -123,8 +142,9 @@ 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>>,
) {
persist_event_if_needed(&event, pool).await;
persist_event_if_needed(&event, pool, ws_manager).await;
match event {
AppEvent::SourceCreate { source_id } => {
@ -182,13 +202,62 @@ async fn handle_control_event(
tracing::error!("Failed to unsubscribe points: {}", e);
}
}
AppEvent::EquipmentStartCommandSent {
equipment_id,
unit_id,
point_id,
} => {
tracing::info!(
"Equipment start command sent: equipment={}, unit={:?}, point={}",
equipment_id,
unit_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
);
}
AppEvent::AutoControlStarted { unit_id } => {
tracing::info!("Auto control started for unit {}", unit_id);
}
AppEvent::AutoControlStopped { unit_id } => {
tracing::info!("Auto control stopped for unit {}", unit_id);
}
AppEvent::FaultLocked { unit_id, equipment_id } => {
tracing::warn!("Fault locked: unit={}, equipment={}", unit_id, equipment_id);
}
AppEvent::FaultAcked { unit_id } => {
tracing::info!("Fault acked for unit {}", unit_id);
}
AppEvent::CommLocked { unit_id } => {
tracing::warn!("Comm locked for unit {}", unit_id);
}
AppEvent::CommRecovered { unit_id } => {
tracing::info!("Comm recovered for unit {}", unit_id);
}
AppEvent::UnitStateChanged { unit_id, from_state, to_state } => {
tracing::info!("Unit {} state: {} → {}", unit_id, from_state, to_state);
}
AppEvent::PointNewValue(_) => {
tracing::warn!("PointNewValue routed to control worker unexpectedly");
}
}
}
async fn persist_event_if_needed(event: &AppEvent, pool: &sqlx::PgPool) {
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 } => Some((
"source.created",
@ -213,7 +282,7 @@ async fn persist_event_if_needed(event: &AppEvent, pool: &sqlx::PgPool) {
"warn",
None,
None,
Some(*source_id),
None,
format!("Source {} deleted", source_id),
serde_json::json!({ "source_id": source_id }),
)),
@ -235,6 +304,82 @@ async fn persist_event_if_needed(event: &AppEvent, pool: &sqlx::PgPool) {
format!("{} points deleted for source {}", point_ids.len(), source_id),
serde_json::json!({ "source_id": source_id, "point_ids": point_ids }),
)),
AppEvent::EquipmentStartCommandSent {
equipment_id,
unit_id,
point_id,
} => Some((
"equipment.start_command_sent",
"info",
*unit_id,
Some(*equipment_id),
None,
format!("Start command sent to equipment {}", equipment_id),
serde_json::json!({
"equipment_id": equipment_id,
"unit_id": unit_id,
"point_id": point_id
}),
)),
AppEvent::EquipmentStopCommandSent {
equipment_id,
unit_id,
point_id,
} => Some((
"equipment.stop_command_sent",
"info",
*unit_id,
Some(*equipment_id),
None,
format!("Stop command sent to equipment {}", equipment_id),
serde_json::json!({
"equipment_id": equipment_id,
"unit_id": unit_id,
"point_id": point_id
}),
)),
AppEvent::AutoControlStarted { unit_id } => Some((
"unit.auto_control_started", "info",
Some(*unit_id), None, None,
format!("Auto control started for unit {}", unit_id),
serde_json::json!({ "unit_id": unit_id }),
)),
AppEvent::AutoControlStopped { unit_id } => Some((
"unit.auto_control_stopped", "info",
Some(*unit_id), None, None,
format!("Auto control stopped for unit {}", unit_id),
serde_json::json!({ "unit_id": unit_id }),
)),
AppEvent::FaultLocked { unit_id, equipment_id } => Some((
"unit.fault_locked", "error",
Some(*unit_id), Some(*equipment_id), None,
format!("Unit {} fault locked by equipment {}", unit_id, equipment_id),
serde_json::json!({ "unit_id": unit_id, "equipment_id": equipment_id }),
)),
AppEvent::FaultAcked { unit_id } => Some((
"unit.fault_acked", "info",
Some(*unit_id), None, None,
format!("Unit {} fault acknowledged", unit_id),
serde_json::json!({ "unit_id": unit_id }),
)),
AppEvent::CommLocked { unit_id } => Some((
"unit.comm_locked", "warn",
Some(*unit_id), None, None,
format!("Unit {} communication locked", unit_id),
serde_json::json!({ "unit_id": unit_id }),
)),
AppEvent::CommRecovered { unit_id } => Some((
"unit.comm_recovered", "info",
Some(*unit_id), None, None,
format!("Unit {} communication recovered", unit_id),
serde_json::json!({ "unit_id": unit_id }),
)),
AppEvent::UnitStateChanged { unit_id, from_state, to_state } => Some((
"unit.state_changed", "info",
Some(*unit_id), None, None,
format!("Unit {} state: {}{}", unit_id, from_state, to_state),
serde_json::json!({ "unit_id": unit_id, "from": from_state, "to": to_state }),
)),
AppEvent::PointNewValue(_) => None,
};
@ -242,10 +387,11 @@ async fn persist_event_if_needed(event: &AppEvent, pool: &sqlx::PgPool) {
return;
};
if let Err(err) = sqlx::query(
let inserted = sqlx::query_as::<_, crate::model::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(event_type)
@ -255,12 +401,23 @@ async fn persist_event_if_needed(event: &AppEvent, pool: &sqlx::PgPool) {
.bind(source_id)
.bind(message)
.bind(sqlx::types::Json(payload))
.execute(pool)
.await
{
.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,

View File

@ -5,10 +5,12 @@ use axum::{
Json,
};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use validator::Validate;
use crate::{
control::validator::{validate_manual_control, ControlAction},
util::{
pagination::{PaginatedResponse, PaginationParams},
response::ApiErr,
@ -47,6 +49,71 @@ pub async fn get_unit_list(
)))
}
pub async fn start_equipment(
State(state): State<AppState>,
Path(equipment_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiErr> {
send_equipment_command(state, equipment_id, ControlAction::Start).await
}
pub async fn stop_equipment(
State(state): State<AppState>,
Path(equipment_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiErr> {
send_equipment_command(state, equipment_id, ControlAction::Stop).await
}
async fn send_equipment_command(
state: AppState,
equipment_id: Uuid,
action: ControlAction,
) -> Result<impl IntoResponse, ApiErr> {
let context = validate_manual_control(&state, equipment_id, action).await?;
let pulse_ms = 300u64;
crate::control::command::send_pulse_command(
&state.connection_manager,
context.command_point.point_id,
context.command_value_type.as_ref(),
pulse_ms,
)
.await
.map_err(|e| ApiErr::Internal(e, None))?;
if state.config.simulate_plc {
crate::control::command::simulate_run_feedback(
&state,
equipment_id,
matches!(action, ControlAction::Start),
)
.await;
}
let event = match action {
ControlAction::Start => crate::event::AppEvent::EquipmentStartCommandSent {
equipment_id,
unit_id: context.unit_id,
point_id: context.command_point.point_id,
},
ControlAction::Stop => crate::event::AppEvent::EquipmentStopCommandSent {
equipment_id,
unit_id: context.unit_id,
point_id: context.command_point.point_id,
},
};
let _ = state.event_manager.send(event);
Ok(Json(json!({
"ok_msg": format!("Equipment {} command sent", action.as_str()),
"equipment_id": equipment_id,
"unit_id": context.unit_id,
"command_role": context.command_point.signal_role,
"command_point_id": context.command_point.point_id,
"pulse_ms": pulse_ms
})))
}
pub async fn get_unit(
State(state): State<AppState>,
Path(unit_id): Path<Uuid>,
@ -57,6 +124,62 @@ pub async fn get_unit(
}
}
#[derive(serde::Serialize)]
pub struct PointDetail {
#[serde(flatten)]
pub point: crate::model::Point,
pub point_monitor: Option<crate::telemetry::PointMonitorInfo>,
}
#[derive(serde::Serialize)]
pub struct EquipmentDetail {
#[serde(flatten)]
pub equipment: crate::model::Equipment,
pub points: Vec<PointDetail>,
}
#[derive(serde::Serialize)]
pub struct UnitDetail {
#[serde(flatten)]
pub unit: crate::model::ControlUnit,
pub equipments: Vec<EquipmentDetail>,
}
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.pool, unit_id)
.await?
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
let equipments = crate::service::get_equipment_by_unit_id(&state.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.pool, &equipment_ids).await?;
let monitor_guard = state
.connection_manager
.get_point_monitor_data_read_guard()
.await;
let equipments = equipments
.into_iter()
.map(|eq| {
let points = all_points
.iter()
.filter(|p| p.equipment_id == Some(eq.id))
.map(|p| PointDetail {
point_monitor: monitor_guard.get(&p.id).cloned(),
point: p.clone(),
})
.collect();
EquipmentDetail { equipment: eq, points }
})
.collect();
Ok(Json(UnitDetail { unit, equipments }))
}
#[derive(Debug, Deserialize, Validate)]
pub struct CreateUnitReq {
#[validate(length(min = 1, max = 100))]
@ -246,3 +369,140 @@ pub async fn get_event_list(
query.pagination.page_size,
)))
}
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.pool, unit_id)
.await?
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
if !unit.enabled {
return Err(ApiErr::BadRequest("Unit is disabled".to_string(), None));
}
let mut runtime = state.control_runtime.get_or_init(unit_id).await;
runtime.auto_enabled = true;
runtime.state = crate::control::runtime::UnitRuntimeState::Stopped;
runtime.current_stop_elapsed_sec = 0;
state.control_runtime.upsert(runtime).await;
let _ = state.event_manager.send(crate::event::AppEvent::AutoControlStarted { 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.pool, unit_id)
.await?
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
let mut runtime = state.control_runtime.get_or_init(unit_id).await;
runtime.auto_enabled = false;
state.control_runtime.upsert(runtime).await;
let _ = state.event_manager.send(crate::event::AppEvent::AutoControlStopped { 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.pool).await?;
let mut started = Vec::new();
let mut skipped = Vec::new();
for unit in units {
let mut runtime = state.control_runtime.get_or_init(unit.id).await;
if runtime.auto_enabled {
skipped.push(unit.id);
continue;
}
if runtime.fault_locked || runtime.comm_locked {
skipped.push(unit.id);
continue;
}
runtime.auto_enabled = true;
runtime.state = crate::control::runtime::UnitRuntimeState::Stopped;
runtime.current_stop_elapsed_sec = 0;
state.control_runtime.upsert(runtime).await;
let _ = state
.event_manager
.send(crate::event::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.pool).await?;
let mut stopped = Vec::new();
for unit in units {
let mut runtime = state.control_runtime.get_or_init(unit.id).await;
if !runtime.auto_enabled {
continue;
}
runtime.auto_enabled = false;
state.control_runtime.upsert(runtime).await;
let _ = state
.event_manager
.send(crate::event::AppEvent::AutoControlStopped { unit_id: unit.id });
stopped.push(unit.id);
}
Ok(Json(json!({ "stopped": stopped })))
}
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.pool, unit_id)
.await?
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
let mut runtime = state.control_runtime.get_or_init(unit_id).await;
if !runtime.fault_locked {
return Err(ApiErr::BadRequest(
"Unit is not fault locked".to_string(),
Some(json!({ "unit_id": unit_id })),
));
}
if runtime.flt_active {
return Err(ApiErr::BadRequest(
"FLT is still active, cannot acknowledge".to_string(),
Some(json!({ "unit_id": unit_id })),
));
}
runtime.fault_locked = false;
runtime.manual_ack_required = false;
runtime.state = crate::control::runtime::UnitRuntimeState::Stopped;
state.control_runtime.upsert(runtime).await;
let _ = state.event_manager.send(crate::event::AppEvent::FaultAcked { 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.pool, unit_id)
.await?
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
let runtime = state.control_runtime.get_or_init(unit_id).await;
Ok(Json(runtime))
}

View File

@ -5,6 +5,7 @@ use axum::{
Json,
};
use serde::{Deserialize, Serialize};
use serde_with::rust::double_option;
use sqlx::{QueryBuilder, Row};
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
@ -145,11 +146,16 @@ pub async fn get_point_history(
#[derive(Deserialize, Validate)]
pub struct UpdatePointReq {
pub name: Option<String>,
pub description: Option<String>,
pub unit: Option<String>,
pub tag_id: Option<Uuid>,
pub equipment_id: Option<Uuid>,
pub signal_role: Option<String>,
#[serde(default, with = "double_option")]
pub description: Option<Option<String>>,
#[serde(default, with = "double_option")]
pub unit: Option<Option<String>>,
#[serde(default, with = "double_option")]
pub tag_id: Option<Option<Uuid>>,
#[serde(default, with = "double_option")]
pub equipment_id: Option<Option<Uuid>>,
#[serde(default, with = "double_option")]
pub signal_role: Option<Option<String>>,
}
/// Request payload for batch setting point tags.
@ -187,7 +193,7 @@ pub async fn update_point(
}
// If tag_id is provided, ensure tag exists.
if let Some(tag_id) = payload.tag_id {
if let Some(Some(tag_id)) = payload.tag_id {
let tag_exists = sqlx::query(r#"SELECT 1 FROM tag WHERE id = $1"#)
.bind(tag_id)
.fetch_optional(pool)
@ -199,7 +205,7 @@ pub async fn update_point(
}
}
if let Some(equipment_id) = payload.equipment_id {
if let Some(Some(equipment_id)) = payload.equipment_id {
let equipment_exists = sqlx::query(r#"SELECT 1 FROM equipment WHERE id = $1"#)
.bind(equipment_id)
.fetch_optional(pool)
@ -220,29 +226,56 @@ pub async fn update_point(
return Err(ApiErr::NotFound("Point not found".to_string(), None));
}
let mut qb = QueryBuilder::new("UPDATE point SET ");
let mut sep = qb.separated(", ");
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new("UPDATE point SET ");
let mut wrote_field = false;
if let Some(name) = &payload.name {
sep.push("name = ").push_bind(name);
if wrote_field {
qb.push(", ");
}
qb.push("name = ").push_bind(name);
wrote_field = true;
}
if let Some(description) = &payload.description {
sep.push("description = ").push_bind(description);
if wrote_field {
qb.push(", ");
}
qb.push("description = ").push_bind(description.as_deref());
wrote_field = true;
}
if let Some(unit) = &payload.unit {
sep.push("unit = ").push_bind(unit);
if wrote_field {
qb.push(", ");
}
qb.push("unit = ").push_bind(unit.as_deref());
wrote_field = true;
}
if let Some(tag_id) = &payload.tag_id {
sep.push("tag_id = ").push_bind(tag_id);
if wrote_field {
qb.push(", ");
}
qb.push("tag_id = ").push_bind(tag_id.as_ref());
wrote_field = true;
}
if let Some(equipment_id) = &payload.equipment_id {
sep.push("equipment_id = ").push_bind(equipment_id);
if wrote_field {
qb.push(", ");
}
qb.push("equipment_id = ").push_bind(equipment_id.as_ref());
wrote_field = true;
}
if let Some(signal_role) = &payload.signal_role {
sep.push("signal_role = ").push_bind(signal_role);
if wrote_field {
qb.push(", ");
}
qb.push("signal_role = ").push_bind(signal_role.as_deref());
wrote_field = true;
}
sep.push("updated_at = NOW()");
if wrote_field {
qb.push(", ");
}
qb.push("updated_at = NOW()");
qb.push(" WHERE id = ").push_bind(point_id);
qb.build().execute(pool).await?;

View File

@ -1,3 +1,4 @@
mod control;
mod config;
mod connection;
mod db;
@ -10,7 +11,7 @@ mod telemetry;
mod util;
mod websocket;
use axum::{
routing::{get, put},
routing::{get, post, put},
Router,
};
use config::AppConfig;
@ -20,9 +21,19 @@ use event::EventManager;
use middleware::simple_logger;
use std::sync::Arc;
use tokio::sync::mpsc;
use axum::{extract::Request, middleware::Next, response::Response};
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
async fn no_cache(req: Request, next: Next) -> Response {
let mut res = next.run(req).await;
res.headers_mut().insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-store"),
);
res
}
#[derive(Clone)]
pub struct AppState {
pub config: AppConfig,
@ -30,6 +41,7 @@ pub struct AppState {
pub connection_manager: Arc<ConnectionManager>,
pub event_manager: Arc<EventManager>,
pub ws_manager: Arc<websocket::WebSocketManager>,
pub control_runtime: Arc<control::runtime::ControlRuntimeStore>,
}
#[tokio::main]
async fn main() {
@ -52,6 +64,7 @@ async fn main() {
connection_manager.set_pool_and_start_reconnect_task(Arc::new(pool.clone()));
let connection_manager = Arc::new(connection_manager);
let control_runtime = Arc::new(control::runtime::ControlRuntimeStore::new());
// Connect to all enabled sources concurrently
let sources = service::get_all_enabled_sources(&pool)
@ -88,7 +101,9 @@ async fn main() {
connection_manager: connection_manager.clone(),
event_manager,
ws_manager,
control_runtime: control_runtime.clone(),
};
control::engine::start(state.clone(), control_runtime);
let app = build_router(state.clone());
let addr = format!("{}:{}", config.server_host, config.server_port);
tracing::info!("Starting server at http://{}", addr);
@ -204,6 +219,42 @@ fn build_router(state: AppState) -> Router {
"/api/event",
get(handler::control::get_event_list),
)
.route(
"/api/control/equipment/{equipment_id}/start",
post(handler::control::start_equipment),
)
.route(
"/api/control/equipment/{equipment_id}/stop",
post(handler::control::stop_equipment),
)
.route(
"/api/control/unit/{unit_id}/start-auto",
post(handler::control::start_auto_unit),
)
.route(
"/api/control/unit/{unit_id}/stop-auto",
post(handler::control::stop_auto_unit),
)
.route(
"/api/control/unit/batch-start-auto",
post(handler::control::batch_start_auto),
)
.route(
"/api/control/unit/batch-stop-auto",
post(handler::control::batch_stop_auto),
)
.route(
"/api/control/unit/{unit_id}/ack-fault",
post(handler::control::ack_fault_unit),
)
.route(
"/api/unit/{unit_id}/runtime",
get(handler::control::get_unit_runtime),
)
.route(
"/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),
@ -230,9 +281,11 @@ fn build_router(state: AppState) -> Router {
Router::new()
.merge(all_route)
.nest_service(
.nest(
"/ui",
ServeDir::new("web").append_index_html_on_directories(true),
Router::new()
.fallback_service(ServeDir::new("web").append_index_html_on_directories(true))
.layer(axum::middleware::from_fn(no_cache)),
)
.route("/ws/public", get(websocket::public_websocket_handler))
.route(

View File

@ -86,7 +86,7 @@ pub struct Node {
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[allow(dead_code)]
pub struct Point {
pub id: Uuid,

View File

@ -1,7 +1,13 @@
use crate::model::{ControlUnit, EventRecord};
use sqlx::{PgPool, QueryBuilder};
use sqlx::{PgPool, QueryBuilder, Row};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct EquipmentRolePoint {
pub point_id: Uuid,
pub signal_role: String,
}
pub async fn get_units_count(pool: &PgPool, keyword: Option<&str>) -> Result<i64, sqlx::Error> {
match keyword {
Some(keyword) => {
@ -301,3 +307,67 @@ 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> {
sqlx::query_as::<_, ControlUnit>(
r#"SELECT * FROM unit WHERE enabled = TRUE ORDER BY created_at"#,
)
.fetch_all(pool)
.await
}
pub async fn get_equipment_by_unit_id(
pool: &PgPool,
unit_id: Uuid,
) -> Result<Vec<crate::model::Equipment>, sqlx::Error> {
sqlx::query_as::<_, crate::model::Equipment>(
r#"SELECT * FROM equipment WHERE unit_id = $1 ORDER BY created_at"#,
)
.bind(unit_id)
.fetch_all(pool)
.await
}
pub async fn get_points_by_equipment_ids(
pool: &PgPool,
equipment_ids: &[Uuid],
) -> Result<Vec<crate::model::Point>, sqlx::Error> {
if equipment_ids.is_empty() {
return Ok(vec![]);
}
sqlx::query_as::<_, crate::model::Point>(
r#"SELECT * FROM point WHERE equipment_id = ANY($1) ORDER BY equipment_id, created_at"#,
)
.bind(equipment_ids)
.fetch_all(pool)
.await
}
pub async fn get_equipment_role_points(
pool: &PgPool,
equipment_id: Uuid,
) -> Result<Vec<EquipmentRolePoint>, sqlx::Error> {
let rows = sqlx::query(
r#"
SELECT
p.id AS point_id,
p.signal_role
FROM equipment e
INNER JOIN point p ON p.equipment_id = e.id
WHERE e.id = $1
AND p.signal_role IS NOT NULL
ORDER BY p.created_at
"#,
)
.bind(equipment_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| EquipmentRolePoint {
point_id: row.get("point_id"),
signal_role: row.get("signal_role"),
})
.collect())
}

View File

@ -17,6 +17,8 @@ use uuid::Uuid;
pub enum WsMessage {
PointNewValue(crate::telemetry::PointMonitorInfo),
PointSetValueBatchResult(crate::connection::BatchSetPointValueRes),
EventCreated(crate::model::EventRecord),
UnitRuntimeChanged(crate::control::runtime::UnitRuntime),
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -1,5 +1,5 @@
<div class="drawer-backdrop hidden" id="apiDocDrawer">
<aside class="drawer" role="dialog" aria-modal="true" aria-labelledby="apiDocTitle">
<aside class="drawer api-drawer" role="dialog" aria-modal="true" aria-labelledby="apiDocTitle">
<div class="drawer-head">
<h3 id="apiDocTitle">API.md</h3>
<button type="button" class="secondary" id="closeApiDoc">关闭</button>

View File

@ -0,0 +1,6 @@
<section class="panel bottom-mid">
<div class="panel-head">
<h2>实时日志</h2>
</div>
<div class="log" id="logView"></div>
</section>

View File

@ -1,18 +1,7 @@
<section class="panel bottom-middle">
<div class="stack-panel">
<div class="stack-section event-section">
<section class="panel ops-bottom">
<div class="panel-head">
<h2>系统事件</h2>
<button type="button" class="secondary" id="refreshEventBtn">刷新</button>
</div>
<div class="list event-list" id="eventList"></div>
</div>
<div class="stack-section stack-section-bordered log-section">
<div class="panel-head">
<h2>实时日志</h2>
</div>
<div class="log" id="logView"></div>
</div>
</div>
</section>

View File

@ -72,7 +72,7 @@
</label>
<label>
类型
<input id="equipmentKind" placeholder="coal_feeder / distributor" />
<select id="equipmentKind"></select>
</label>
<label>
说明

17
web/html/ops-panel.html Normal file
View File

@ -0,0 +1,17 @@
<section class="panel ops-main">
<div class="ops-layout">
<aside class="ops-unit-sidebar">
<div class="panel-head">
<h2>控制单元</h2>
<div class="ops-batch-actions">
<button type="button" class="secondary" id="batchStartAutoBtn" title="启动所有未锁定单元的自动控制">全部启动</button>
<button type="button" class="danger" id="batchStopAutoBtn" title="停止所有单元的自动控制">全部停止</button>
</div>
</div>
<div class="list ops-unit-list" id="opsUnitList"></div>
</aside>
<div class="ops-equipment-area" id="opsEquipmentArea">
<div class="muted ops-placeholder">← 选择控制单元</div>
</div>
</div>
</section>

View File

@ -1,8 +1,15 @@
<header class="topbar">
<div class="title">PLC Control</div>
<div class="tab-bar">
<button type="button" class="tab-btn active" id="tabOps">运维</button>
<button type="button" class="tab-btn" id="tabConfig">配置</button>
</div>
<div class="topbar-actions">
<button type="button" class="secondary" id="clearEquipmentFilter">设备筛选: 全部</button>
<button type="button" class="secondary" id="openApiDoc">API.md</button>
<div class="status" id="statusText">Ready</div>
<div class="status" id="statusText">
<span class="ws-dot" id="wsDot"></span>
<span id="wsLabel">连接中…</span>
</div>
</div>
</header>

View File

@ -4,15 +4,17 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PLC Control</title>
<link rel="stylesheet" href="/ui/styles.css?v=20260323e" />
<link rel="stylesheet" href="/ui/styles.css?v=20260325f" />
</head>
<body>
<div data-partial="/ui/html/topbar.html"></div>
<main class="grid">
<main class="grid-ops">
<div data-partial="/ui/html/ops-panel.html"></div>
<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/logs-panel.html"></div>
<div data-partial="/ui/html/chart-panel.html"></div>
</main>
@ -20,6 +22,6 @@
<div data-partial="/ui/html/modals.html"></div>
<div data-partial="/ui/html/api-doc-drawer.html"></div>
<script type="module" src="/ui/js/index.js?v=20260323f"></script>
<script type="module" src="/ui/js/index.js?v=20260325f"></script>
</body>
</html>

View File

@ -4,6 +4,58 @@ export function setStatus(text) {
dom.statusText.textContent = text;
}
// ── Toast ─────────────────────────────────────────
function getContainer() {
let el = document.getElementById("toast-container");
if (!el) {
el = document.createElement("div");
el.id = "toast-container";
document.body.appendChild(el);
}
return el;
}
const ICONS = { error: "✕", warning: "!", success: "✓", info: "i" };
/**
* 显示 toast 通知
* @param {string} title 主要文字
* @param {object} [opts]
* @param {string} [opts.message] 次要说明文字
* @param {"error"|"warning"|"success"|"info"} [opts.level="error"]
* @param {number} [opts.duration=4000] 自动关闭毫秒数0 表示不自动关闭
* @param {boolean} [opts.shake=false] 出现时加抖动动画
* @returns {{ dismiss: () => void }}
*/
export function showToast(title, { message, level = "error", duration = 4000, shake = false } = {}) {
const container = getContainer();
const el = document.createElement("div");
el.className = `toast ${level}${shake ? " shake" : ""}`;
el.innerHTML = `
<span class="toast-icon">${ICONS[level] ?? "i"}</span>
<div class="toast-body">
<div class="toast-title">${title}</div>
${message ? `<div class="toast-message">${message}</div>` : ""}
</div>`;
const dismiss = () => {
if (!el.parentNode) return;
el.classList.remove("shake");
el.classList.add("hiding");
el.addEventListener("animationend", () => el.remove(), { once: true });
};
el.addEventListener("click", dismiss);
container.appendChild(el);
if (duration > 0) setTimeout(dismiss, duration);
return { dismiss };
}
// ── apiFetch ──────────────────────────────────────
export async function apiFetch(url, options = {}) {
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
@ -11,7 +63,9 @@ export async function apiFetch(url, options = {}) {
});
if (!response.ok) {
throw new Error((await response.text()) || response.statusText);
const text = (await response.text()) || response.statusText;
showToast(`请求失败 ${response.status}`, { message: text });
throw new Error(text);
}
if (response.status === 204) {

View File

@ -14,7 +14,8 @@ import {
resetEquipmentForm,
saveEquipment,
} from "./equipment.js";
import { startLogs, startPointSocket } from "./logs.js";
import { startPointSocket, startLogs, stopLogs } from "./logs.js";
import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js";
import {
clearBatchBinding,
browseAndLoadTree,
@ -34,6 +35,36 @@ import { state } from "./state.js";
import { loadSources, saveSource } from "./sources.js";
import { closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.js";
function switchView(view) {
state.activeView = view;
const main = document.querySelector("main");
main.className = view === "ops" ? "grid-ops" : "grid-config";
dom.tabOps.classList.toggle("active", view === "ops");
dom.tabConfig.classList.toggle("active", view === "config");
// config-only panels
["top-left", "top-right", "bottom-left", "bottom-right"].forEach((cls) => {
const el = main.querySelector(`.panel.${cls}`);
if (el) el.classList.toggle("hidden", view === "ops");
});
// bottom-mid is log-stream in config, hidden in ops
const logStreamPanel = main.querySelector(".panel.bottom-mid");
if (logStreamPanel) logStreamPanel.classList.toggle("hidden", view === "ops");
// ops-only panels
const opsMain = main.querySelector(".panel.ops-main");
const opsBottom = main.querySelector(".panel.ops-bottom");
if (opsMain) opsMain.classList.toggle("hidden", view === "config");
if (opsBottom) opsBottom.classList.toggle("hidden", view === "config");
if (view === "config") {
startLogs();
} else {
stopLogs();
}
}
function bindEvents() {
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event)));
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event)));
@ -125,21 +156,31 @@ function bindEvents() {
}
});
dom.tabOps.addEventListener("click", () => switchView("ops"));
dom.tabConfig.addEventListener("click", () => switchView("config"));
document.addEventListener("equipments-updated", () => {
renderUnits();
renderOpsUnits();
});
document.addEventListener("units-loaded", () => {
renderOpsUnits();
if (!state.selectedOpsUnitId) loadAllEquipmentCards();
});
}
async function bootstrap() {
bindEvents();
switchView("ops");
renderSelectedNodes();
updateSelectedPointSummary();
updatePointFilterSummary();
renderChart();
startLogs();
startPointSocket();
await withStatus(loadUnits());
startOps();
await withStatus(loadSources());
await withStatus(loadEquipments());
await withStatus(loadEvents());

View File

@ -103,10 +103,11 @@ export async function loadApiDoc() {
if (!id) {
return;
}
dom.apiDocContent.querySelector(`#${CSS.escape(id)}`)?.scrollIntoView({
behavior: "smooth",
block: "start",
});
const target = dom.apiDocContent.querySelector(`#${CSS.escape(id)}`);
if (target) {
const offset = target.getBoundingClientRect().top - dom.apiDocContent.getBoundingClientRect().top;
dom.apiDocContent.scrollBy({ top: offset, behavior: "smooth" });
}
});
});

View File

@ -2,6 +2,15 @@ const byId = (id) => document.getElementById(id);
export const dom = {
statusText: byId("statusText"),
wsDot: byId("wsDot"),
wsLabel: byId("wsLabel"),
batchStartAutoBtn: byId("batchStartAutoBtn"),
batchStopAutoBtn: byId("batchStopAutoBtn"),
tabOps: byId("tabOps"),
tabConfig: byId("tabConfig"),
opsUnitList: byId("opsUnitList"),
opsEquipmentArea: byId("opsEquipmentArea"),
logView: byId("logView"),
sourceList: byId("sourceList"),
unitList: byId("unitList"),
eventList: byId("eventList"),
@ -15,7 +24,6 @@ export const dom = {
pointSourceSelect: byId("pointSourceSelect"),
pointSourceNodeCount: byId("pointSourceNodeCount"),
openPointModalBtn: byId("openPointModal"),
logView: byId("logView"),
chartCanvas: byId("chartCanvas"),
chartTitle: byId("chartTitle"),
chartSummary: byId("chartSummary"),

View File

@ -1,6 +1,6 @@
import { apiFetch } from "./api.js";
import { dom } from "./dom.js";
import { renderRoleOptions } from "./roles.js";
import { renderEquipmentKindOptions, renderRoleOptions } from "./roles.js";
import { clearSelectedPoints, loadPoints, updatePointFilterSummary } from "./points.js";
import { state } from "./state.js";
@ -81,6 +81,7 @@ export function resetEquipmentForm() {
dom.equipmentForm.reset();
dom.equipmentId.value = "";
renderEquipmentUnitOptions("");
dom.equipmentKind.innerHTML = renderEquipmentKindOptions("");
}
function openEquipmentModal() {
@ -93,6 +94,9 @@ export function closeEquipmentModal() {
export function openCreateEquipmentModal() {
resetEquipmentForm();
if (state.selectedUnitId && dom.equipmentUnitId) {
dom.equipmentUnitId.value = state.selectedUnitId;
}
openEquipmentModal();
}
@ -101,7 +105,7 @@ function openEditEquipmentModal(equipment) {
dom.equipmentUnitId.value = equipment.unit_id || "";
dom.equipmentCode.value = equipment.code || "";
dom.equipmentName.value = equipment.name || "";
dom.equipmentKind.value = equipment.kind || "";
dom.equipmentKind.innerHTML = renderEquipmentKindOptions(equipment.kind || "");
dom.equipmentDescription.value = equipment.description || "";
openEquipmentModal();
}
@ -209,6 +213,29 @@ export function renderEquipments() {
});
actionRow.append(editBtn, deleteBtn);
if (equipment.kind === "coal_feeder" || equipment.kind === "distributor") {
const startBtn = document.createElement("button");
startBtn.className = "secondary";
startBtn.textContent = "Start";
startBtn.addEventListener("click", (e) => {
e.stopPropagation();
apiFetch(`/api/control/equipment/${equipment.id}/start`, { method: "POST" })
.catch(() => {});
});
const stopBtn = document.createElement("button");
stopBtn.className = "danger";
stopBtn.textContent = "Stop";
stopBtn.addEventListener("click", (e) => {
e.stopPropagation();
apiFetch(`/api/control/equipment/${equipment.id}/stop`, { method: "POST" })
.catch(() => {});
});
actionRow.append(startBtn, stopBtn);
}
dom.equipmentList.appendChild(box);
});
}
@ -235,6 +262,7 @@ export async function loadEquipments() {
renderEquipmentUnitOptions(dom.equipmentUnitId?.value || "");
renderBatchUnitOptions(dom.equipmentBatchUnitId?.value || "");
dom.equipmentKind.innerHTML = renderEquipmentKindOptions(dom.equipmentKind?.value || "");
renderBindingEquipmentOptions();
renderBatchBindingDefaults();
if (state.selectedEquipmentId && !state.equipmentMap.has(state.selectedEquipmentId)) {

View File

@ -2,47 +2,80 @@ import { apiFetch } from "./api.js";
import { dom } from "./dom.js";
import { state } from "./state.js";
const PAGE_SIZE = 10;
let _page = 1;
let _hasMore = false;
let _loading = false;
function formatTime(value) {
if (!value) {
return "--";
}
return value;
return value || "--";
}
export function renderEvents() {
function makeCard(item) {
const row = document.createElement("div");
row.className = "event-card";
row.innerHTML = `<div class="event-meta"><span class="badge">${(item.level || "info").toUpperCase()}</span><span class="muted event-time">${formatTime(item.created_at)}</span><strong class="event-type">${item.event_type}</strong></div><div class="event-message">${item.message}</div>`;
return row;
}
async function loadMore() {
if (_loading || !_hasMore) return;
_loading = true;
const params = new URLSearchParams({ page: String(_page), page_size: String(PAGE_SIZE) });
if (state.selectedUnitId) params.set("unit_id", state.selectedUnitId);
try {
const response = await apiFetch(`/api/event?${params.toString()}`);
const items = response.data || [];
items.forEach((item) => dom.eventList.appendChild(makeCard(item)));
_hasMore = items.length === PAGE_SIZE;
_page += 1;
} finally {
_loading = false;
}
}
export async function loadEvents() {
_page = 1;
_hasMore = false;
_loading = false;
dom.eventList.innerHTML = "";
if (!state.events.length) {
const params = new URLSearchParams({ page: "1", page_size: String(PAGE_SIZE) });
if (state.selectedUnitId) params.set("unit_id", state.selectedUnitId);
_loading = true;
try {
const response = await apiFetch(`/api/event?${params.toString()}`);
const items = response.data || [];
if (!items.length) {
dom.eventList.innerHTML = '<div class="list-item"><div class="muted">暂无事件</div></div>';
return;
}
state.events.forEach((item) => {
const row = document.createElement("div");
row.className = "list-item event-card";
row.innerHTML = `
<div class="row">
<strong>${item.event_type}</strong>
<span class="badge">${(item.level || "info").toUpperCase()}</span>
</div>
<div>${item.message}</div>
<div class="muted">${formatTime(item.created_at)}</div>
`;
dom.eventList.appendChild(row);
items.forEach((item) => dom.eventList.appendChild(makeCard(item)));
_hasMore = items.length === PAGE_SIZE;
_page = 2;
} finally {
_loading = false;
}
}
export function prependEvent(item) {
if (state.selectedUnitId && item.unit_id !== state.selectedUnitId) return;
const placeholder = dom.eventList.querySelector(".list-item");
if (placeholder) placeholder.remove();
dom.eventList.insertBefore(makeCard(item), dom.eventList.firstChild);
}
dom.eventList.addEventListener("scroll", () => {
const el = dom.eventList;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) {
loadMore();
}
});
}
export async function loadEvents() {
const params = new URLSearchParams({
page: "1",
page_size: "20",
});
if (state.selectedUnitId) {
params.set("unit_id", state.selectedUnitId);
}
const response = await apiFetch(`/api/event?${params.toString()}`);
state.events = response.data || [];
renderEvents();
}

View File

@ -1,33 +1,26 @@
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 { renderUnits } from "./units.js";
import { showToast } from "./api.js";
function escapeHtml(text) {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
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;
}
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
try { return JSON.parse(trimmed); } catch { return null; }
}
export 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;
@ -39,31 +32,51 @@ export function appendLog(line) {
`<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>`,
`<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;
}
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
}
export function startLogs() {
if (state.logSource) {
state.logSource.close();
}
if (state.logSource) return;
state.logSource = new EventSource("/api/logs/stream");
state.logSource.addEventListener("log", (event) => {
const data = JSON.parse(event.data);
(data.lines || []).forEach(appendLog);
});
state.logSource.addEventListener("error", () => {
appendLog("[log stream error]");
state.logSource.addEventListener("error", () => appendLog("[log stream error]"));
}
export function stopLogs() {
if (state.logSource) {
state.logSource.close();
state.logSource = null;
}
}
let _disconnectToast = null;
function setWsStatus(connected) {
if (dom.wsDot) {
dom.wsDot.className = `ws-dot ${connected ? "connected" : "disconnected"}`;
}
if (dom.wsLabel) {
dom.wsLabel.textContent = connected ? "已连接" : "连接断开,重连中…";
}
if (!connected && !_disconnectToast) {
_disconnectToast = showToast("后端连接断开", {
message: "正在重连,请稍候…",
level: "error",
duration: 0,
shake: true,
});
} else if (connected && _disconnectToast) {
_disconnectToast.dismiss();
_disconnectToast = null;
showToast("连接已恢复", { level: "success", duration: 3000 });
}
}
export function startPointSocket() {
@ -71,14 +84,15 @@ export function startPointSocket() {
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
state.pointSocket = ws;
ws.onopen = () => setWsStatus(true);
ws.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type !== "PointNewValue" && payload.type !== "point_new_value") {
return;
}
if (payload.type === "PointNewValue" || payload.type === "point_new_value") {
const data = payload.data;
// config view point table
const entry = state.pointEls.get(data.point_id);
if (entry) {
entry.value.textContent = formatValue(data);
@ -87,15 +101,44 @@ export function startPointSocket() {
entry.time.textContent = data.timestamp || "--";
}
// ops view signal cell
const opsEntry = state.opsPointEls.get(data.point_id);
if (opsEntry) {
opsEntry.valueEl.textContent = formatValue(data);
opsEntry.qualityEl.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`;
opsEntry.qualityEl.textContent = (data.quality || "unknown").toUpperCase();
}
if (state.chartPointId === data.point_id) {
appendChartPoint(data);
}
return;
}
if (payload.type === "EventCreated" || payload.type === "event_created") {
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, runtime.auto_enabled);
});
return;
}
} catch {
// ignore malformed messages
}
};
ws.onclose = () => {
setWsStatus(false);
window.setTimeout(startPointSocket, 2000);
};
ws.onerror = () => setWsStatus(false);
}

218
web/js/ops.js Normal file
View File

@ -0,0 +1,218 @@
import { apiFetch } from "./api.js";
import { dom } from "./dom.js";
import { formatValue } from "./points.js";
import { state } from "./state.js";
import { loadUnits } from "./units.js";
const SIGNAL_ROLES = ["rem", "run", "flt"];
const ROLE_LABELS = { rem: "REM", run: "RUN", flt: "FLT" };
function runtimeBadge(runtime) {
if (!runtime) return '<span class="badge offline">OFFLINE</span>';
if (runtime.comm_locked) return '<span class="badge offline">COMM ERR</span>';
if (runtime.fault_locked) return '<span class="badge danger">FAULT</span>';
const labels = { stopped: "STOPPED", running: "RUNNING", distributor_running: "DIST RUN", fault_locked: "FAULT", comm_locked: "COMM ERR" };
const cls = { stopped: "", running: "online", distributor_running: "online", fault_locked: "danger", comm_locked: "offline" };
return `<span class="badge ${cls[runtime.state] ?? ""}">${labels[runtime.state] ?? runtime.state}</span>`;
}
export function renderOpsUnits() {
if (!dom.opsUnitList) return;
dom.opsUnitList.innerHTML = "";
if (!state.units.length) {
dom.opsUnitList.innerHTML = '<div class="muted" style="padding:12px">暂无控制单元</div>';
return;
}
state.units.forEach((unit) => {
const runtime = state.runtimes.get(unit.id);
const item = document.createElement("div");
item.className = `ops-unit-item${state.selectedOpsUnitId === unit.id ? " selected" : ""}`;
item.innerHTML = `
<div class="ops-unit-item-name">${unit.code} / ${unit.name}</div>
<div class="ops-unit-item-meta">
${runtimeBadge(runtime)}
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
${runtime ? `<span class="muted">Acc ${Math.floor(runtime.accumulated_run_sec / 1000)}s</span>` : ""}
</div>
<div class="ops-unit-item-actions"></div>
`;
item.addEventListener("click", () => selectOpsUnit(unit.id));
const actions = item.querySelector(".ops-unit-item-actions");
const isAutoOn = runtime?.auto_enabled;
const autoBtn = document.createElement("button");
autoBtn.className = isAutoOn ? "danger" : "secondary";
autoBtn.textContent = isAutoOn ? "Stop Auto" : "Start Auto";
autoBtn.title = isAutoOn ? "停止自动控制" : "启动自动控制";
autoBtn.addEventListener("click", (e) => {
e.stopPropagation();
apiFetch(`/api/control/unit/${unit.id}/${isAutoOn ? "stop-auto" : "start-auto"}`, { method: "POST" })
.then(() => loadUnits()).catch(() => {});
});
actions.append(autoBtn);
if (runtime?.manual_ack_required) {
const ackBtn = document.createElement("button");
ackBtn.className = "danger";
ackBtn.textContent = "Ack Fault";
ackBtn.title = "人工确认解除故障锁定";
ackBtn.addEventListener("click", (e) => {
e.stopPropagation();
apiFetch(`/api/control/unit/${unit.id}/ack-fault`, { method: "POST" })
.then(() => loadUnits()).catch(() => {});
});
actions.append(ackBtn);
}
dom.opsUnitList.appendChild(item);
});
}
async function selectOpsUnit(unitId) {
state.selectedOpsUnitId = unitId === state.selectedOpsUnitId ? null : unitId;
renderOpsUnits();
if (!state.selectedOpsUnitId) {
await loadAllEquipmentCards();
return;
}
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">加载中...</div>';
state.opsPointEls.clear();
const detail = await apiFetch(`/api/unit/${state.selectedOpsUnitId}/detail`);
renderOpsEquipments(detail.equipments || []);
}
export async function loadAllEquipmentCards() {
if (!dom.opsEquipmentArea) return;
if (!state.units.length) {
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">暂无控制单元</div>';
return;
}
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">加载中...</div>';
state.opsPointEls.clear();
const details = await Promise.all(
state.units.map((u) => apiFetch(`/api/unit/${u.id}/detail`).catch(() => ({ equipments: [] })))
);
const allEquipments = details.flatMap((d) => d.equipments || []);
renderOpsEquipments(allEquipments);
}
function renderOpsEquipments(equipments) {
dom.opsEquipmentArea.innerHTML = "";
if (!equipments.length) {
dom.opsEquipmentArea.innerHTML = '<div class="muted ops-placeholder">该单元下暂无设备</div>';
return;
}
equipments.forEach((eq) => {
const card = document.createElement("div");
card.className = "ops-eq-card";
// Build role → point map
const roleMap = {};
(eq.points || []).forEach((p) => {
if (p.signal_role) roleMap[p.signal_role] = p;
});
// Signal rows HTML (placeholders; WS will fill values)
const signalRowsHtml = SIGNAL_ROLES.map((role) => {
const point = roleMap[role];
if (!point) return "";
return `
<div class="ops-signal-row">
<span class="ops-signal-label">${ROLE_LABELS[role] || role}</span>
<span class="badge quality-unknown" data-ops-quality="${point.id}">?</span>
<span class="ops-signal-value" data-ops-value="${point.id}">--</span>
</div>`;
}).join("");
const canControl = eq.kind === "coal_feeder" || eq.kind === "distributor";
card.innerHTML = `
<div class="ops-eq-card-head">
<strong title="${eq.name}">${eq.code}</strong>
<span class="badge">${eq.kind || "--"}</span>
</div>
<div class="ops-signal-rows">${signalRowsHtml || '<span class="muted" style="font-size:11px;padding:2px 0">无绑定信号</span>'}</div>
${canControl ? `<div class="ops-eq-card-actions" data-unit-id="${eq.unit_id || ""}"></div>` : ""}
`;
if (canControl) {
const actions = card.querySelector(".ops-eq-card-actions");
const autoOn = !!(eq.unit_id && state.runtimes.get(eq.unit_id)?.auto_enabled);
const startBtn = document.createElement("button");
startBtn.className = "secondary";
startBtn.textContent = "Start";
startBtn.disabled = autoOn;
startBtn.title = autoOn ? "自动控制运行中,请先停止自动" : "";
startBtn.addEventListener("click", () =>
apiFetch(`/api/control/equipment/${eq.id}/start`, { method: "POST" }).catch(() => {})
);
const stopBtn = document.createElement("button");
stopBtn.className = "danger";
stopBtn.textContent = "Stop";
stopBtn.disabled = autoOn;
stopBtn.title = autoOn ? "自动控制运行中,请先停止自动" : "";
stopBtn.addEventListener("click", () =>
apiFetch(`/api/control/equipment/${eq.id}/stop`, { method: "POST" }).catch(() => {})
);
actions.append(startBtn, stopBtn);
}
dom.opsEquipmentArea.appendChild(card);
// Register DOM elements for WS updates, then seed from cached monitor data
SIGNAL_ROLES.forEach((role) => {
const point = roleMap[role];
if (!point) return;
const valueEl = card.querySelector(`[data-ops-value="${point.id}"]`);
const qualityEl = card.querySelector(`[data-ops-quality="${point.id}"]`);
if (valueEl && qualityEl) {
state.opsPointEls.set(point.id, { valueEl, qualityEl });
if (point.point_monitor) {
const m = point.point_monitor;
valueEl.textContent = formatValue(m);
qualityEl.className = `badge quality-${(m.quality || "unknown").toLowerCase()}`;
qualityEl.textContent = (m.quality || "unknown").toUpperCase();
}
}
});
});
}
export function startOps() {
renderOpsUnits();
loadAllEquipmentCards();
dom.batchStartAutoBtn?.addEventListener("click", () => {
apiFetch("/api/control/unit/batch-start-auto", { method: "POST" })
.then(() => loadUnits())
.catch(() => {});
});
dom.batchStopAutoBtn?.addEventListener("click", () => {
apiFetch("/api/control/unit/batch-stop-auto", { method: "POST" })
.then(() => loadUnits())
.catch(() => {});
});
}
/** Called by WS handler when a unit's runtime changes — syncs manual button disabled state. */
export function syncEquipmentButtonsForUnit(unitId, autoEnabled) {
if (!dom.opsEquipmentArea) return;
dom.opsEquipmentArea
.querySelectorAll(`.ops-eq-card-actions[data-unit-id="${unitId}"]`)
.forEach((actions) => {
actions.querySelectorAll("button").forEach((btn) => {
btn.disabled = autoEnabled;
btn.title = autoEnabled ? "自动控制运行中,请先停止自动" : "";
});
});
}

View File

@ -10,7 +10,7 @@ import { renderRoleOptions } from "./roles.js";
import { state } from "./state.js";
function updatePointSourceNodeCount() {
const count = dom.nodeTree.querySelectorAll("details, summary").length;
const count = dom.nodeTree.querySelectorAll("details").length;
dom.pointSourceNodeCount.textContent = `Nodes: ${count}`;
}

View File

@ -1,19 +1,17 @@
export const SIGNAL_ROLE_OPTIONS = [
{ value: "", label: "Unset" },
{ value: "remote_status", label: "Remote Status" },
{ value: "run_status", label: "Run Status" },
{ value: "fault_status", label: "Fault Status" },
{ value: "ready_status", label: "Ready Status" },
{ value: "alarm_status", label: "Alarm Status" },
{ value: "interlock_status", label: "Interlock Status" },
{ value: "auto_enable", label: "Auto Enable" },
{ value: "mode_auto", label: "Auto Mode" },
{ value: "mode_manual", label: "Manual Mode" },
{ value: "rem", label: "REM Remote Enable" },
{ value: "run", label: "RUN Running" },
{ value: "flt", label: "FLT Fault" },
{ value: "ii", label: "II Current" },
{ value: "start_cmd", label: "Start Command" },
{ value: "stop_cmd", label: "Stop Command" },
{ value: "reset_cmd", label: "Reset Command" },
{ value: "runtime_value", label: "Runtime Value" },
{ value: "counter_value", label: "Counter Value" },
];
export const EQUIPMENT_KIND_OPTIONS = [
{ value: "", label: "Unset" },
{ value: "coal_feeder", label: "Coal Feeder" },
{ value: "distributor", label: "Distributor" },
];
export function renderRoleOptions(selected = "") {
@ -22,3 +20,10 @@ export function renderRoleOptions(selected = "") {
return `<option value="${item.value}" ${isSelected}>${item.label}</option>`;
}).join("");
}
export function renderEquipmentKindOptions(selected = "") {
return EQUIPMENT_KIND_OPTIONS.map((item) => {
const isSelected = item.value === selected ? "selected" : "";
return `<option value="${item.value}" ${isSelected}>${item.label}</option>`;
}).join("");
}

View File

@ -18,7 +18,11 @@ export const state = {
chartPointId: null,
chartPointName: "",
chartData: [],
logSource: null,
pointSocket: null,
apiDocLoaded: false,
runtimes: new Map(), // unit_id -> UnitRuntime
activeView: "ops", // "ops" | "config"
opsPointEls: new Map(), // point_id -> { valueEl, qualityEl }
logSource: null,
selectedOpsUnitId: null,
};

View File

@ -74,6 +74,29 @@ async function selectUnit(unitId) {
await loadEvents();
}
function runtimeBadge(runtime) {
if (!runtime) return '<span class="badge offline">OFFLINE</span>';
if (runtime.comm_locked) return '<span class="badge offline">COMM ERR</span>';
if (runtime.fault_locked) return '<span class="badge danger">FAULT</span>';
const stateLabels = {
stopped: 'STOPPED',
running: 'RUNNING',
distributor_running: 'DIST RUN',
fault_locked: 'FAULT',
comm_locked: 'COMM ERR',
};
const stateCls = {
stopped: '',
running: 'online',
distributor_running: 'online',
fault_locked: 'danger',
comm_locked: 'offline',
};
const label = stateLabels[runtime.state] ?? runtime.state;
const cls = stateCls[runtime.state] ?? '';
return `<span class="badge ${cls}">${label}</span>`;
}
export function renderUnits() {
dom.unitList.innerHTML = "";
@ -86,13 +109,15 @@ export function renderUnits() {
const card = document.createElement("div");
const selected = state.selectedUnitId === unit.id;
card.className = `list-item unit-card ${selected ? "selected" : ""}`;
const runtime = state.runtimes.get(unit.id);
card.innerHTML = `
<div class="row">
<strong>${unit.code}</strong>
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "ENABLED" : "DISABLED"}</span>
${runtimeBadge(runtime)}
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
</div>
<div>${unit.name}</div>
<div class="muted">设备 ${equipmentCount(unit.id)} </div>
<div class="muted">设备 ${equipmentCount(unit.id)} | Acc ${runtime ? Math.floor(runtime.accumulated_run_sec / 1000) : 0}s</div>
<div class="muted">Run ${unit.run_time_sec}s / Stop ${unit.stop_time_sec}s / Acc ${unit.acc_time_sec}s / BL ${unit.bl_time_sec}s</div>
<div class="row unit-card-actions"></div>
`;
@ -124,6 +149,32 @@ export function renderUnits() {
});
actions.append(editBtn, deleteBtn);
const isAutoOn = runtime?.auto_enabled;
const autoBtn = document.createElement("button");
autoBtn.className = isAutoOn ? "danger" : "secondary";
autoBtn.textContent = isAutoOn ? "Stop Auto" : "Start Auto";
autoBtn.title = isAutoOn ? "停止自动控制" : "启动自动控制";
autoBtn.addEventListener("click", (e) => {
e.stopPropagation();
const url = `/api/control/unit/${unit.id}/${isAutoOn ? "stop-auto" : "start-auto"}`;
apiFetch(url, { method: "POST" }).then(() => loadUnits()).catch(() => {});
});
actions.append(autoBtn);
if (runtime?.manual_ack_required) {
const ackBtn = document.createElement("button");
ackBtn.className = "danger";
ackBtn.textContent = "Ack Fault";
ackBtn.title = "人工确认解除故障锁定";
ackBtn.addEventListener("click", (e) => {
e.stopPropagation();
apiFetch(`/api/control/unit/${unit.id}/ack-fault`, { method: "POST" })
.then(() => loadUnits()).catch(() => {});
});
actions.append(ackBtn);
}
dom.unitList.appendChild(card);
});
}
@ -140,6 +191,7 @@ export async function loadUnits() {
renderUnits();
renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId);
renderUnitOptions(dom.equipmentBatchUnitId?.value || "", dom.equipmentBatchUnitId);
document.dispatchEvent(new Event("units-loaded"));
}
export async function saveUnit(event) {

View File

@ -51,7 +51,20 @@ body {
.status {
font-size: 12px;
color: var(--text-3);
display: flex;
align-items: center;
gap: 5px;
}
.ws-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-3);
flex-shrink: 0;
transition: background 0.3s;
}
.ws-dot.connected { background: #22c55e; }
.ws-dot.disconnected { background: #ef4444; }
.topbar-actions {
display: flex;
@ -59,6 +72,30 @@ body {
gap: 10px;
}
/* ── Tabs ───────────────────────────────────────── */
.tab-bar {
display: flex;
gap: 2px;
}
.tab-btn {
padding: 0 16px;
height: 28px;
font-size: 13px;
font-weight: 500;
background: transparent;
border: 1px solid var(--border);
color: var(--text-2);
cursor: pointer;
}
.tab-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.link-button {
display: inline-flex;
align-items: center;
@ -79,19 +116,33 @@ body {
/* ── Grid Layout ────────────────────────────────── */
.grid {
.grid-ops,
.grid-config {
display: grid;
grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr);
grid-template-rows: 1fr 380px;
gap: 1px;
height: calc(100vh - var(--topbar-h));
}
.panel.top-left { grid-column: 1; grid-row: 1; }
.panel.top-right { grid-column: 2 / 4; grid-row: 1; }
.panel.bottom-left { grid-column: 1; grid-row: 2; min-height: 0; }
.panel.bottom-middle { grid-column: 2; grid-row: 2; min-height: 0; }
.panel.bottom-right { grid-column: 3; grid-row: 2; min-height: 0; }
.grid-config {
grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr);
grid-template-rows: 1fr 380px;
}
.grid-ops {
grid-template-columns: 260px minmax(0, 1fr);
grid-template-rows: 1fr 260px;
}
/* config view slot assignments */
.grid-config .panel.top-left { grid-column: 1; grid-row: 1; }
.grid-config .panel.top-right { grid-column: 2 / 4; grid-row: 1; }
.grid-config .panel.bottom-left { grid-column: 1; grid-row: 2; }
.grid-config .panel.bottom-mid { grid-column: 2; grid-row: 2; }
.grid-config .panel.bottom-right{ grid-column: 3; grid-row: 2; }
/* ops view slot assignments */
.grid-ops .panel.ops-main { grid-column: 1 / 3; grid-row: 1; }
.grid-ops .panel.ops-bottom { grid-column: 1 / 3; grid-row: 2; }
.panel {
background: var(--surface);
@ -119,6 +170,150 @@ body {
border-top: 1px solid var(--border-light);
}
/* ── Ops View ───────────────────────────────────── */
.ops-layout {
display: flex;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
}
.ops-unit-sidebar {
width: 260px;
flex-shrink: 0;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.ops-unit-list {
flex: 1 1 auto;
overflow-y: auto;
}
.ops-equipment-area {
flex: 1 1 auto;
overflow: auto;
padding: 12px;
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 12px;
}
.ops-placeholder {
padding: 20px;
}
/* Equipment ops card */
.ops-eq-card {
width: 220px;
border: 1px solid var(--border);
background: var(--surface);
display: flex;
flex-direction: column;
gap: 0;
}
.ops-eq-card-head {
padding: 8px 10px 6px;
border-bottom: 1px solid var(--border-light);
display: flex;
align-items: center;
gap: 6px;
}
.ops-eq-card-head strong {
flex: 1;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops-signal-rows {
padding: 6px 10px;
display: flex;
flex-direction: column;
gap: 3px;
}
.ops-signal-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.ops-signal-label {
width: 36px;
color: var(--text-3);
font-size: 11px;
text-transform: uppercase;
flex-shrink: 0;
}
.ops-signal-value {
flex: 1;
font-weight: 500;
}
.ops-eq-card-actions {
padding: 6px 10px 8px;
display: flex;
gap: 6px;
border-top: 1px solid var(--border-light);
}
.ops-eq-card-actions button {
flex: 1;
padding: 3px 0;
font-size: 12px;
}
/* ops unit list item */
.ops-unit-item {
padding: 8px 10px;
cursor: pointer;
border-bottom: 1px solid var(--border-light);
display: flex;
flex-direction: column;
gap: 3px;
}
.ops-unit-item:hover { background: var(--accent-bg); }
.ops-unit-item.selected {
background: var(--accent-bg);
border-left: 3px solid var(--accent);
}
.ops-unit-item-name {
font-size: 13px;
font-weight: 600;
}
.ops-unit-item-meta {
font-size: 11px;
color: var(--text-3);
display: flex;
align-items: center;
gap: 6px;
}
.ops-unit-item-actions {
display: flex;
gap: 4px;
padding-top: 4px;
}
.ops-unit-item-actions button {
padding: 2px 8px;
font-size: 11px;
}
/* ── Panel Header ───────────────────────────────── */
.panel-head {
@ -128,6 +323,16 @@ body {
padding: 7px 12px;
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
flex-wrap: wrap;
gap: 6px;
}
.ops-batch-actions {
display: flex;
gap: 4px;
}
.ops-batch-actions button {
font-size: 11px;
padding: 2px 8px;
}
h2, h3 {
@ -585,6 +790,7 @@ button.danger:hover { background: var(--danger-hover); }
backdrop-filter: blur(2px);
}
.hidden { display: none !important; }
.modal.hidden { display: none; }
.modal-content {
@ -660,8 +866,7 @@ button.danger:hover { background: var(--danger-hover); }
max-height: 50vh;
}
.unit-list,
.event-list {
.unit-list {
padding-top: 6px;
}
@ -670,20 +875,38 @@ button.danger:hover { background: var(--danger-hover); }
}
.event-card {
cursor: default;
padding: 4px 8px;
font-size: 12px;
border-bottom: 1px solid var(--border);
}
.event-card:hover {
background: var(--surface);
border-color: var(--border);
background: var(--surface-hover, var(--surface));
}
.event-section {
flex-basis: 42%;
.event-meta {
display: flex;
align-items: baseline;
gap: 6px;
}
.log-section {
flex-basis: 58%;
.event-type {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-time {
flex-shrink: 0;
font-size: 11px;
}
.event-message {
color: var(--text-muted, #888);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.equipment-select-row {
@ -745,6 +968,10 @@ button.danger:hover { background: var(--danger-hover); }
grid-template-columns: 220px minmax(0, 1fr);
}
.api-drawer {
width: min(1100px, 96vw);
}
.equipment-drawer {
width: min(1120px, 96vw);
}
@ -1000,6 +1227,71 @@ button.danger:hover { background: var(--danger-hover); }
color: var(--text);
}
/* ── Toast ────────────────────────────────────────── */
#toast-container {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column-reverse;
gap: 8px;
z-index: 9999;
pointer-events: none;
}
.toast {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 240px;
max-width: 380px;
padding: 10px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--text-3);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
font-size: 13px;
color: var(--text);
pointer-events: auto;
animation: toast-in 0.15s ease;
cursor: pointer;
}
.toast.error { border-left-color: var(--danger); }
.toast.warning { border-left-color: var(--warning); }
.toast.success { border-left-color: var(--success); }
.toast-icon {
flex-shrink: 0;
font-size: 14px;
line-height: 1.5;
}
.toast-body { flex: 1; word-break: break-word; }
.toast-title { font-weight: 600; margin-bottom: 2px; }
.toast-title:only-child { margin-bottom: 0; }
.toast-message { color: var(--text-2); font-size: 12px; }
.toast.hiding { animation: toast-out 0.15s ease forwards; }
.toast.shake { animation: toast-shake 0.4s ease; }
@keyframes toast-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(8px); }
}
@keyframes toast-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-5px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
/* ── Scrollbar ────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; }
@ -1010,7 +1302,8 @@ button.danger:hover { background: var(--danger-hover); }
/* ── Responsive ───────────────────────────────────── */
@media (max-width: 900px) {
.grid {
.grid-config,
.grid-ops {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto auto;
height: auto;
@ -1018,9 +1311,9 @@ button.danger:hover { background: var(--danger-hover); }
body { height: auto; overflow: auto; }
.panel.top-left { min-height: 200px; }
.panel.top-right { min-height: 300px; }
.panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; }
.panel.bottom-middle { grid-column: 1; grid-row: 4; min-height: 200px; }
.panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; }
.grid-config .panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; }
.grid-config .panel.bottom-mid { grid-column: 1; grid-row: 4; min-height: 200px; }
.grid-config .panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; }
.drawer { width: 100vw; }
.drawer-body { grid-template-columns: 1fr; }
.equipment-layout { grid-template-columns: 1fr; }
@ -1029,3 +1322,5 @@ button.danger:hover { background: var(--danger-hover); }
.doc-view { padding: 0; }
.doc-card { border-left: none; border-right: none; }
}