Compare commits
No commits in common. "13c4b515d7233e7551e3579da005849ba12e35cf" and "1f29eb3871effada35480f8124751280d8d4d6d1" have entirely different histories.
13c4b515d7
...
1f29eb3871
712
API.md
712
API.md
|
|
@ -1,6 +1,6 @@
|
||||||
# PLC Control 接口说明
|
# PLC Control 接口说明
|
||||||
|
|
||||||
本文档基于当前服务端路由与处理器代码整理,覆盖 HTTP API 和 WebSocket 实时消息。
|
本文档基于当前服务端路由与处理器代码整理,覆盖 HTTP API、SSE 日志流和 WebSocket 实时消息。
|
||||||
|
|
||||||
## 基本信息
|
## 基本信息
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
常见状态码:
|
常见状态码:
|
||||||
|
|
||||||
- `400 Bad Request`:参数错误
|
- `400 Bad Request`:参数错误
|
||||||
- `403 Forbidden`:写入权限不足或控制条件不满足
|
- `403 Forbidden`:写入权限不足
|
||||||
- `404 Not Found`:资源不存在
|
- `404 Not Found`:资源不存在
|
||||||
- `500 Internal Server Error`:服务端内部错误
|
- `500 Internal Server Error`:服务端内部错误
|
||||||
|
|
||||||
|
|
@ -74,12 +74,16 @@
|
||||||
响应:
|
响应:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "id": "uuid" }
|
{
|
||||||
|
"id": "uuid"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### PUT `/api/source/{source_id}`
|
### PUT `/api/source/{source_id}`
|
||||||
|
|
||||||
更新数据源。请求体字段均可选:
|
更新数据源。
|
||||||
|
|
||||||
|
请求体字段均可选:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -93,29 +97,63 @@
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok_msg": "Source updated successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### DELETE `/api/source/{source_id}`
|
### DELETE `/api/source/{source_id}`
|
||||||
|
|
||||||
删除数据源。成功响应:`204 No Content`
|
删除数据源。
|
||||||
|
|
||||||
|
成功响应:`204 No Content`
|
||||||
|
|
||||||
### POST `/api/source/{source_id}/reconnect`
|
### POST `/api/source/{source_id}/reconnect`
|
||||||
|
|
||||||
手动重连指定数据源。
|
手动重连指定数据源。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "ok_msg": "Source reconnected successfully" }
|
{
|
||||||
|
"ok_msg": "Source reconnected successfully"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST `/api/source/{source_id}/browse`
|
### POST `/api/source/{source_id}/browse`
|
||||||
|
|
||||||
从 OPC UA 源浏览节点并写入本地 `node` 表。
|
从 OPC UA 源浏览节点并写入本地 `node` 表。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "ok_msg": "Browse completed", "total_nodes": 123 }
|
{
|
||||||
|
"ok_msg": "Browse completed",
|
||||||
|
"total_nodes": 123
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET `/api/source/{source_id}/node-tree`
|
### GET `/api/source/{source_id}/node-tree`
|
||||||
|
|
||||||
获取指定数据源的节点树(含 `children` 递归嵌套)。
|
获取指定数据源的节点树。
|
||||||
|
|
||||||
|
响应字段:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `source_id`
|
||||||
|
- `external_id`
|
||||||
|
- `namespace_uri`
|
||||||
|
- `namespace_index`
|
||||||
|
- `identifier_type`
|
||||||
|
- `identifier`
|
||||||
|
- `browse_name`
|
||||||
|
- `display_name`
|
||||||
|
- `node_class`
|
||||||
|
- `parent_id`
|
||||||
|
- `children`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -123,14 +161,13 @@
|
||||||
|
|
||||||
### GET `/api/point`
|
### GET `/api/point`
|
||||||
|
|
||||||
分页获取点位列表,同时返回实时监测值。
|
分页获取点位列表。
|
||||||
|
|
||||||
查询参数:
|
查询参数:
|
||||||
|
|
||||||
- `source_id`:可选,按数据源过滤
|
- `source_id`:可选,按数据源过滤
|
||||||
- `equipment_id`:可选,按设备过滤
|
|
||||||
- `page`:页码
|
- `page`:页码
|
||||||
- `page_size`:每页条数(`-1` 表示全量)
|
- `page_size`:每页条数
|
||||||
|
|
||||||
响应示例:
|
响应示例:
|
||||||
|
|
||||||
|
|
@ -138,22 +175,28 @@
|
||||||
{
|
{
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"point": {
|
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"node_id": "uuid",
|
"node_id": "uuid",
|
||||||
"name": "Temperature",
|
"name": "Temperature",
|
||||||
"equipment_id": "uuid",
|
"description": null,
|
||||||
"signal_role": "run",
|
"unit": null,
|
||||||
|
"tag_id": null,
|
||||||
"created_at": "2026-03-20 10:00:00.000",
|
"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": {
|
"point_monitor": {
|
||||||
|
"protocol": "opcua",
|
||||||
|
"source_id": "uuid",
|
||||||
"point_id": "uuid",
|
"point_id": "uuid",
|
||||||
|
"client_handle": 1001,
|
||||||
|
"scan_mode": "subscribe",
|
||||||
"timestamp": "2026-03-20 10:05:00.000",
|
"timestamp": "2026-03-20 10:05:00.000",
|
||||||
"quality": "good",
|
"quality": "good",
|
||||||
"value": 12.3,
|
"value": 12.3,
|
||||||
"value_type": "float",
|
"value_type": "float",
|
||||||
"value_text": "12.3"
|
"value_text": "12.3",
|
||||||
|
"old_value": 12.1,
|
||||||
|
"old_timestamp": "2026-03-20 10:04:59.000",
|
||||||
|
"value_changed": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -169,9 +212,13 @@
|
||||||
|
|
||||||
### GET `/api/point/{point_id}/history`
|
### GET `/api/point/{point_id}/history`
|
||||||
|
|
||||||
获取点位最近历史样本(进程内存环形缓冲,重启后清空)。
|
获取点位最近历史样本。数据来自进程内存中的环形缓冲,不是持久化历史库。
|
||||||
|
|
||||||
查询参数:`limit`(可选,默认 `120`,最大 `1000`)
|
查询参数:
|
||||||
|
|
||||||
|
- `limit`:可选,默认 `120`,最大 `1000`
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
|
|
@ -185,17 +232,23 @@
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `value_number` 便于前端直接绘图
|
||||||
|
- 非数值型点位时,`value_number` 可能为 `null`
|
||||||
|
|
||||||
### PUT `/api/point/{point_id}`
|
### PUT `/api/point/{point_id}`
|
||||||
|
|
||||||
更新点位元数据,字段均可选:
|
更新点位元数据。
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "Temperature",
|
"name": "Temperature",
|
||||||
"description": "Room temperature",
|
"description": "Room temperature",
|
||||||
"unit": "°C",
|
"unit": "°C",
|
||||||
"equipment_id": "uuid",
|
"tag_id": "uuid"
|
||||||
"signal_role": "run"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -203,12 +256,24 @@
|
||||||
|
|
||||||
删除单个点位。
|
删除单个点位。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok_msg": "Point deleted successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### POST `/api/point/batch`
|
### POST `/api/point/batch`
|
||||||
|
|
||||||
根据节点批量创建点位。
|
根据节点批量创建点位。
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "node_ids": ["uuid1", "uuid2"] }
|
{
|
||||||
|
"node_ids": ["uuid1", "uuid2"]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
响应:
|
响应:
|
||||||
|
|
@ -226,25 +291,27 @@
|
||||||
|
|
||||||
批量删除点位。
|
批量删除点位。
|
||||||
|
|
||||||
```json
|
请求体:
|
||||||
{ "point_ids": ["uuid1", "uuid2"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
### PUT `/api/point/batch/set-equipment`
|
|
||||||
|
|
||||||
批量设置点位的设备绑定和信号角色。
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"point_ids": ["uuid1", "uuid2"],
|
"point_ids": ["uuid1", "uuid2"]
|
||||||
"equipment_id": "uuid",
|
}
|
||||||
"signal_role": "run"
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deleted_count": 2
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### PUT `/api/point/batch/set-tags`
|
### PUT `/api/point/batch/set-tags`
|
||||||
|
|
||||||
批量设置点位的标签(`tag_id`,传 `null` 可清除绑定)。
|
批量设置点位标签。
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -253,213 +320,40 @@
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
响应:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "ok_msg": "Point tags updated successfully", "updated_count": 2 }
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST `/api/point/value/batch`
|
### POST `/api/point/value/batch`
|
||||||
|
|
||||||
批量写点。
|
批量写点。
|
||||||
|
|
||||||
请求头:`X-Write-Key: <key>`
|
请求头:
|
||||||
|
|
||||||
|
- `X-Write-Key: <key>`
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"items": [
|
"items": [
|
||||||
{ "point_id": "uuid", "value": 12.3 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Equipment
|
|
||||||
|
|
||||||
### GET `/api/equipment`
|
|
||||||
|
|
||||||
分页获取设备列表,包含每台设备绑定的点位数量。
|
|
||||||
|
|
||||||
查询参数:`page`、`page_size`(`-1` 全量)、`keyword`(可选,按 code/name 模糊搜索)
|
|
||||||
|
|
||||||
响应示例:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"point_id": "uuid",
|
||||||
"unit_id": "uuid",
|
"value": 12.3
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"err_msg": null,
|
||||||
|
"success_count": 1,
|
||||||
|
"failed_count": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"point_id": "uuid",
|
||||||
|
"success": true,
|
||||||
|
"err_msg": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -467,359 +361,183 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Event(系统事件)
|
## Tag
|
||||||
|
|
||||||
### 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`
|
### GET `/api/tag`
|
||||||
|
|
||||||
分页获取标签列表。
|
分页获取标签列表。
|
||||||
|
|
||||||
查询参数:`page`、`page_size`
|
查询参数:
|
||||||
|
|
||||||
响应示例:
|
- `page`
|
||||||
|
- `page_size`
|
||||||
|
|
||||||
```json
|
### GET `/api/tag/{tag_id}`
|
||||||
{
|
|
||||||
"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`
|
### POST `/api/tag`
|
||||||
|
|
||||||
创建标签,可同时绑定点位。
|
创建标签。
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "主蒸汽",
|
"name": "Area-A",
|
||||||
"description": null,
|
"description": "Area A points",
|
||||||
"point_ids": ["uuid1", "uuid2"]
|
"point_ids": ["uuid1", "uuid2"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
响应:`201 Created`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "id": "uuid", "ok_msg": "Tag created successfully" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET `/api/tag/{tag_id}`
|
|
||||||
|
|
||||||
获取标签下所有绑定点位。
|
|
||||||
|
|
||||||
响应:点位对象数组。
|
|
||||||
|
|
||||||
### PUT `/api/tag/{tag_id}`
|
### PUT `/api/tag/{tag_id}`
|
||||||
|
|
||||||
更新标签,字段均可选(`point_ids` 传入时全量替换绑定关系):
|
更新标签。
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "主蒸汽",
|
"name": "Area-A",
|
||||||
"description": "描述",
|
"description": "Updated",
|
||||||
"point_ids": ["uuid1"]
|
"point_ids": ["uuid1", "uuid2"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### DELETE `/api/tag/{tag_id}`
|
### DELETE `/api/tag/{tag_id}`
|
||||||
|
|
||||||
删除标签。成功响应:`204 No Content`
|
删除标签。
|
||||||
|
|
||||||
|
成功响应:`204 No Content`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Page(自定义页面)
|
## Page
|
||||||
|
|
||||||
|
`page` 用于保存页面布局或组件映射数据。
|
||||||
|
|
||||||
### GET `/api/page`
|
### GET `/api/page`
|
||||||
|
|
||||||
获取所有页面,按 `created_at` 排序。
|
查询页面列表。
|
||||||
|
|
||||||
查询参数:`name`(可选,模糊搜索)
|
查询参数:
|
||||||
|
|
||||||
响应:Page 对象数组。
|
- `name`:可选,按名称模糊搜索
|
||||||
|
|
||||||
```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": "总览",
|
|
||||||
"data": { "slot_a": "uuid1", "slot_b": "uuid2" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
响应:`201 Created`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "id": "uuid", "ok_msg": "Page created successfully" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET `/api/page/{page_id}`
|
### GET `/api/page/{page_id}`
|
||||||
|
|
||||||
获取单个页面。
|
获取单个页面。
|
||||||
|
|
||||||
### PUT `/api/page/{page_id}`
|
### POST `/api/page`
|
||||||
|
|
||||||
更新页面,字段均可选:
|
创建页面。
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "总览",
|
"name": "Dashboard",
|
||||||
"data": { "slot_a": "uuid1" }
|
"data": {
|
||||||
|
"widgetA": "uuid1",
|
||||||
|
"widgetB": "uuid2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PUT `/api/page/{page_id}`
|
||||||
|
|
||||||
|
更新页面。
|
||||||
|
|
||||||
|
请求体字段均可选:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Dashboard",
|
||||||
|
"data": {
|
||||||
|
"widgetA": "uuid1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### DELETE `/api/page/{page_id}`
|
### DELETE `/api/page/{page_id}`
|
||||||
|
|
||||||
删除页面。成功响应:`204 No Content`
|
删除页面。
|
||||||
|
|
||||||
|
成功响应:`204 No Content`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Log(运行日志)
|
## Log
|
||||||
|
|
||||||
### GET `/api/logs`
|
### GET `/api/logs`
|
||||||
|
|
||||||
读取服务端日志文件内容(默认取最新 `app.log*` 文件尾部 200 行)。
|
读取日志文件内容。
|
||||||
|
|
||||||
查询参数:
|
查询参数:
|
||||||
|
|
||||||
- `file`:可选,指定文件名(仅允许 `app.log` 前缀)
|
- `file`:可选,指定日志文件名,仅允许 `app.log*`
|
||||||
- `cursor`:可选,上次返回的字节偏移量;传入时增量读取 cursor 之后的内容
|
- `cursor`:可选,从指定游标后读取
|
||||||
- `tail_lines`:可选,不传 cursor 时返回的尾部行数(默认 200,最大 2000)
|
- `tail_lines`:可选,默认 `200`
|
||||||
- `max_bytes`:可选,单次最多返回字节数(默认 64 KB,最大 512 KB)
|
- `max_bytes`:可选
|
||||||
|
|
||||||
响应示例:
|
响应:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"file": "app.log",
|
"file": "app.log",
|
||||||
"cursor": 204800,
|
"cursor": 1024,
|
||||||
"lines": ["2026-03-25 10:00:00 INFO ..."],
|
"lines": ["..."],
|
||||||
"truncated": false,
|
"truncated": false,
|
||||||
"reset": false
|
"reset": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `truncated`:`true` 表示本次未读完,可用新 cursor 继续请求
|
|
||||||
- `reset`:`true` 表示文件已轮转(cursor > 文件大小),已从头读取
|
|
||||||
|
|
||||||
### GET `/api/logs/stream`
|
### GET `/api/logs/stream`
|
||||||
|
|
||||||
以 **SSE**(Server-Sent Events)流式推送日志增量,每 800 ms 检查一次文件变化。
|
SSE 实时日志流。
|
||||||
|
|
||||||
查询参数:`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
|
## WebSocket
|
||||||
|
|
||||||
### 连接地址
|
## 连接地址
|
||||||
|
|
||||||
- 公共广播:`/ws/public`
|
- 公共广播:`/ws/public`
|
||||||
- 客户端专属:`/ws/client/{client_id}`
|
- 客户端专属:`/ws/client/{client_id}`
|
||||||
|
|
||||||
### 服务端主动推送消息
|
## 服务端主动消息
|
||||||
|
|
||||||
#### `PointNewValue`
|
### `PointNewValue`
|
||||||
|
|
||||||
点位实时值更新:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "PointNewValue",
|
"type": "PointNewValue",
|
||||||
"data": {
|
"data": {
|
||||||
|
"protocol": "opcua",
|
||||||
|
"source_id": "uuid",
|
||||||
"point_id": "uuid",
|
"point_id": "uuid",
|
||||||
|
"client_handle": 1001,
|
||||||
|
"scan_mode": "subscribe",
|
||||||
"timestamp": "2026-03-20 10:05:00.000",
|
"timestamp": "2026-03-20 10:05:00.000",
|
||||||
"quality": "good",
|
"quality": "good",
|
||||||
"value": 12.3,
|
"value": 12.3,
|
||||||
"value_type": "float",
|
"value_type": "float",
|
||||||
"value_text": "12.3"
|
"value_text": "12.3",
|
||||||
|
"old_value": 12.1,
|
||||||
|
"old_timestamp": "2026-03-20 10:04:59.000",
|
||||||
|
"value_changed": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `EventCreated`
|
### `PointSetValueBatchResult`
|
||||||
|
|
||||||
系统事件创建(控制操作、故障、状态变更等):
|
|
||||||
|
|
||||||
```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
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -834,25 +552,30 @@ data: log stream read failed
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 客户端发送消息
|
## 客户端发送消息
|
||||||
|
|
||||||
#### 写权限认证
|
### 写权限认证
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "auth_write",
|
"type": "auth_write",
|
||||||
"data": { "key": "your-write-key" }
|
"data": {
|
||||||
|
"key": "your-write-key"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 批量写点
|
### 批量写点
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "point_set_value_batch",
|
"type": "point_set_value_batch",
|
||||||
"data": {
|
"data": {
|
||||||
"items": [
|
"items": [
|
||||||
{ "point_id": "uuid", "value": 12.3 }
|
{
|
||||||
|
"point_id": "uuid",
|
||||||
|
"value": 12.3
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -862,7 +585,6 @@ data: log stream read failed
|
||||||
|
|
||||||
## 备注
|
## 备注
|
||||||
|
|
||||||
- 运行时状态(`/runtime`)存储在内存中,服务重启后重置。
|
- 历史曲线接口当前使用内存缓存,服务重启后历史会清空。
|
||||||
- 历史曲线数据(`/history`)同样是内存环形缓冲,重启后清空。
|
- 实时遥测与 WebSocket 推送是“最新值优先”的设计,在高压场景下允许丢弃部分中间消息。
|
||||||
- 控制单元时间配置字段(`run_time_sec` 等)单位为秒,运行时 elapsed 字段单位为毫秒。
|
- `/api/tag/{tag_id}` 当前返回的是标签下点位,而不是标签自身详情。
|
||||||
- 自动控制启动后,状态机以 500ms 为周期运行,实时状态通过 WebSocket `UnitRuntimeChanged` 推送。
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
# 投煤器布料机远程监控与控制功能实现方案
|
# 投煤器布料机远程监控与控制功能实现方案
|
||||||
|
|
||||||
> 最后更新:2026-03-24(基于当前代码重新审阅)
|
|
||||||
|
|
||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
|
|
@ -13,424 +11,551 @@
|
||||||
- 支持故障锁定、人工复位、通讯异常冻结
|
- 支持故障锁定、人工复位、通讯异常冻结
|
||||||
- 支持通过配置适配不同现场,不改代码完成项目复用
|
- 支持通过配置适配不同现场,不改代码完成项目复用
|
||||||
|
|
||||||
---
|
## 2. 现有系统能力盘点
|
||||||
|
|
||||||
## 2. 当前系统能力盘点
|
当前项目已经具备较好的通用工业采集平台基础:
|
||||||
|
|
||||||
### 2.1 通用平台基础(已有)
|
- OPC UA 数据源接入与自动重连
|
||||||
|
- 节点浏览、批量建点、点位实时订阅
|
||||||
- OPC UA 数据源接入与自动重连(`connection.rs`)
|
- 点位批量写入能力
|
||||||
- 节点浏览、批量建点、点位实时订阅(`handler/point.rs`, `handler/source.rs`)
|
- 设备 `equipment` 模型
|
||||||
- 点位批量写入能力(`connection.rs: write_point_values_batch`)
|
- 点位到设备绑定 `equipment_id`
|
||||||
- WebSocket 实时推送(`websocket.rs`)
|
- 点位信号角色字段 `signal_role`
|
||||||
|
- WebSocket 实时推送
|
||||||
- 前端设备、点位、日志、趋势图基础界面
|
- 前端设备、点位、日志、趋势图基础界面
|
||||||
- 页面配置 `page` 能力
|
- 页面配置 `page` 能力
|
||||||
- 进程内事件总线 `event.rs`(control + telemetry 双通道)
|
- 进程内事件总线 `event.rs`
|
||||||
- HTTP 中间件、鉴权、分页等工具链
|
|
||||||
|
|
||||||
### 2.2 业务模型(已有)
|
现状更接近“通用点位监控平台”,还不是“投煤器/布料机业务控制系统”。
|
||||||
|
|
||||||
- `unit` 表(`migrations/20260324090000_add_unit_and_event.sql`)
|
## 3. 与需求的差距
|
||||||
- 包含 `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 业务逻辑(已有)
|
需求文档要求的软件能力,当前尚未落地的核心部分如下:
|
||||||
|
|
||||||
- Unit CRUD 接口:`GET/POST/PUT/DELETE /api/unit`, `GET /api/unit/{id}`
|
- 缺少“控制单元”概念,无法表达一组投煤器对应一组布料机
|
||||||
- 设备手动控制接口:`POST /api/control/equipment/{id}/start|stop`
|
- 缺少业务配置模型,无法配置 `RunTime`、`StopTime`、`AccTime`、`BLTime`
|
||||||
- 内置脉冲写入(写 1 → 等 300ms → 写 0)
|
- 缺少设备业务类型约束,尚未明确区分投煤器、布料机
|
||||||
- 前置校验:`rem == 1`, `flt == 0`, 通讯质量 good(`control/validator.rs`)
|
- 缺少设备归属单元字段,尚未形成 `unit -> equipment -> point` 的业务链路
|
||||||
- `AppEvent::EquipmentStartCommandSent/EquipmentStopCommandSent`,自动持久化 `event` 表并推送 WebSocket
|
- 缺少业务信号角色规范,尚未标准化 `REM/RUN/FLT/STA/STP`
|
||||||
- 内存运行态结构体(`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. 推荐实现思路
|
## 4. 推荐实现思路
|
||||||
|
|
||||||
继续在现有平台上"增量扩展":
|
推荐在现有平台上“增量扩展”,而不是重写:
|
||||||
|
|
||||||
- 现有 `unit` / `equipment` / `point` / `event` 底座不动
|
- 保留现有 `source/node/point/equipment` 通用底座
|
||||||
- 填充 `control/engine.rs` 实现状态机调度
|
- 新增面向业务控制的配置表和运行态管理
|
||||||
- 新增自动控制和 ack-fault 接口
|
- 将控制逻辑放在 Rust 服务端,复用当前 OPC UA 连接和写点能力
|
||||||
- `validator.rs` 增加对运行态的检查
|
- 前端增加业务页面,不破坏现有通用点位管理页面
|
||||||
- 扩展 `AppEvent` 业务事件,通过 WebSocket 推送运行态变更
|
- 基于现有 `event.rs` 扩展事件体系,而不是再造一套事件机制
|
||||||
- 前端增加业务控制页面
|
|
||||||
|
|
||||||
---
|
这样做的好处是:
|
||||||
|
|
||||||
## 5. 命名与模型设计(已落地,确认)
|
- 现有 OPC UA、点位、设备、WebSocket 基础都能继续复用
|
||||||
|
- 后续不同现场只需要换设备映射和参数
|
||||||
|
- 手动调试仍然可以通过现有点位/设备页面完成
|
||||||
|
- 未来做报警、审计、统计时可以直接复用统一事件体系
|
||||||
|
|
||||||
### 5.1 设备分类(`equipment.kind`)
|
## 5. 命名与模型设计
|
||||||
|
|
||||||
|
### 5.1 命名原则
|
||||||
|
|
||||||
|
建议区分“代码命名”和“表命名”:
|
||||||
|
|
||||||
|
- 代码模型名保留语义完整性,如 `ControlUnit`
|
||||||
|
- 数据库表名尽量简洁,如 `unit`
|
||||||
|
|
||||||
|
不建议使用 `group` 作为表名,原因是语义过泛,后续容易与权限分组、界面分组、标签分组等概念冲突。
|
||||||
|
|
||||||
|
### 5.2 设备分类
|
||||||
|
|
||||||
|
继续复用 `equipment` 表中的 `kind` 字段,约定:
|
||||||
|
|
||||||
- `coal_feeder`:投煤器
|
- `coal_feeder`:投煤器
|
||||||
- `distributor`:布料机
|
- `distributor`:布料机
|
||||||
|
|
||||||
### 5.2 点位角色规范(`point.signal_role`)
|
### 5.3 点位角色规范
|
||||||
|
|
||||||
|
继续复用 `point.signal_role`,建议统一枚举值:
|
||||||
|
|
||||||
- 状态点:`rem` `run` `flt` `ii` `q`
|
- 状态点:`rem` `run` `flt` `ii` `q`
|
||||||
- 控制点:`start_cmd` `stop_cmd`
|
- 控制点:`start_cmd` `stop_cmd`
|
||||||
- 可选扩展:`estop` `mode_auto` `mode_manual` `reset_cmd`
|
- 可选扩展:`estop` `mode_auto` `mode_manual` `reset_cmd`
|
||||||
|
|
||||||
### 5.3 Unit 运行态放内存(已确认)
|
这样每台设备都可以通过“设备 + 点位角色”完成映射,而不是在代码里写死点名。
|
||||||
|
|
||||||
运行态字段已在 `control/runtime.rs:UnitRuntime` 中定义完整,不落库。
|
### 5.4 新增 `unit` 表
|
||||||
服务重启后重新从 `REM/RUN/FLT/Q` 重建运行态,不自动补发命令。
|
|
||||||
|
|
||||||
### 5.4 统一 `event` 表,预留 `alarm` 表(已落地)
|
建议新增 `unit` 表,对应代码模型 `ControlUnit`,表示一个业务控制单元。
|
||||||
|
|
||||||
- `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. 控制逻辑设计
|
||||||
|
|
||||||
### 6.1 手动控制(已实现,待补充运行态检查)
|
### 6.1 手动控制
|
||||||
|
|
||||||
当前已实现:
|
手动控制前置校验:
|
||||||
- `rem == 1` 检查
|
|
||||||
- `flt == 0` 检查
|
|
||||||
- 通讯质量检查
|
|
||||||
- 脉冲写入(300ms)
|
|
||||||
|
|
||||||
**待补充**:在 `validator.rs` 中增加对 `ControlRuntimeStore` 的检查:
|
- `REM == 1`
|
||||||
- `fault_locked == true` → 拒绝
|
- `FLT == 0`
|
||||||
- `comm_locked == true` → 拒绝
|
- 通讯正常
|
||||||
- `manual_ack_required == true` → 拒绝(等待人工确认)
|
- 未处于故障锁定
|
||||||
|
- 如有急停点,`ESTOP == 0`
|
||||||
|
|
||||||
### 6.2 自动控制状态机(待实现)
|
控制命令统一走脉冲写入:
|
||||||
|
|
||||||
每个 `unit` 独立维护状态机,在 `control/engine.rs` 中以后台任务驱动。
|
1. 写入 `1`
|
||||||
|
2. 延时 `200-500ms`
|
||||||
|
3. 写回 `0`
|
||||||
|
|
||||||
|
服务端需要封装 `pulse_write(point_id, high_ms)`,前端不能直接拼两次写点。
|
||||||
|
|
||||||
|
### 6.2 自动控制状态机
|
||||||
|
|
||||||
|
每个 `unit` 独立维护状态机。
|
||||||
|
|
||||||
#### `STOPPED`
|
#### `STOPPED`
|
||||||
|
|
||||||
- 累计停止时间 `current_stop_elapsed_sec`
|
- 累计停止时间
|
||||||
- 若 `stop_elapsed >= StopTime` → 校验投煤器 REM/FLT/质量 → 发启动命令 → 切换到 `RUNNING`
|
- 若 `stop_elapsed >= StopTime`,尝试启动投煤器
|
||||||
|
- 成功后切换到 `RUNNING`
|
||||||
|
|
||||||
#### `RUNNING`
|
#### `RUNNING`
|
||||||
|
|
||||||
- 累计运行时间 `current_run_elapsed_sec`
|
- 累计运行时间
|
||||||
- `accumulated_run_sec += delta`
|
- `accumulated_run_sec += delta`
|
||||||
- 若 `run_elapsed >= RunTime` → 停止投煤器 → 切换回 `STOPPED`
|
- 若 `run_elapsed >= RunTime`,尝试停止投煤器,切换回 `STOPPED`
|
||||||
- 若 `accumulated_run_sec >= AccTime` → 进入 `DISTRIBUTOR_RUNNING`
|
- 若 `accumulated_run_sec >= AccTime`,进入布料机触发流程
|
||||||
|
|
||||||
#### `DISTRIBUTOR_RUNNING`
|
#### `DISTRIBUTOR_RUNNING`
|
||||||
|
|
||||||
- 校验布料机 REM/FLT/质量
|
- 校验布料机 `REM/FLT/通讯`
|
||||||
- 启动布料机,等待 `RUN == 1`
|
- 启动布料机
|
||||||
|
- 等待 `RUN == 1` 反馈
|
||||||
- 计时 `BLTime`
|
- 计时 `BLTime`
|
||||||
- 停止布料机
|
- 停止布料机
|
||||||
- 清零 `accumulated_run_sec`
|
- 累计时间清零
|
||||||
- 切回 `STOPPED` 或自动节拍起点
|
- 回到 `STOPPED` 或回到自动节拍起点
|
||||||
|
|
||||||
### 6.3 故障机制(待实现)
|
### 6.3 故障机制
|
||||||
|
|
||||||
任意设备检测到 `FLT == 1`:
|
任意设备检测到 `FLT == 1`:
|
||||||
|
|
||||||
- 停止该单元自动控制
|
- 停止该单元自动控制
|
||||||
- `state = FaultLocked`, `fault_locked = true`
|
- 标记 `fault_locked = true`
|
||||||
- 发送并持久化 `FaultLocked` 事件
|
- 禁止再次自动发命令
|
||||||
|
- 发送并持久化关键事件
|
||||||
|
|
||||||
|
当 `FLT` 从 `1 -> 0` 恢复:
|
||||||
|
|
||||||
当 `FLT` 从 `1 → 0` 恢复:
|
|
||||||
- 不自动解锁
|
- 不自动解锁
|
||||||
- `manual_ack_required = true`
|
- 标记 `manual_ack_required = true`
|
||||||
- 等待人工调用 `POST /api/control/unit/{id}/ack-fault`
|
- 等待人工在界面点击“解除故障锁定”
|
||||||
|
|
||||||
### 6.4 通讯异常机制(待实现)
|
### 6.4 通讯异常机制
|
||||||
|
|
||||||
当 OPC UA 质量位异常或连接中断:
|
当质量位异常或 OPC 连接中断:
|
||||||
- `state = CommLocked`, `comm_locked = true`
|
|
||||||
|
- 标记 `comm_locked = true`
|
||||||
- 冻结全部控制动作
|
- 冻结全部控制动作
|
||||||
- 前端按钮灰化
|
- 前端按钮灰化
|
||||||
|
- 不允许任何自动/手动写入
|
||||||
|
|
||||||
通讯恢复后:
|
通讯恢复后:
|
||||||
|
|
||||||
- 重新读取 `REM/RUN/FLT`
|
- 重新读取 `REM/RUN/FLT`
|
||||||
- 重同步运行态
|
- 重同步运行态
|
||||||
- 不自动补发控制命令
|
- 不自动补发控制命令
|
||||||
- 持久化恢复事件
|
- 发送并持久化恢复事件
|
||||||
- 等待人工操作或下一次自动触发
|
- 等待人工操作或下一次自动触发
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 事件体系设计
|
## 7. 事件体系设计
|
||||||
|
|
||||||
### 7.1 继续复用 `src/event.rs`
|
### 7.1 继续复用 `src/event.rs`
|
||||||
|
|
||||||
当前 `AppEvent` 已有:
|
建议不要另起一套业务事件中心,而是在现有 [src/event.rs](D:/projects/plc_control/src/event.rs) 上扩展。
|
||||||
- `SourceCreate/Update/Delete`
|
|
||||||
- `PointCreateBatch/PointDeleteBatch`
|
|
||||||
- `EquipmentStartCommandSent/EquipmentStopCommandSent`
|
|
||||||
- `PointNewValue`(遥测)
|
|
||||||
|
|
||||||
**待扩展**:
|
当前它已经承担两类职责:
|
||||||
|
|
||||||
```rust
|
- 控制类内部事件分发
|
||||||
AutoControlStarted { unit_id }
|
- 遥测类高频事件分发
|
||||||
AutoControlStopped { unit_id }
|
|
||||||
FaultLocked { unit_id, equipment_id }
|
推荐继续保留这个结构:
|
||||||
FaultAcked { unit_id }
|
|
||||||
CommLocked { unit_id }
|
- `AppEvent` 作为统一进程内事件枚举
|
||||||
CommRecovered { unit_id }
|
- 高频遥测事件继续走内存和 WebSocket
|
||||||
UnitStateChanged { unit_id, from_state, to_state }
|
- 低频且有审计价值的事件选择性落库到 `event` 表
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 哪些事件适合落库
|
### 7.2 哪些事件适合落库
|
||||||
|
|
||||||
- 适合:所有手动/自动启停、故障、通讯、参数变更、状态切换
|
适合落库的:
|
||||||
- 不适合:`PointNewValue`、高频遥测、内部轮询过程
|
|
||||||
|
|
||||||
---
|
- `SourceCreate`
|
||||||
|
- `SourceUpdate`
|
||||||
|
- `SourceDelete`
|
||||||
|
- 自动控制启动/停止
|
||||||
|
- 手动启动/停止命令发送
|
||||||
|
- 故障锁定
|
||||||
|
- 人工确认恢复
|
||||||
|
- 通讯异常/恢复
|
||||||
|
- 参数配置变更
|
||||||
|
- 单元状态切换
|
||||||
|
|
||||||
|
不适合直接落库的:
|
||||||
|
|
||||||
|
- `PointNewValue`
|
||||||
|
- 高频实时遥测
|
||||||
|
- 细碎的内部轮询过程
|
||||||
|
|
||||||
|
### 7.3 推荐扩展方向
|
||||||
|
|
||||||
|
建议在 `AppEvent` 中逐步增加业务事件,例如:
|
||||||
|
|
||||||
|
- `AutoControlStarted`
|
||||||
|
- `AutoControlStopped`
|
||||||
|
- `EquipmentStartCommandSent`
|
||||||
|
- `EquipmentStopCommandSent`
|
||||||
|
- `FaultLocked`
|
||||||
|
- `FaultAcked`
|
||||||
|
- `CommLocked`
|
||||||
|
- `CommRecovered`
|
||||||
|
- `UnitStateChanged`
|
||||||
|
|
||||||
|
这样后续无论是写日志、落库、推送 WebSocket、做报警触发,都可以基于同一个事件入口。
|
||||||
|
|
||||||
## 8. 后端改造方案
|
## 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 完整 |
|
|
||||||
|
|
||||||
### 8.2 待新增接口
|
- `src/handler/control.rs`
|
||||||
|
- `src/service/control.rs`
|
||||||
|
- `src/control/engine.rs`
|
||||||
|
- `src/control/runtime.rs`
|
||||||
|
- `src/control/validator.rs`
|
||||||
|
|
||||||
```
|
职责划分:
|
||||||
POST /api/control/unit/{id}/start-auto 启动自动控制
|
|
||||||
POST /api/control/unit/{id}/stop-auto 停止自动控制
|
- `handler`:HTTP 接口
|
||||||
POST /api/control/unit/{id}/ack-fault 人工确认故障解锁
|
- `service`:数据库读写
|
||||||
GET /api/unit/{id}/runtime 查询运行态(state, elapsed, fault_locked 等)
|
- `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`
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
- `start/stop-auto` 修改运行态 `auto_enabled`,引擎轮询时读取
|
|
||||||
- `ack-fault` 仅在 `manual_ack_required == true` 时允许操作,否则返回 400
|
|
||||||
|
|
||||||
### 8.3 控制引擎运行方式(待实现)
|
- 设备手动控制必须走业务接口,不建议继续直接暴露给页面做原始点位写入
|
||||||
|
- 原 `/api/point/value/batch` 保留给调试或底层工具能力
|
||||||
|
- 事件查询接口可以直接面向统一 `event` 表
|
||||||
|
|
||||||
在 `control/engine.rs: start()` 中实现后台任务:
|
### 8.3 控制引擎运行方式
|
||||||
|
|
||||||
```
|
建议服务启动后增加一个后台任务:
|
||||||
每 500ms 扫描所有 enabled unit
|
|
||||||
↓
|
|
||||||
从 ControlRuntimeStore 读取运行态
|
|
||||||
↓
|
|
||||||
从 connection_manager.get_point_monitor_data_read_guard() 取实时点值
|
|
||||||
↓
|
|
||||||
检查质量位 → 更新 comm_locked
|
|
||||||
↓
|
|
||||||
检查 FLT → 更新 fault_locked
|
|
||||||
↓
|
|
||||||
驱动状态机 tick
|
|
||||||
↓
|
|
||||||
有状态变化 → 更新 ControlRuntimeStore → 发 AppEvent → 推 WebSocket
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.4 手动控制补充运行态检查
|
- 每 `500ms` 或 `1s` 扫描所有启用的 `unit`
|
||||||
|
- 从内存读取运行态缓存
|
||||||
|
- 从当前点位监控数据中取 `REM/RUN/FLT/Q`
|
||||||
|
- 驱动状态机执行
|
||||||
|
|
||||||
在 `control/validator.rs: validate_manual_control()` 中增加:
|
控制引擎不要直接查 OPC,应复用当前 `connection_manager` 已维护的实时点值。
|
||||||
|
|
||||||
```rust
|
### 8.4 关键复用点
|
||||||
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. 前端改造方案
|
||||||
|
|
||||||
### 9.1 已有页面(确认现状)
|
建议在现有通用页面之外新增业务页面,避免混杂。
|
||||||
|
|
||||||
| 页面/功能 | 状态 |
|
### 9.1 新增页面
|
||||||
|-----------|------|
|
|
||||||
| Unit 列表(含 CRUD Modal) | ✅ 基础实现 |
|
|
||||||
| 事件列表 | ✅ 基础实现 |
|
|
||||||
| 设备列表(含 Unit 归属绑定) | ✅ 实现 |
|
|
||||||
| 点位绑定(equipment/signal_role) | ✅ 实现 |
|
|
||||||
| 趋势图 | ✅ 实现 |
|
|
||||||
|
|
||||||
### 9.2 待新增页面/功能
|
- 单元总览页
|
||||||
|
- 单元详情页
|
||||||
|
- 设备控制面板
|
||||||
|
- 事件记录页
|
||||||
|
- 报警页
|
||||||
|
- 参数配置页
|
||||||
|
|
||||||
**设备控制面板**(单台投煤器/布料机):
|
### 9.2 单元总览页内容
|
||||||
- REM / RUN / FLT / Q / II 实时显示
|
|
||||||
- 启动 / 停止按钮(灰化逻辑:comm_locked / fault_locked / manual_ack_required)
|
每个 `unit` 展示:
|
||||||
|
|
||||||
|
- 单元名称
|
||||||
|
- 自动/手动状态
|
||||||
|
- 当前状态机状态
|
||||||
|
- 投煤器运行状态
|
||||||
|
- 布料机运行状态
|
||||||
|
- 当前累计运行时间
|
||||||
|
- 故障锁定状态
|
||||||
|
- 通讯状态
|
||||||
|
|
||||||
|
并提供按钮:
|
||||||
|
|
||||||
|
- 启动自动
|
||||||
|
- 停止自动
|
||||||
|
- 故障确认/解除锁定
|
||||||
|
|
||||||
|
### 9.3 设备控制页内容
|
||||||
|
|
||||||
|
针对单台投煤器/布料机提供:
|
||||||
|
|
||||||
|
- REM/RUN/FLT/Q/II 实时显示
|
||||||
|
- 启动按钮
|
||||||
|
- 停止按钮
|
||||||
- 通讯异常、故障锁定提示
|
- 通讯异常、故障锁定提示
|
||||||
- 最近控制事件
|
- 最近事件
|
||||||
|
|
||||||
**单元总览**(Unit 卡片增强):
|
### 9.4 趋势、事件与报警
|
||||||
- 当前状态机状态(STOPPED / RUNNING / DISTRIBUTOR_RUNNING / FAULT_LOCKED / COMM_LOCKED)
|
|
||||||
- 自动/手动切换按钮
|
|
||||||
- 故障确认按钮(`manual_ack_required == true` 时显示)
|
|
||||||
- 累计运行时间进度
|
|
||||||
- 投煤器 + 布料机运行状态摘要
|
|
||||||
|
|
||||||
**参数在线编辑**:
|
复用已有趋势图能力:
|
||||||
- 现有 Unit Modal 已有表单
|
|
||||||
- 需补充:保存后通知引擎重新加载(或引擎每次 tick 从 DB 读取配置)
|
|
||||||
|
|
||||||
**WebSocket 运行态更新**:
|
- 电流 `II` 趋势
|
||||||
- 新增 `WsMessage::UnitRuntimeChanged { unit_id, runtime }` 消息类型
|
- 运行状态变化曲线
|
||||||
- 前端收到后实时更新 Unit 卡片状态,无需轮询
|
- 事件时间线
|
||||||
|
|
||||||
---
|
后续报警页面基于独立 `alarm` 表实现:
|
||||||
|
|
||||||
|
- 当前活动报警
|
||||||
|
- 已确认报警
|
||||||
|
- 已恢复报警
|
||||||
|
- 报警确认操作
|
||||||
|
|
||||||
## 10. 分阶段实施建议
|
## 10. 分阶段实施建议
|
||||||
|
|
||||||
### 第一阶段:补全控制闭环(当前阶段)
|
### 第一阶段:最小可用版
|
||||||
|
|
||||||
**目标**:让自动控制可以跑起来,故障/通讯保护机制生效。
|
目标:先让系统具备业务闭环,但不追求复杂页面。
|
||||||
|
|
||||||
待完成工作:
|
内容:
|
||||||
|
|
||||||
1. **补充运行态检查到 `validator.rs`**
|
- 新增 `unit` 表
|
||||||
- 手动控制时检查 `fault_locked`, `comm_locked`, `manual_ack_required`
|
- 为 `equipment` 增加 `unit_id`
|
||||||
|
- 约定设备 `kind` 和点位 `signal_role`
|
||||||
|
- 新增手动控制接口
|
||||||
|
- 实现脉冲写入
|
||||||
|
- 实现故障锁定与通讯冻结
|
||||||
|
- 实现自动控制状态机
|
||||||
|
- 基于 `event.rs` 落统一 `event` 表
|
||||||
|
- 前端增加一个“控制单元”面板和事件列表
|
||||||
|
|
||||||
2. **实现 `control/engine.rs`**
|
交付后即可验证:
|
||||||
- 后台 500ms 轮询任务
|
|
||||||
- 质量检查 → `comm_locked` 更新
|
|
||||||
- FLT 检测 → `fault_locked` 更新
|
|
||||||
- 状态机 tick(STOPPED / 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 卡片增强**
|
|
||||||
- 状态机状态展示、自动/手动切换、故障确认
|
|
||||||
|
|
||||||
交付后可验证:
|
|
||||||
- 单台设备手动启停(含故障/通讯拦截)
|
|
||||||
- 单元自动定时启停
|
|
||||||
- 累计触发布料机运行
|
- 累计触发布料机运行
|
||||||
- 故障恢复后人工确认才能操作
|
- 故障恢复后人工确认
|
||||||
- 通讯异常冻结后恢复自动同步
|
- 关键操作和状态切换可追溯
|
||||||
|
|
||||||
### 第二阶段:增强版
|
### 第二阶段:增强版
|
||||||
|
|
||||||
- 单元详情页(运行趋势、事件时间线、参数在线编辑)
|
内容:
|
||||||
- 更丰富的趋势图(电流 II、运行状态变化曲线)
|
|
||||||
|
- 单元总览页
|
||||||
|
- 单元详情页
|
||||||
|
- 参数在线编辑
|
||||||
|
- 更丰富的趋势图
|
||||||
|
- WebSocket 业务事件推送
|
||||||
- 报警规则与 `alarm` 表
|
- 报警规则与 `alarm` 表
|
||||||
- 报警确认与恢复流程
|
- 报警确认与恢复流程
|
||||||
|
|
||||||
### 第三阶段:现场适配版
|
### 第三阶段:现场适配版
|
||||||
|
|
||||||
|
内容:
|
||||||
|
|
||||||
- 导入导出配置
|
- 导入导出配置
|
||||||
- 项目模板
|
- 项目模板
|
||||||
- 配置校验工具
|
- 配置校验工具
|
||||||
- 启停联锁自检
|
- 启停联锁自检
|
||||||
- 操作权限控制
|
- 操作权限控制
|
||||||
|
|
||||||
---
|
## 11. 建议优先落地顺序
|
||||||
|
|
||||||
## 11. 当前优先落地顺序
|
从当前代码基础出发,建议按下面顺序开发:
|
||||||
|
|
||||||
从当前代码基础出发,第一阶段建议按下面顺序开发:
|
1. 补齐业务数据模型和数据库迁移
|
||||||
|
2. 新增 `unit` 表并为 `equipment` 增加 `unit_id`
|
||||||
1. `validator.rs` 补充运行态检查
|
3. 规范 `equipment.kind` 与 `point.signal_role`
|
||||||
2. `engine.rs` 实现质量检查与 `comm_locked` 更新
|
4. 实现服务端脉冲写入能力
|
||||||
3. `engine.rs` 实现 FLT 检测与 `fault_locked` 更新
|
5. 实现手动控制接口
|
||||||
4. `engine.rs` 实现状态机主循环
|
6. 实现 `unit` 自动控制状态机
|
||||||
5. `handler/control.rs` 新增 `start-auto`, `stop-auto`, `ack-fault`
|
7. 扩展 `event.rs` 并实现统一 `event` 表持久化
|
||||||
6. `handler/control.rs` 新增 `GET /api/unit/{id}/runtime`
|
8. 实现故障锁定、通讯冻结、人工确认
|
||||||
7. 扩展 `AppEvent` 业务事件类型并落库
|
9. 增加前端业务页面
|
||||||
8. `websocket.rs` 新增 `UnitRuntimeChanged` 消息
|
10. 第二阶段再引入独立 `alarm` 表
|
||||||
9. 前端设备控制面板
|
|
||||||
10. 前端 Unit 卡片增强
|
|
||||||
11. 第二阶段再引入独立 `alarm` 表
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 对当前代码的具体落点
|
## 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)
|
||||||
- `src/model.rs`(数据模型完整)
|
- 增加 `ControlUnit` 模型
|
||||||
- `src/control/runtime.rs`(运行态结构体完整)
|
- 为 `Equipment` 增加 `unit_id`
|
||||||
- 所有迁移文件(schema 已完整)
|
- 后续增加 `EventRecord`、`AlarmRecord` 模型
|
||||||
- `src/handler/point.rs`(保留底层写点,不承载业务控制)
|
- [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)
|
||||||
|
- 增加控制单元页面事件绑定
|
||||||
|
|
||||||
## 13. 本次结论
|
## 13. 本次结论
|
||||||
|
|
||||||
当前项目已经完成了业务底座的建设(数据模型、脉冲写入、手动控制、事件持久化、运行态数据结构),具备了较好的基础。
|
当前项目不需要推倒重来,可以直接演进成投煤器与布料机远程监控控制系统。
|
||||||
|
|
||||||
下一步核心工作集中在:
|
最合理的路径是:
|
||||||
|
|
||||||
1. **填充 `control/engine.rs`**——这是最核心的缺口,所有自动控制、故障保护、通讯冻结都需要它来驱动
|
- 以现有 OPC UA 与点位平台为底座
|
||||||
2. **前端业务控制面板**——让操作员看到并操作设备状态
|
- 数据库使用简洁表名 `unit`、`event`
|
||||||
|
- 代码层保留语义化命名,如 `ControlUnit`、`AppEvent`
|
||||||
|
- 在 `equipment` 上直接增加 `unit_id`
|
||||||
|
- 在服务端以内存运行态实现状态机和脉冲控制
|
||||||
|
- 在现有 [src/event.rs](D:/projects/plc_control/src/event.rs) 上扩展统一事件体系
|
||||||
|
- 第一阶段先做统一 `event`,第二阶段再拆分独立 `alarm`
|
||||||
|
|
||||||
其余部分(接口、事件、WebSocket)属于连接层,工作量相对可控。
|
如果进入下一步开发,建议先做“第一阶段最小可用版”。
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ pub struct AppConfig {
|
||||||
pub server_host: String,
|
pub server_host: String,
|
||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
pub write_api_key: Option<String>,
|
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,16 +22,11 @@ impl AppConfig {
|
||||||
.ok()
|
.ok()
|
||||||
.or_else(|| env::var("WRITE_KEY").ok());
|
.or_else(|| env::var("WRITE_KEY").ok());
|
||||||
|
|
||||||
let simulate_plc = env::var("SIMULATE_PLC")
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_lowercase() == "true";
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
database_url,
|
database_url,
|
||||||
server_host,
|
server_host,
|
||||||
server_port,
|
server_port,
|
||||||
write_api_key,
|
write_api_key,
|
||||||
simulate_plc,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ impl ConnectionManager {
|
||||||
let manager = self.clone();
|
let manager = self.clone();
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
let mut ticker = tokio::time::interval(Duration::from_secs(8)); // 每8秒检测一次心跳
|
let mut ticker = tokio::time::interval(Duration::from_secs(4)); // 每4秒检测一次心跳
|
||||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -1153,8 +1153,8 @@ impl ConnectionManager {
|
||||||
match session
|
match session
|
||||||
.create_subscription(
|
.create_subscription(
|
||||||
Duration::from_secs(1),
|
Duration::from_secs(1),
|
||||||
15,
|
10,
|
||||||
5,
|
30,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
true,
|
true,
|
||||||
|
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
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) },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,431 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
pub mod command;
|
|
||||||
pub mod engine;
|
|
||||||
pub mod runtime;
|
|
||||||
pub mod validator;
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
173
src/event.rs
173
src/event.rs
|
|
@ -24,23 +24,6 @@ pub enum AppEvent {
|
||||||
source_id: Uuid,
|
source_id: Uuid,
|
||||||
point_ids: Vec<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),
|
PointNewValue(crate::telemetry::PointNewValue),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,11 +45,9 @@ impl EventManager {
|
||||||
|
|
||||||
let control_cm = connection_manager.clone();
|
let control_cm = connection_manager.clone();
|
||||||
let control_pool = pool.clone();
|
let control_pool = pool.clone();
|
||||||
let control_ws_manager = ws_manager.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(event) = control_receiver.recv().await {
|
while let Some(event) = control_receiver.recv().await {
|
||||||
handle_control_event(event, &control_pool, &control_cm, control_ws_manager.as_ref())
|
handle_control_event(event, &control_pool, &control_cm).await;
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -142,9 +123,8 @@ async fn handle_control_event(
|
||||||
event: AppEvent,
|
event: AppEvent,
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
connection_manager: &std::sync::Arc<crate::connection::ConnectionManager>,
|
connection_manager: &std::sync::Arc<crate::connection::ConnectionManager>,
|
||||||
ws_manager: Option<&std::sync::Arc<crate::websocket::WebSocketManager>>,
|
|
||||||
) {
|
) {
|
||||||
persist_event_if_needed(&event, pool, ws_manager).await;
|
persist_event_if_needed(&event, pool).await;
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
AppEvent::SourceCreate { source_id } => {
|
AppEvent::SourceCreate { source_id } => {
|
||||||
|
|
@ -202,62 +182,13 @@ async fn handle_control_event(
|
||||||
tracing::error!("Failed to unsubscribe points: {}", e);
|
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(_) => {
|
AppEvent::PointNewValue(_) => {
|
||||||
tracing::warn!("PointNewValue routed to control worker unexpectedly");
|
tracing::warn!("PointNewValue routed to control worker unexpectedly");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_event_if_needed(
|
async fn persist_event_if_needed(event: &AppEvent, pool: &sqlx::PgPool) {
|
||||||
event: &AppEvent,
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
ws_manager: Option<&std::sync::Arc<crate::websocket::WebSocketManager>>,
|
|
||||||
) {
|
|
||||||
let record = match event {
|
let record = match event {
|
||||||
AppEvent::SourceCreate { source_id } => Some((
|
AppEvent::SourceCreate { source_id } => Some((
|
||||||
"source.created",
|
"source.created",
|
||||||
|
|
@ -282,7 +213,7 @@ async fn persist_event_if_needed(
|
||||||
"warn",
|
"warn",
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
Some(*source_id),
|
||||||
format!("Source {} deleted", source_id),
|
format!("Source {} deleted", source_id),
|
||||||
serde_json::json!({ "source_id": source_id }),
|
serde_json::json!({ "source_id": source_id }),
|
||||||
)),
|
)),
|
||||||
|
|
@ -304,82 +235,6 @@ async fn persist_event_if_needed(
|
||||||
format!("{} points deleted for source {}", point_ids.len(), source_id),
|
format!("{} points deleted for source {}", point_ids.len(), source_id),
|
||||||
serde_json::json!({ "source_id": source_id, "point_ids": point_ids }),
|
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,
|
AppEvent::PointNewValue(_) => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -387,11 +242,10 @@ async fn persist_event_if_needed(
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let inserted = sqlx::query_as::<_, crate::model::EventRecord>(
|
if let Err(err) = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO event (event_type, level, unit_id, equipment_id, source_id, message, payload)
|
INSERT INTO event (event_type, level, unit_id, equipment_id, source_id, message, payload)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING *
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(event_type)
|
.bind(event_type)
|
||||||
|
|
@ -401,22 +255,11 @@ async fn persist_event_if_needed(
|
||||||
.bind(source_id)
|
.bind(source_id)
|
||||||
.bind(message)
|
.bind(message)
|
||||||
.bind(sqlx::types::Json(payload))
|
.bind(sqlx::types::Json(payload))
|
||||||
.fetch_one(pool)
|
.execute(pool)
|
||||||
.await;
|
.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);
|
tracing::warn!("Failed to persist event: {}", err);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn process_point_new_value(
|
async fn process_point_new_value(
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,10 @@ use axum::{
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
control::validator::{validate_manual_control, ControlAction},
|
|
||||||
util::{
|
util::{
|
||||||
pagination::{PaginatedResponse, PaginationParams},
|
pagination::{PaginatedResponse, PaginationParams},
|
||||||
response::ApiErr,
|
response::ApiErr,
|
||||||
|
|
@ -49,71 +47,6 @@ 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(
|
pub async fn get_unit(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(unit_id): Path<Uuid>,
|
Path(unit_id): Path<Uuid>,
|
||||||
|
|
@ -124,62 +57,6 @@ 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)]
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
pub struct CreateUnitReq {
|
pub struct CreateUnitReq {
|
||||||
#[validate(length(min = 1, max = 100))]
|
#[validate(length(min = 1, max = 100))]
|
||||||
|
|
@ -369,140 +246,3 @@ pub async fn get_event_list(
|
||||||
query.pagination.page_size,
|
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))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ use axum::{
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_with::rust::double_option;
|
|
||||||
use sqlx::{QueryBuilder, Row};
|
use sqlx::{QueryBuilder, Row};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -146,16 +145,11 @@ pub async fn get_point_history(
|
||||||
#[derive(Deserialize, Validate)]
|
#[derive(Deserialize, Validate)]
|
||||||
pub struct UpdatePointReq {
|
pub struct UpdatePointReq {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
#[serde(default, with = "double_option")]
|
pub description: Option<String>,
|
||||||
pub description: Option<Option<String>>,
|
pub unit: Option<String>,
|
||||||
#[serde(default, with = "double_option")]
|
pub tag_id: Option<Uuid>,
|
||||||
pub unit: Option<Option<String>>,
|
pub equipment_id: Option<Uuid>,
|
||||||
#[serde(default, with = "double_option")]
|
pub signal_role: Option<String>,
|
||||||
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.
|
/// Request payload for batch setting point tags.
|
||||||
|
|
@ -193,7 +187,7 @@ pub async fn update_point(
|
||||||
}
|
}
|
||||||
|
|
||||||
// If tag_id is provided, ensure tag exists.
|
// If tag_id is provided, ensure tag exists.
|
||||||
if let Some(Some(tag_id)) = payload.tag_id {
|
if let Some(tag_id) = payload.tag_id {
|
||||||
let tag_exists = sqlx::query(r#"SELECT 1 FROM tag WHERE id = $1"#)
|
let tag_exists = sqlx::query(r#"SELECT 1 FROM tag WHERE id = $1"#)
|
||||||
.bind(tag_id)
|
.bind(tag_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
@ -205,7 +199,7 @@ pub async fn update_point(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(Some(equipment_id)) = payload.equipment_id {
|
if let Some(equipment_id) = payload.equipment_id {
|
||||||
let equipment_exists = sqlx::query(r#"SELECT 1 FROM equipment WHERE id = $1"#)
|
let equipment_exists = sqlx::query(r#"SELECT 1 FROM equipment WHERE id = $1"#)
|
||||||
.bind(equipment_id)
|
.bind(equipment_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|
@ -226,56 +220,29 @@ pub async fn update_point(
|
||||||
return Err(ApiErr::NotFound("Point not found".to_string(), None));
|
return Err(ApiErr::NotFound("Point not found".to_string(), None));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new("UPDATE point SET ");
|
let mut qb = QueryBuilder::new("UPDATE point SET ");
|
||||||
let mut wrote_field = false;
|
let mut sep = qb.separated(", ");
|
||||||
|
|
||||||
if let Some(name) = &payload.name {
|
if let Some(name) = &payload.name {
|
||||||
if wrote_field {
|
sep.push("name = ").push_bind(name);
|
||||||
qb.push(", ");
|
|
||||||
}
|
|
||||||
qb.push("name = ").push_bind(name);
|
|
||||||
wrote_field = true;
|
|
||||||
}
|
}
|
||||||
if let Some(description) = &payload.description {
|
if let Some(description) = &payload.description {
|
||||||
if wrote_field {
|
sep.push("description = ").push_bind(description);
|
||||||
qb.push(", ");
|
|
||||||
}
|
|
||||||
qb.push("description = ").push_bind(description.as_deref());
|
|
||||||
wrote_field = true;
|
|
||||||
}
|
}
|
||||||
if let Some(unit) = &payload.unit {
|
if let Some(unit) = &payload.unit {
|
||||||
if wrote_field {
|
sep.push("unit = ").push_bind(unit);
|
||||||
qb.push(", ");
|
|
||||||
}
|
|
||||||
qb.push("unit = ").push_bind(unit.as_deref());
|
|
||||||
wrote_field = true;
|
|
||||||
}
|
}
|
||||||
if let Some(tag_id) = &payload.tag_id {
|
if let Some(tag_id) = &payload.tag_id {
|
||||||
if wrote_field {
|
sep.push("tag_id = ").push_bind(tag_id);
|
||||||
qb.push(", ");
|
|
||||||
}
|
|
||||||
qb.push("tag_id = ").push_bind(tag_id.as_ref());
|
|
||||||
wrote_field = true;
|
|
||||||
}
|
}
|
||||||
if let Some(equipment_id) = &payload.equipment_id {
|
if let Some(equipment_id) = &payload.equipment_id {
|
||||||
if wrote_field {
|
sep.push("equipment_id = ").push_bind(equipment_id);
|
||||||
qb.push(", ");
|
|
||||||
}
|
|
||||||
qb.push("equipment_id = ").push_bind(equipment_id.as_ref());
|
|
||||||
wrote_field = true;
|
|
||||||
}
|
}
|
||||||
if let Some(signal_role) = &payload.signal_role {
|
if let Some(signal_role) = &payload.signal_role {
|
||||||
if wrote_field {
|
sep.push("signal_role = ").push_bind(signal_role);
|
||||||
qb.push(", ");
|
|
||||||
}
|
|
||||||
qb.push("signal_role = ").push_bind(signal_role.as_deref());
|
|
||||||
wrote_field = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if wrote_field {
|
sep.push("updated_at = NOW()");
|
||||||
qb.push(", ");
|
|
||||||
}
|
|
||||||
qb.push("updated_at = NOW()");
|
|
||||||
|
|
||||||
qb.push(" WHERE id = ").push_bind(point_id);
|
qb.push(" WHERE id = ").push_bind(point_id);
|
||||||
qb.build().execute(pool).await?;
|
qb.build().execute(pool).await?;
|
||||||
|
|
|
||||||
59
src/main.rs
59
src/main.rs
|
|
@ -1,4 +1,3 @@
|
||||||
mod control;
|
|
||||||
mod config;
|
mod config;
|
||||||
mod connection;
|
mod connection;
|
||||||
mod db;
|
mod db;
|
||||||
|
|
@ -11,7 +10,7 @@ mod telemetry;
|
||||||
mod util;
|
mod util;
|
||||||
mod websocket;
|
mod websocket;
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{get, post, put},
|
routing::{get, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
|
|
@ -21,19 +20,9 @@ use event::EventManager;
|
||||||
use middleware::simple_logger;
|
use middleware::simple_logger;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use axum::{extract::Request, middleware::Next, response::Response};
|
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
use tower_http::services::ServeDir;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: AppConfig,
|
pub config: AppConfig,
|
||||||
|
|
@ -41,7 +30,6 @@ pub struct AppState {
|
||||||
pub connection_manager: Arc<ConnectionManager>,
|
pub connection_manager: Arc<ConnectionManager>,
|
||||||
pub event_manager: Arc<EventManager>,
|
pub event_manager: Arc<EventManager>,
|
||||||
pub ws_manager: Arc<websocket::WebSocketManager>,
|
pub ws_manager: Arc<websocket::WebSocketManager>,
|
||||||
pub control_runtime: Arc<control::runtime::ControlRuntimeStore>,
|
|
||||||
}
|
}
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
|
@ -64,7 +52,6 @@ async fn main() {
|
||||||
connection_manager.set_pool_and_start_reconnect_task(Arc::new(pool.clone()));
|
connection_manager.set_pool_and_start_reconnect_task(Arc::new(pool.clone()));
|
||||||
|
|
||||||
let connection_manager = Arc::new(connection_manager);
|
let connection_manager = Arc::new(connection_manager);
|
||||||
let control_runtime = Arc::new(control::runtime::ControlRuntimeStore::new());
|
|
||||||
|
|
||||||
// Connect to all enabled sources concurrently
|
// Connect to all enabled sources concurrently
|
||||||
let sources = service::get_all_enabled_sources(&pool)
|
let sources = service::get_all_enabled_sources(&pool)
|
||||||
|
|
@ -101,9 +88,7 @@ async fn main() {
|
||||||
connection_manager: connection_manager.clone(),
|
connection_manager: connection_manager.clone(),
|
||||||
event_manager,
|
event_manager,
|
||||||
ws_manager,
|
ws_manager,
|
||||||
control_runtime: control_runtime.clone(),
|
|
||||||
};
|
};
|
||||||
control::engine::start(state.clone(), control_runtime);
|
|
||||||
let app = build_router(state.clone());
|
let app = build_router(state.clone());
|
||||||
let addr = format!("{}:{}", config.server_host, config.server_port);
|
let addr = format!("{}:{}", config.server_host, config.server_port);
|
||||||
tracing::info!("Starting server at http://{}", addr);
|
tracing::info!("Starting server at http://{}", addr);
|
||||||
|
|
@ -219,42 +204,6 @@ fn build_router(state: AppState) -> Router {
|
||||||
"/api/event",
|
"/api/event",
|
||||||
get(handler::control::get_event_list),
|
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(
|
.route(
|
||||||
"/api/tag",
|
"/api/tag",
|
||||||
get(handler::tag::get_tag_list).post(handler::tag::create_tag),
|
get(handler::tag::get_tag_list).post(handler::tag::create_tag),
|
||||||
|
|
@ -281,11 +230,9 @@ fn build_router(state: AppState) -> Router {
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(all_route)
|
.merge(all_route)
|
||||||
.nest(
|
.nest_service(
|
||||||
"/ui",
|
"/ui",
|
||||||
Router::new()
|
ServeDir::new("web").append_index_html_on_directories(true),
|
||||||
.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("/ws/public", get(websocket::public_websocket_handler))
|
||||||
.route(
|
.route(
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ pub struct Node {
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct Point {
|
pub struct Point {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
use crate::model::{ControlUnit, EventRecord};
|
use crate::model::{ControlUnit, EventRecord};
|
||||||
use sqlx::{PgPool, QueryBuilder, Row};
|
use sqlx::{PgPool, QueryBuilder};
|
||||||
use uuid::Uuid;
|
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> {
|
pub async fn get_units_count(pool: &PgPool, keyword: Option<&str>) -> Result<i64, sqlx::Error> {
|
||||||
match keyword {
|
match keyword {
|
||||||
Some(keyword) => {
|
Some(keyword) => {
|
||||||
|
|
@ -307,67 +301,3 @@ pub async fn get_events_paginated(
|
||||||
|
|
||||||
qb.build_query_as::<EventRecord>().fetch_all(pool).await
|
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())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,6 @@ use uuid::Uuid;
|
||||||
pub enum WsMessage {
|
pub enum WsMessage {
|
||||||
PointNewValue(crate::telemetry::PointMonitorInfo),
|
PointNewValue(crate::telemetry::PointMonitorInfo),
|
||||||
PointSetValueBatchResult(crate::connection::BatchSetPointValueRes),
|
PointSetValueBatchResult(crate::connection::BatchSetPointValueRes),
|
||||||
EventCreated(crate::model::EventRecord),
|
|
||||||
UnitRuntimeChanged(crate::control::runtime::UnitRuntime),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div class="drawer-backdrop hidden" id="apiDocDrawer">
|
<div class="drawer-backdrop hidden" id="apiDocDrawer">
|
||||||
<aside class="drawer api-drawer" role="dialog" aria-modal="true" aria-labelledby="apiDocTitle">
|
<aside class="drawer" role="dialog" aria-modal="true" aria-labelledby="apiDocTitle">
|
||||||
<div class="drawer-head">
|
<div class="drawer-head">
|
||||||
<h3 id="apiDocTitle">API.md</h3>
|
<h3 id="apiDocTitle">API.md</h3>
|
||||||
<button type="button" class="secondary" id="closeApiDoc">关闭</button>
|
<button type="button" class="secondary" id="closeApiDoc">关闭</button>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<section class="panel bottom-mid">
|
|
||||||
<div class="panel-head">
|
|
||||||
<h2>实时日志</h2>
|
|
||||||
</div>
|
|
||||||
<div class="log" id="logView"></div>
|
|
||||||
</section>
|
|
||||||
|
|
@ -1,7 +1,18 @@
|
||||||
<section class="panel ops-bottom">
|
<section class="panel bottom-middle">
|
||||||
|
<div class="stack-panel">
|
||||||
|
<div class="stack-section event-section">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>系统事件</h2>
|
<h2>系统事件</h2>
|
||||||
<button type="button" class="secondary" id="refreshEventBtn">刷新</button>
|
<button type="button" class="secondary" id="refreshEventBtn">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="list event-list" id="eventList"></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>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
类型
|
类型
|
||||||
<select id="equipmentKind"></select>
|
<input id="equipmentKind" placeholder="coal_feeder / distributor" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
说明
|
说明
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,15 +1,8 @@
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="title">PLC Control</div>
|
<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">
|
<div class="topbar-actions">
|
||||||
<button type="button" class="secondary" id="clearEquipmentFilter">设备筛选: 全部</button>
|
<button type="button" class="secondary" id="clearEquipmentFilter">设备筛选: 全部</button>
|
||||||
<button type="button" class="secondary" id="openApiDoc">API.md</button>
|
<button type="button" class="secondary" id="openApiDoc">API.md</button>
|
||||||
<div class="status" id="statusText">
|
<div class="status" id="statusText">Ready</div>
|
||||||
<span class="ws-dot" id="wsDot"></span>
|
|
||||||
<span id="wsLabel">连接中…</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,15 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>PLC Control</title>
|
<title>PLC Control</title>
|
||||||
<link rel="stylesheet" href="/ui/styles.css?v=20260325f" />
|
<link rel="stylesheet" href="/ui/styles.css?v=20260323e" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div data-partial="/ui/html/topbar.html"></div>
|
<div data-partial="/ui/html/topbar.html"></div>
|
||||||
|
|
||||||
<main class="grid-ops">
|
<main class="grid">
|
||||||
<div data-partial="/ui/html/ops-panel.html"></div>
|
|
||||||
<div data-partial="/ui/html/equipment-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/points-panel.html"></div>
|
||||||
<div data-partial="/ui/html/source-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/logs-panel.html"></div>
|
||||||
<div data-partial="/ui/html/chart-panel.html"></div>
|
<div data-partial="/ui/html/chart-panel.html"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -22,6 +20,6 @@
|
||||||
<div data-partial="/ui/html/modals.html"></div>
|
<div data-partial="/ui/html/modals.html"></div>
|
||||||
<div data-partial="/ui/html/api-doc-drawer.html"></div>
|
<div data-partial="/ui/html/api-doc-drawer.html"></div>
|
||||||
|
|
||||||
<script type="module" src="/ui/js/index.js?v=20260325f"></script>
|
<script type="module" src="/ui/js/index.js?v=20260323f"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -4,58 +4,6 @@ export function setStatus(text) {
|
||||||
dom.statusText.textContent = 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 = {}) {
|
export async function apiFetch(url, options = {}) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -63,9 +11,7 @@ export async function apiFetch(url, options = {}) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = (await response.text()) || response.statusText;
|
throw new Error((await response.text()) || response.statusText);
|
||||||
showToast(`请求失败 ${response.status}`, { message: text });
|
|
||||||
throw new Error(text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,7 @@ import {
|
||||||
resetEquipmentForm,
|
resetEquipmentForm,
|
||||||
saveEquipment,
|
saveEquipment,
|
||||||
} from "./equipment.js";
|
} from "./equipment.js";
|
||||||
import { startPointSocket, startLogs, stopLogs } from "./logs.js";
|
import { startLogs, startPointSocket } from "./logs.js";
|
||||||
import { startOps, renderOpsUnits, loadAllEquipmentCards } from "./ops.js";
|
|
||||||
import {
|
import {
|
||||||
clearBatchBinding,
|
clearBatchBinding,
|
||||||
browseAndLoadTree,
|
browseAndLoadTree,
|
||||||
|
|
@ -35,36 +34,6 @@ import { state } from "./state.js";
|
||||||
import { loadSources, saveSource } from "./sources.js";
|
import { loadSources, saveSource } from "./sources.js";
|
||||||
import { closeUnitModal, loadUnits, openCreateUnitModal, resetUnitForm, renderUnits, saveUnit } from "./units.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() {
|
function bindEvents() {
|
||||||
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event)));
|
dom.unitForm.addEventListener("submit", (event) => withStatus(saveUnit(event)));
|
||||||
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event)));
|
dom.sourceForm.addEventListener("submit", (event) => withStatus(saveSource(event)));
|
||||||
|
|
@ -156,31 +125,21 @@ function bindEvents() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dom.tabOps.addEventListener("click", () => switchView("ops"));
|
|
||||||
dom.tabConfig.addEventListener("click", () => switchView("config"));
|
|
||||||
|
|
||||||
document.addEventListener("equipments-updated", () => {
|
document.addEventListener("equipments-updated", () => {
|
||||||
renderUnits();
|
renderUnits();
|
||||||
renderOpsUnits();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("units-loaded", () => {
|
|
||||||
renderOpsUnits();
|
|
||||||
if (!state.selectedOpsUnitId) loadAllEquipmentCards();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
bindEvents();
|
bindEvents();
|
||||||
switchView("ops");
|
|
||||||
renderSelectedNodes();
|
renderSelectedNodes();
|
||||||
updateSelectedPointSummary();
|
updateSelectedPointSummary();
|
||||||
updatePointFilterSummary();
|
updatePointFilterSummary();
|
||||||
renderChart();
|
renderChart();
|
||||||
|
startLogs();
|
||||||
startPointSocket();
|
startPointSocket();
|
||||||
|
|
||||||
await withStatus(loadUnits());
|
await withStatus(loadUnits());
|
||||||
startOps();
|
|
||||||
await withStatus(loadSources());
|
await withStatus(loadSources());
|
||||||
await withStatus(loadEquipments());
|
await withStatus(loadEquipments());
|
||||||
await withStatus(loadEvents());
|
await withStatus(loadEvents());
|
||||||
|
|
|
||||||
|
|
@ -103,11 +103,10 @@ export async function loadApiDoc() {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = dom.apiDocContent.querySelector(`#${CSS.escape(id)}`);
|
dom.apiDocContent.querySelector(`#${CSS.escape(id)}`)?.scrollIntoView({
|
||||||
if (target) {
|
behavior: "smooth",
|
||||||
const offset = target.getBoundingClientRect().top - dom.apiDocContent.getBoundingClientRect().top;
|
block: "start",
|
||||||
dom.apiDocContent.scrollBy({ top: offset, behavior: "smooth" });
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,6 @@ const byId = (id) => document.getElementById(id);
|
||||||
|
|
||||||
export const dom = {
|
export const dom = {
|
||||||
statusText: byId("statusText"),
|
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"),
|
sourceList: byId("sourceList"),
|
||||||
unitList: byId("unitList"),
|
unitList: byId("unitList"),
|
||||||
eventList: byId("eventList"),
|
eventList: byId("eventList"),
|
||||||
|
|
@ -24,6 +15,7 @@ export const dom = {
|
||||||
pointSourceSelect: byId("pointSourceSelect"),
|
pointSourceSelect: byId("pointSourceSelect"),
|
||||||
pointSourceNodeCount: byId("pointSourceNodeCount"),
|
pointSourceNodeCount: byId("pointSourceNodeCount"),
|
||||||
openPointModalBtn: byId("openPointModal"),
|
openPointModalBtn: byId("openPointModal"),
|
||||||
|
logView: byId("logView"),
|
||||||
chartCanvas: byId("chartCanvas"),
|
chartCanvas: byId("chartCanvas"),
|
||||||
chartTitle: byId("chartTitle"),
|
chartTitle: byId("chartTitle"),
|
||||||
chartSummary: byId("chartSummary"),
|
chartSummary: byId("chartSummary"),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { apiFetch } from "./api.js";
|
import { apiFetch } from "./api.js";
|
||||||
import { dom } from "./dom.js";
|
import { dom } from "./dom.js";
|
||||||
import { renderEquipmentKindOptions, renderRoleOptions } from "./roles.js";
|
import { renderRoleOptions } from "./roles.js";
|
||||||
import { clearSelectedPoints, loadPoints, updatePointFilterSummary } from "./points.js";
|
import { clearSelectedPoints, loadPoints, updatePointFilterSummary } from "./points.js";
|
||||||
import { state } from "./state.js";
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
|
@ -81,7 +81,6 @@ export function resetEquipmentForm() {
|
||||||
dom.equipmentForm.reset();
|
dom.equipmentForm.reset();
|
||||||
dom.equipmentId.value = "";
|
dom.equipmentId.value = "";
|
||||||
renderEquipmentUnitOptions("");
|
renderEquipmentUnitOptions("");
|
||||||
dom.equipmentKind.innerHTML = renderEquipmentKindOptions("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEquipmentModal() {
|
function openEquipmentModal() {
|
||||||
|
|
@ -94,9 +93,6 @@ export function closeEquipmentModal() {
|
||||||
|
|
||||||
export function openCreateEquipmentModal() {
|
export function openCreateEquipmentModal() {
|
||||||
resetEquipmentForm();
|
resetEquipmentForm();
|
||||||
if (state.selectedUnitId && dom.equipmentUnitId) {
|
|
||||||
dom.equipmentUnitId.value = state.selectedUnitId;
|
|
||||||
}
|
|
||||||
openEquipmentModal();
|
openEquipmentModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,7 +101,7 @@ function openEditEquipmentModal(equipment) {
|
||||||
dom.equipmentUnitId.value = equipment.unit_id || "";
|
dom.equipmentUnitId.value = equipment.unit_id || "";
|
||||||
dom.equipmentCode.value = equipment.code || "";
|
dom.equipmentCode.value = equipment.code || "";
|
||||||
dom.equipmentName.value = equipment.name || "";
|
dom.equipmentName.value = equipment.name || "";
|
||||||
dom.equipmentKind.innerHTML = renderEquipmentKindOptions(equipment.kind || "");
|
dom.equipmentKind.value = equipment.kind || "";
|
||||||
dom.equipmentDescription.value = equipment.description || "";
|
dom.equipmentDescription.value = equipment.description || "";
|
||||||
openEquipmentModal();
|
openEquipmentModal();
|
||||||
}
|
}
|
||||||
|
|
@ -213,29 +209,6 @@ export function renderEquipments() {
|
||||||
});
|
});
|
||||||
|
|
||||||
actionRow.append(editBtn, deleteBtn);
|
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);
|
dom.equipmentList.appendChild(box);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -262,7 +235,6 @@ export async function loadEquipments() {
|
||||||
|
|
||||||
renderEquipmentUnitOptions(dom.equipmentUnitId?.value || "");
|
renderEquipmentUnitOptions(dom.equipmentUnitId?.value || "");
|
||||||
renderBatchUnitOptions(dom.equipmentBatchUnitId?.value || "");
|
renderBatchUnitOptions(dom.equipmentBatchUnitId?.value || "");
|
||||||
dom.equipmentKind.innerHTML = renderEquipmentKindOptions(dom.equipmentKind?.value || "");
|
|
||||||
renderBindingEquipmentOptions();
|
renderBindingEquipmentOptions();
|
||||||
renderBatchBindingDefaults();
|
renderBatchBindingDefaults();
|
||||||
if (state.selectedEquipmentId && !state.equipmentMap.has(state.selectedEquipmentId)) {
|
if (state.selectedEquipmentId && !state.equipmentMap.has(state.selectedEquipmentId)) {
|
||||||
|
|
|
||||||
|
|
@ -2,80 +2,47 @@ import { apiFetch } from "./api.js";
|
||||||
import { dom } from "./dom.js";
|
import { dom } from "./dom.js";
|
||||||
import { state } from "./state.js";
|
import { state } from "./state.js";
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
|
||||||
|
|
||||||
let _page = 1;
|
|
||||||
let _hasMore = false;
|
|
||||||
let _loading = false;
|
|
||||||
|
|
||||||
function formatTime(value) {
|
function formatTime(value) {
|
||||||
return value || "--";
|
if (!value) {
|
||||||
}
|
return "--";
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadEvents() {
|
export function renderEvents() {
|
||||||
_page = 1;
|
|
||||||
_hasMore = false;
|
|
||||||
_loading = false;
|
|
||||||
dom.eventList.innerHTML = "";
|
dom.eventList.innerHTML = "";
|
||||||
|
|
||||||
const params = new URLSearchParams({ page: "1", page_size: String(PAGE_SIZE) });
|
if (!state.events.length) {
|
||||||
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>';
|
dom.eventList.innerHTML = '<div class="list-item"><div class="muted">暂无事件</div></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
items.forEach((item) => dom.eventList.appendChild(makeCard(item)));
|
state.events.forEach((item) => {
|
||||||
_hasMore = items.length === PAGE_SIZE;
|
const row = document.createElement("div");
|
||||||
_page = 2;
|
row.className = "list-item event-card";
|
||||||
} finally {
|
row.innerHTML = `
|
||||||
_loading = false;
|
<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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prependEvent(item) {
|
export async function loadEvents() {
|
||||||
if (state.selectedUnitId && item.unit_id !== state.selectedUnitId) return;
|
const params = new URLSearchParams({
|
||||||
|
page: "1",
|
||||||
|
page_size: "20",
|
||||||
|
});
|
||||||
|
|
||||||
const placeholder = dom.eventList.querySelector(".list-item");
|
if (state.selectedUnitId) {
|
||||||
if (placeholder) placeholder.remove();
|
params.set("unit_id", state.selectedUnitId);
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
const response = await apiFetch(`/api/event?${params.toString()}`);
|
||||||
|
state.events = response.data || [];
|
||||||
|
renderEvents();
|
||||||
|
}
|
||||||
|
|
|
||||||
105
web/js/logs.js
105
web/js/logs.js
|
|
@ -1,26 +1,33 @@
|
||||||
import { appendChartPoint } from "./chart.js";
|
import { appendChartPoint } from "./chart.js";
|
||||||
import { dom } from "./dom.js";
|
import { dom } from "./dom.js";
|
||||||
import { prependEvent } from "./events.js";
|
|
||||||
import { formatValue } from "./points.js";
|
import { formatValue } from "./points.js";
|
||||||
import { state } from "./state.js";
|
import { state } from "./state.js";
|
||||||
import { renderUnits } from "./units.js";
|
|
||||||
import { showToast } from "./api.js";
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
return text
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseLogLine(line) {
|
function parseLogLine(line) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
|
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||||
try { return JSON.parse(trimmed); } catch { return null; }
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendLog(line) {
|
export function appendLog(line) {
|
||||||
if (!dom.logView) return;
|
|
||||||
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
|
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
const parsed = parseLogLine(line);
|
const parsed = parseLogLine(line);
|
||||||
|
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
div.className = "log-line";
|
div.className = "log-line";
|
||||||
div.textContent = line;
|
div.textContent = line;
|
||||||
|
|
@ -32,51 +39,31 @@ export function appendLog(line) {
|
||||||
`<span class="level">${escapeHtml(levelRaw || "LOG")}</span>`,
|
`<span class="level">${escapeHtml(levelRaw || "LOG")}</span>`,
|
||||||
parsed.timestamp ? `<span class="muted"> ${escapeHtml(parsed.timestamp)}</span>` : "",
|
parsed.timestamp ? `<span class="muted"> ${escapeHtml(parsed.timestamp)}</span>` : "",
|
||||||
parsed.target ? `<span class="muted"> ${escapeHtml(parsed.target)}</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("");
|
].join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
dom.logView.appendChild(div);
|
dom.logView.appendChild(div);
|
||||||
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
|
if (atBottom) {
|
||||||
|
dom.logView.scrollTop = dom.logView.scrollHeight;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startLogs() {
|
export function startLogs() {
|
||||||
if (state.logSource) return;
|
if (state.logSource) {
|
||||||
|
state.logSource.close();
|
||||||
|
}
|
||||||
|
|
||||||
state.logSource = new EventSource("/api/logs/stream");
|
state.logSource = new EventSource("/api/logs/stream");
|
||||||
state.logSource.addEventListener("log", (event) => {
|
state.logSource.addEventListener("log", (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
(data.lines || []).forEach(appendLog);
|
(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() {
|
export function startPointSocket() {
|
||||||
|
|
@ -84,15 +71,14 @@ export function startPointSocket() {
|
||||||
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
|
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
|
||||||
state.pointSocket = ws;
|
state.pointSocket = ws;
|
||||||
|
|
||||||
ws.onopen = () => setWsStatus(true);
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data);
|
const payload = JSON.parse(event.data);
|
||||||
if (payload.type === "PointNewValue" || payload.type === "point_new_value") {
|
if (payload.type !== "PointNewValue" && payload.type !== "point_new_value") {
|
||||||
const data = payload.data;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// config view point table
|
const data = payload.data;
|
||||||
const entry = state.pointEls.get(data.point_id);
|
const entry = state.pointEls.get(data.point_id);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
entry.value.textContent = formatValue(data);
|
entry.value.textContent = formatValue(data);
|
||||||
|
|
@ -101,44 +87,15 @@ export function startPointSocket() {
|
||||||
entry.time.textContent = data.timestamp || "--";
|
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) {
|
if (state.chartPointId === data.point_id) {
|
||||||
appendChartPoint(data);
|
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 {
|
} catch {
|
||||||
// ignore malformed messages
|
// ignore malformed messages
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
setWsStatus(false);
|
|
||||||
window.setTimeout(startPointSocket, 2000);
|
window.setTimeout(startPointSocket, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => setWsStatus(false);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
218
web/js/ops.js
218
web/js/ops.js
|
|
@ -1,218 +0,0 @@
|
||||||
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 ? "自动控制运行中,请先停止自动" : "";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { renderRoleOptions } from "./roles.js";
|
||||||
import { state } from "./state.js";
|
import { state } from "./state.js";
|
||||||
|
|
||||||
function updatePointSourceNodeCount() {
|
function updatePointSourceNodeCount() {
|
||||||
const count = dom.nodeTree.querySelectorAll("details").length;
|
const count = dom.nodeTree.querySelectorAll("details, summary").length;
|
||||||
dom.pointSourceNodeCount.textContent = `Nodes: ${count}`;
|
dom.pointSourceNodeCount.textContent = `Nodes: ${count}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
export const SIGNAL_ROLE_OPTIONS = [
|
export const SIGNAL_ROLE_OPTIONS = [
|
||||||
{ value: "", label: "Unset" },
|
{ value: "", label: "Unset" },
|
||||||
{ value: "rem", label: "REM Remote Enable" },
|
{ value: "remote_status", label: "Remote Status" },
|
||||||
{ value: "run", label: "RUN Running" },
|
{ value: "run_status", label: "Run Status" },
|
||||||
{ value: "flt", label: "FLT Fault" },
|
{ value: "fault_status", label: "Fault Status" },
|
||||||
{ value: "ii", label: "II Current" },
|
{ 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: "start_cmd", label: "Start Command" },
|
{ value: "start_cmd", label: "Start Command" },
|
||||||
{ value: "stop_cmd", label: "Stop Command" },
|
{ value: "stop_cmd", label: "Stop Command" },
|
||||||
];
|
{ value: "reset_cmd", label: "Reset Command" },
|
||||||
|
{ value: "runtime_value", label: "Runtime Value" },
|
||||||
export const EQUIPMENT_KIND_OPTIONS = [
|
{ value: "counter_value", label: "Counter Value" },
|
||||||
{ value: "", label: "Unset" },
|
|
||||||
{ value: "coal_feeder", label: "Coal Feeder" },
|
|
||||||
{ value: "distributor", label: "Distributor" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function renderRoleOptions(selected = "") {
|
export function renderRoleOptions(selected = "") {
|
||||||
|
|
@ -20,10 +22,3 @@ export function renderRoleOptions(selected = "") {
|
||||||
return `<option value="${item.value}" ${isSelected}>${item.label}</option>`;
|
return `<option value="${item.value}" ${isSelected}>${item.label}</option>`;
|
||||||
}).join("");
|
}).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("");
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,7 @@ export const state = {
|
||||||
chartPointId: null,
|
chartPointId: null,
|
||||||
chartPointName: "",
|
chartPointName: "",
|
||||||
chartData: [],
|
chartData: [],
|
||||||
|
logSource: null,
|
||||||
pointSocket: null,
|
pointSocket: null,
|
||||||
apiDocLoaded: false,
|
apiDocLoaded: false,
|
||||||
runtimes: new Map(), // unit_id -> UnitRuntime
|
|
||||||
activeView: "ops", // "ops" | "config"
|
|
||||||
opsPointEls: new Map(), // point_id -> { valueEl, qualityEl }
|
|
||||||
logSource: null,
|
|
||||||
selectedOpsUnitId: null,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -74,29 +74,6 @@ async function selectUnit(unitId) {
|
||||||
await loadEvents();
|
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() {
|
export function renderUnits() {
|
||||||
dom.unitList.innerHTML = "";
|
dom.unitList.innerHTML = "";
|
||||||
|
|
||||||
|
|
@ -109,15 +86,13 @@ export function renderUnits() {
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
const selected = state.selectedUnitId === unit.id;
|
const selected = state.selectedUnitId === unit.id;
|
||||||
card.className = `list-item unit-card ${selected ? "selected" : ""}`;
|
card.className = `list-item unit-card ${selected ? "selected" : ""}`;
|
||||||
const runtime = state.runtimes.get(unit.id);
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<strong>${unit.code}</strong>
|
<strong>${unit.code}</strong>
|
||||||
${runtimeBadge(runtime)}
|
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "ENABLED" : "DISABLED"}</span>
|
||||||
<span class="badge ${unit.enabled ? "" : "offline"}">${unit.enabled ? "EN" : "DIS"}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div>${unit.name}</div>
|
<div>${unit.name}</div>
|
||||||
<div class="muted">设备 ${equipmentCount(unit.id)} 台 | Acc ${runtime ? Math.floor(runtime.accumulated_run_sec / 1000) : 0}s</div>
|
<div class="muted">设备 ${equipmentCount(unit.id)} 台</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="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>
|
<div class="row unit-card-actions"></div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -149,32 +124,6 @@ export function renderUnits() {
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.append(editBtn, deleteBtn);
|
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);
|
dom.unitList.appendChild(card);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +140,6 @@ export async function loadUnits() {
|
||||||
renderUnits();
|
renderUnits();
|
||||||
renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId);
|
renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId);
|
||||||
renderUnitOptions(dom.equipmentBatchUnitId?.value || "", dom.equipmentBatchUnitId);
|
renderUnitOptions(dom.equipmentBatchUnitId?.value || "", dom.equipmentBatchUnitId);
|
||||||
document.dispatchEvent(new Event("units-loaded"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveUnit(event) {
|
export async function saveUnit(event) {
|
||||||
|
|
|
||||||
337
web/styles.css
337
web/styles.css
|
|
@ -51,20 +51,7 @@ body {
|
||||||
.status {
|
.status {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
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 {
|
.topbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -72,30 +59,6 @@ body {
|
||||||
gap: 10px;
|
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 {
|
.link-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -116,33 +79,19 @@ body {
|
||||||
|
|
||||||
/* ── Grid Layout ────────────────────────────────── */
|
/* ── Grid Layout ────────────────────────────────── */
|
||||||
|
|
||||||
.grid-ops,
|
.grid {
|
||||||
.grid-config {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr);
|
||||||
|
grid-template-rows: 1fr 380px;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
height: calc(100vh - var(--topbar-h));
|
height: calc(100vh - var(--topbar-h));
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-config {
|
.panel.top-left { grid-column: 1; grid-row: 1; }
|
||||||
grid-template-columns: 320px minmax(0, 2fr) minmax(0, 1.3fr);
|
.panel.top-right { grid-column: 2 / 4; grid-row: 1; }
|
||||||
grid-template-rows: 1fr 380px;
|
.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-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 {
|
.panel {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|
@ -170,150 +119,6 @@ body {
|
||||||
border-top: 1px solid var(--border-light);
|
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 Header ───────────────────────────────── */
|
||||||
|
|
||||||
.panel-head {
|
.panel-head {
|
||||||
|
|
@ -323,16 +128,6 @@ body {
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
border-bottom: 1px solid var(--border-light);
|
border-bottom: 1px solid var(--border-light);
|
||||||
flex-shrink: 0;
|
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 {
|
h2, h3 {
|
||||||
|
|
@ -790,7 +585,6 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden { display: none !important; }
|
|
||||||
.modal.hidden { display: none; }
|
.modal.hidden { display: none; }
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
|
|
@ -866,7 +660,8 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unit-list {
|
.unit-list,
|
||||||
|
.event-list {
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -875,38 +670,20 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-card {
|
.event-card {
|
||||||
padding: 4px 8px;
|
cursor: default;
|
||||||
font-size: 12px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-card:hover {
|
.event-card:hover {
|
||||||
background: var(--surface-hover, var(--surface));
|
background: var(--surface);
|
||||||
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-meta {
|
.event-section {
|
||||||
display: flex;
|
flex-basis: 42%;
|
||||||
align-items: baseline;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-type {
|
.log-section {
|
||||||
overflow: hidden;
|
flex-basis: 58%;
|
||||||
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 {
|
.equipment-select-row {
|
||||||
|
|
@ -968,10 +745,6 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-drawer {
|
|
||||||
width: min(1100px, 96vw);
|
|
||||||
}
|
|
||||||
|
|
||||||
.equipment-drawer {
|
.equipment-drawer {
|
||||||
width: min(1120px, 96vw);
|
width: min(1120px, 96vw);
|
||||||
}
|
}
|
||||||
|
|
@ -1227,71 +1000,6 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
color: var(--text);
|
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 ────────────────────────────────────── */
|
/* ── Scrollbar ────────────────────────────────────── */
|
||||||
|
|
||||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
|
@ -1302,8 +1010,7 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
/* ── Responsive ───────────────────────────────────── */
|
/* ── Responsive ───────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.grid-config,
|
.grid {
|
||||||
.grid-ops {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: auto auto auto auto;
|
grid-template-rows: auto auto auto auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
@ -1311,9 +1018,9 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
body { height: auto; overflow: auto; }
|
body { height: auto; overflow: auto; }
|
||||||
.panel.top-left { min-height: 200px; }
|
.panel.top-left { min-height: 200px; }
|
||||||
.panel.top-right { min-height: 300px; }
|
.panel.top-right { min-height: 300px; }
|
||||||
.grid-config .panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; }
|
.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; }
|
.panel.bottom-middle { grid-column: 1; grid-row: 4; min-height: 200px; }
|
||||||
.grid-config .panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; }
|
.panel.bottom-right { grid-column: 1; grid-row: 5; min-height: 320px; }
|
||||||
.drawer { width: 100vw; }
|
.drawer { width: 100vw; }
|
||||||
.drawer-body { grid-template-columns: 1fr; }
|
.drawer-body { grid-template-columns: 1fr; }
|
||||||
.equipment-layout { grid-template-columns: 1fr; }
|
.equipment-layout { grid-template-columns: 1fr; }
|
||||||
|
|
@ -1322,5 +1029,3 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
.doc-view { padding: 0; }
|
.doc-view { padding: 0; }
|
||||||
.doc-card { border-left: none; border-right: none; }
|
.doc-card { border-left: none; border-right: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue